Skip to content

Commit 447a670

Browse files
committed
Fix notification toast stylings on Contact page
1 parent f0b5f81 commit 447a670

6 files changed

Lines changed: 234 additions & 18 deletions

File tree

src/components/Pages/Contact/client/__tests__/formSubmission.spec.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
22
import type { RenderContactFormContext } from './testUtils'
3-
import { appendUploadFiles } from '@components/Pages/Contact/client/formSubmission'
3+
import {
4+
appendUploadFiles,
5+
applyContactPreviewState,
6+
resolveContactPreviewState,
7+
} from '@components/Pages/Contact/client/formSubmission'
48

59
const contactSubmitMock = vi.hoisted(() => vi.fn())
610
const isInputErrorMock = vi.hoisted(() => vi.fn())
@@ -58,6 +62,15 @@ describe('ContactForm submission', () => {
5862
})
5963
})
6064

65+
it('resolves supported contact preview states from query params', () => {
66+
expect(resolveContactPreviewState('?contactState=success')).toBe('success')
67+
expect(resolveContactPreviewState('?contactState=error')).toBe('error')
68+
expect(resolveContactPreviewState('?contactState=loading')).toBe('loading')
69+
expect(resolveContactPreviewState('?contactState=validation')).toBe('validation')
70+
expect(resolveContactPreviewState('?contactState=unknown')).toBeNull()
71+
expect(resolveContactPreviewState('')).toBeNull()
72+
})
73+
6174
const fillValidFields = (context: RenderContactFormContext): void => {
6275
const { elements, window } = context
6376
elements.fields.name.input.value = 'Webstack Builders'
@@ -78,6 +91,73 @@ describe('ContactForm submission', () => {
7891
})
7992
})
8093

94+
it('applies success preview state for styling', async () => {
95+
await renderContactForm(async context => {
96+
applyContactPreviewState(context.elements, {
97+
state: 'success',
98+
rootElement: context.element,
99+
})
100+
101+
expect(context.element.getAttribute('data-contact-state')).toBe('success')
102+
expect(context.elements.messages.style.display).toBe('block')
103+
expect(context.elements.successMessage.classList.contains('hidden')).toBe(false)
104+
expect(context.elements.errorMessage.classList.contains('hidden')).toBe(true)
105+
expect(context.elements.submitBtn.disabled).toBe(false)
106+
})
107+
})
108+
109+
it('applies error preview state for styling', async () => {
110+
await renderContactForm(async context => {
111+
applyContactPreviewState(context.elements, {
112+
state: 'error',
113+
rootElement: context.element,
114+
})
115+
116+
expect(context.element.getAttribute('data-contact-state')).toBe('error')
117+
expect(context.elements.messages.style.display).toBe('block')
118+
expect(context.elements.errorMessage.classList.contains('hidden')).toBe(false)
119+
expect(context.elements.errorText.textContent).toContain('Unable to send message')
120+
expect(context.elements.successMessage.classList.contains('hidden')).toBe(true)
121+
})
122+
})
123+
124+
it('applies loading preview state for styling', async () => {
125+
await renderContactForm(async context => {
126+
applyContactPreviewState(context.elements, {
127+
state: 'loading',
128+
rootElement: context.element,
129+
})
130+
131+
expect(context.element.getAttribute('data-contact-state')).toBe('loading')
132+
expect(context.elements.submitBtn.disabled).toBe(true)
133+
expect(context.elements.btnText.style.display).toBe('none')
134+
expect(context.elements.btnLoading.classList.contains('hidden')).toBe(false)
135+
expect(context.elements.messages.style.display).toBe('none')
136+
})
137+
})
138+
139+
it('applies validation preview state for styling', async () => {
140+
await renderContactForm(async context => {
141+
applyContactPreviewState(context.elements, {
142+
state: 'validation',
143+
rootElement: context.element,
144+
})
145+
146+
expect(context.element.getAttribute('data-contact-state')).toBe('validation')
147+
expect(context.elements.formErrorBanner.classList.contains('hidden')).toBe(false)
148+
expect(context.elements.messages.style.display).toBe('block')
149+
expect(context.elements.errorMessage.classList.contains('hidden')).toBe(false)
150+
expect(context.elements.errorText.textContent).toBe(
151+
'Please correct the highlighted fields and try again.'
152+
)
153+
expect(context.elements.fields.name.feedback.textContent).toBe('Please enter your name')
154+
expect(context.elements.fields.email.feedback.textContent).toBe(
155+
'Please enter a valid email address.'
156+
)
157+
expect(context.elements.fields.message.feedback.textContent).toBe('Please describe your project')
158+
})
159+
})
160+
81161
it('submits successfully and resets UI state', async () => {
82162
await renderContactForm(async context => {
83163
fillValidFields(context)
@@ -136,7 +216,7 @@ describe('ContactForm submission', () => {
136216
expect(contactSubmitMock).toHaveBeenCalled()
137217
expect(context.elements.messages.style.display).toBe('block')
138218
expect(context.elements.errorMessage.classList.contains('hidden')).toBe(false)
139-
expect(context.elements.errorMessage.style.display).toBe('flex')
219+
expect(context.elements.errorMessage.style.display).toBe('block')
140220
expect(context.elements.errorText.textContent).toBe('Server error')
141221
expect(context.elements.successMessage.classList.contains('hidden')).toBe(true)
142222
expect(context.elements.charCount.textContent).toBe('42')

src/components/Pages/Contact/client/formSubmission.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@ import type { ContactFormElements } from './@types'
1212
import { validateEmailField } from './email'
1313
import { actions, isInputError } from 'astro:actions'
1414
import type { UploadController } from './upload'
15+
import { queryContactFormGeneratedFieldError, queryContactFormGenericFields } from './selectors'
16+
17+
export type ContactUiState = 'idle' | 'loading' | 'success' | 'error' | 'validation'
18+
19+
export const contactPreviewStates = ['loading', 'success', 'error', 'validation'] as const
20+
21+
type ContactPreviewState = (typeof contactPreviewStates)[number]
1522

1623
interface SubmissionControllers {
1724
labelController: LabelController
1825
uploadController?: UploadController | null
26+
rootElement?: HTMLElement | null
1927
}
2028

2129
export const appendUploadFiles = (
@@ -33,6 +41,13 @@ const hideMessages = (elements: ContactFormElements): void => {
3341
elements.messages.style.display = 'none'
3442
}
3543

44+
const setContactState = (
45+
rootElement: HTMLElement | null | undefined,
46+
state: ContactUiState
47+
): void => {
48+
rootElement?.setAttribute('data-contact-state', state)
49+
}
50+
3651
const showSuccessMessage = (elements: ContactFormElements): void => {
3752
elements.messages.style.display = 'block'
3853
elements.successMessage.classList.remove('hidden')
@@ -43,7 +58,7 @@ const showErrorMessage = (elements: ContactFormElements, message: string): void
4358
elements.messages.style.display = 'block'
4459
elements.successMessage.classList.add('hidden')
4560
elements.errorMessage.classList.remove('hidden')
46-
elements.errorMessage.style.display = 'flex'
61+
elements.errorMessage.style.display = 'block'
4762
elements.errorText.textContent = message
4863
}
4964

@@ -61,6 +76,72 @@ const setLoading = (elements: ContactFormElements, loading: boolean): void => {
6176
}
6277
}
6378

79+
const clearGeneratedGenericErrors = (elements: ContactFormElements): void => {
80+
queryContactFormGenericFields(elements.form).forEach(field => {
81+
field.classList.remove('error')
82+
field.setAttribute('aria-invalid', 'false')
83+
field.removeAttribute('aria-errormessage')
84+
queryContactFormGeneratedFieldError(field)?.remove()
85+
})
86+
}
87+
88+
const resetSubmissionFeedback = (elements: ContactFormElements): void => {
89+
hideMessages(elements)
90+
hideErrorBanner(elements.formErrorBanner)
91+
setLoading(elements, false)
92+
clearGeneratedGenericErrors(elements)
93+
Object.values(elements.fields).forEach(clearFieldFeedback)
94+
}
95+
96+
export const resolveContactPreviewState = (search: string): ContactPreviewState | null => {
97+
const params = new URLSearchParams(search)
98+
const previewState = params.get('contactState')?.trim().toLowerCase()
99+
100+
if (!previewState) {
101+
return null
102+
}
103+
104+
return contactPreviewStates.includes(previewState as ContactPreviewState)
105+
? (previewState as ContactPreviewState)
106+
: null
107+
}
108+
109+
export const applyContactPreviewState = (
110+
elements: ContactFormElements,
111+
options: {
112+
state: ContactPreviewState
113+
rootElement?: HTMLElement | null
114+
}
115+
): void => {
116+
resetSubmissionFeedback(elements)
117+
118+
switch (options.state) {
119+
case 'loading':
120+
setLoading(elements, true)
121+
setContactState(options.rootElement, 'loading')
122+
return
123+
124+
case 'success':
125+
showSuccessMessage(elements)
126+
setContactState(options.rootElement, 'success')
127+
return
128+
129+
case 'error':
130+
showErrorMessage(elements, 'Unable to send message. Please try again later.')
131+
setContactState(options.rootElement, 'error')
132+
return
133+
134+
case 'validation':
135+
showFieldFeedback(elements.fields.name, 'Please enter your name', 'error')
136+
showFieldFeedback(elements.fields.email, 'Please enter a valid email address.', 'error')
137+
showFieldFeedback(elements.fields.message, 'Please describe your project', 'error')
138+
showErrorBanner(elements.formErrorBanner)
139+
showErrorMessage(elements, 'Please correct the highlighted fields and try again.')
140+
setContactState(options.rootElement, 'validation')
141+
return
142+
}
143+
}
144+
64145
const runFieldValidations = (elements: ContactFormElements): boolean => {
65146
const validations = [
66147
validateEmailField(elements.fields.email),
@@ -129,18 +210,21 @@ export const initFormSubmission = (
129210

130211
if (!isValid) {
131212
showErrorBanner(elements.formErrorBanner)
213+
setContactState(controllers.rootElement, 'validation')
132214
return
133215
}
134216

135217
hideErrorBanner(elements.formErrorBanner)
136218
setLoading(elements, true)
137219
hideMessages(elements)
220+
setContactState(controllers.rootElement, 'loading')
138221

139222
try {
140223
const formData = new FormData(elements.form)
141224
appendUploadFiles(formData, controllers.uploadController)
142225
const { data, error } = await actions.contact.submit(formData)
143226
if (showActionInputErrors(elements, error)) {
227+
setContactState(controllers.rootElement, 'validation')
144228
return
145229
}
146230

@@ -149,10 +233,12 @@ export const initFormSubmission = (
149233
elements,
150234
error?.message || data?.message || 'An error occurred while sending your message.'
151235
)
236+
setContactState(controllers.rootElement, 'error')
152237
return
153238
}
154239

155240
showSuccessMessage(elements)
241+
setContactState(controllers.rootElement, 'success')
156242

157243
elements.submitBtn.dispatchEvent(
158244
new CustomEvent('confetti:fire', {
@@ -165,12 +251,14 @@ export const initFormSubmission = (
165251
} catch (error) {
166252
handleScriptError(error, context)
167253
showErrorMessage(elements, 'Unable to send message. Please try again later.')
254+
setContactState(controllers.rootElement, 'error')
168255
} finally {
169256
setLoading(elements, false)
170257
}
171258
} catch (error) {
172259
handleScriptError(error, context)
173260
setLoading(elements, false)
261+
setContactState(controllers.rootElement, 'error')
174262
}
175263
})
176264
}

src/components/Pages/Contact/client/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { ContactFormConfig } from './@types'
1313
import { initCharacterCounter, initUploadPlaceholder } from './utils'
1414
import { initLabelHandlers, type LabelController } from './feedback'
1515
import { initEmailValidationHandler } from './email'
16-
import { initFormSubmission } from './formSubmission'
16+
import { applyContactPreviewState, initFormSubmission, resolveContactPreviewState } from './formSubmission'
1717
import { initGenericValidation, initNameLengthHandler, initMssgLengthHandler } from './validation'
1818
import { defineCustomElement } from '@components/scripts/utils'
1919
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
@@ -75,7 +75,9 @@ export class ContactFormElement extends LitElement {
7575
initFormSubmission(elements, {
7676
labelController: this.labelController,
7777
uploadController: this.uploadController,
78+
rootElement: this,
7879
})
80+
this.applyPreviewStateFromQuery(elements)
7981
this.initializeStickySidebar()
8082
this.setViewTransitionsHandlers()
8183
} catch (error) {
@@ -106,6 +108,22 @@ export class ContactFormElement extends LitElement {
106108
projectTypeSelect.value = projectType
107109
}
108110

111+
private applyPreviewStateFromQuery(elements: ReturnType<typeof getContactFormElements>): void {
112+
if (typeof window === 'undefined' || import.meta.env.PROD) {
113+
return
114+
}
115+
116+
const previewState = resolveContactPreviewState(window.location.search)
117+
if (!previewState) {
118+
return
119+
}
120+
121+
applyContactPreviewState(elements, {
122+
state: previewState,
123+
rootElement: this,
124+
})
125+
}
126+
109127
/**
110128
* Set up JS-driven sticky sidebar for the contact info card.
111129
* Container = the grid parent (data-sticky-container); sidebar = the card (data-sticky-sidebar).

src/components/Pages/Contact/index.astro

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
---
2+
/**
3+
* Contact Page
4+
*
5+
* Use query parameters to view prefilled form states for testing:
6+
* - /contact/?projectType=web-development (prefills project type dropdown)
7+
* - /contact/?formState=success (shows success message)
8+
* - /contact/?formState=error (shows error message)
9+
* - /contact/?formState=idle (shows idle state)
10+
*
11+
* - /contact/?contactState=success
12+
* - /contact/?contactState=error
13+
* - /contact/?contactState=validation
14+
* - /contact/?contactState=loading
15+
*/
216
import Confetti from '@components/Animations/Confetti/index.astro'
317
import GDPRConsent from '@components/Consent/Checkbox/index.astro'
418
import Icon from '@components/Icon/index.astro'
@@ -92,7 +106,7 @@ export type Props = {
92106
const { content, prefilledProjectType = '' } = Astro.props
93107
---
94108

95-
<contact-form>
109+
<contact-form data-contact-state="idle">
96110
<div class="min-h-screen md:mb-16">
97111
{/** Google Maps embed */}
98112
{/* <Map /> */}
@@ -369,9 +383,14 @@ const { content, prefilledProjectType = '' } = Astro.props
369383
</form>
370384

371385
{/** Form Messages */}
372-
<div id="formMessages" class="mt-8 rounded-xl overflow-hidden" style="display: none;">
373-
<div class="message-success hidden" role="status" aria-live="polite" aria-atomic="true">
374-
<div class="flex items-start p-6 bg-success-inverse border border-success gap-4">
386+
<div id="formMessages" class="mt-8" style="display: none;">
387+
<div
388+
class="message-success hidden w-full rounded-xl border border-success bg-success-inverse"
389+
role="status"
390+
aria-live="polite"
391+
aria-atomic="true"
392+
>
393+
<div class="flex items-start gap-4 p-6">
375394
<div
376395
class="shrink-0 w-6 h-6 bg-success rounded-full flex items-center justify-center text-sm font-bold"
377396
>
@@ -385,17 +404,28 @@ const { content, prefilledProjectType = '' } = Astro.props
385404
</div>
386405
</div>
387406
</div>
388-
<div class="message-error hidden" role="alert" aria-live="assertive" aria-atomic="true">
389-
<div class="flex items-start p-6 bg-danger-offset border border-danger gap-4">
407+
<div
408+
class="message-error hidden w-full rounded-xl border border-danger bg-danger-inverse text-danger"
409+
role="alert"
410+
aria-live="assertive"
411+
aria-atomic="true"
412+
>
413+
<div class="flex items-start gap-4 p-6">
390414
<div
391415
class="shrink-0 w-6 h-6 bg-danger rounded-full flex items-center justify-center"
392416
>
393417
<Icon icon="warning" color="white" size={3.5} />
394418
</div>
395419
<div>
396420
<h3 class="text-lg font-semibold text-danger mb-2">Oops!</h3>
397-
<p id="errorMessage">
398-
{content.form.errorText} <a href={`mailto:${content.form.errorEmail}`}>{content.form.errorEmail}</a>
421+
<p id="errorMessage" class="text-danger">
422+
{content.form.errorText}{' '}
423+
<a
424+
href={`mailto:${content.form.errorEmail}`}
425+
class="text-danger underline decoration-current underline-offset-2"
426+
>
427+
{content.form.errorEmail}
428+
</a>
399429
</p>
400430
</div>
401431
</div>

0 commit comments

Comments
 (0)