Skip to content

Commit a198d92

Browse files
committed
Update styling on notification toasts for my-data page
1 parent 8db4b96 commit a198d92

3 files changed

Lines changed: 371 additions & 71 deletions

File tree

src/components/Pages/MyData/client/__tests__/index.spec.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ describe('PrivacyForm behavior', () => {
113113
email: 'test@example.com',
114114
requestType: 'ACCESS',
115115
})
116-
expect(elements.accessMessage.textContent).toBe('Access request sent.')
116+
expect(elements.accessMessage.textContent).toContain('Request Sent')
117+
expect(elements.accessMessage.textContent).toContain('Access request sent.')
117118
expect(elements.accessMessage.classList.contains('hidden')).toBe(false)
118119
expect(elements.accessMessage.classList.contains('border-success')).toBe(true)
119120
expect(elements.accessEmailInput.value).toBe('')
@@ -145,18 +146,27 @@ describe('PrivacyForm behavior', () => {
145146

146147
expect(elements.accessForm.dataset.privacyState).toBe('loading')
147148
expect(elements.accessForm.getAttribute('aria-busy')).toBe('true')
148-
expect(elements.accessMessage.textContent).toBe('Sending request...')
149-
expect(elements.accessMessage.classList.contains('border-info')).toBe(true)
150149
expect(accessSubmitButton).toBeInstanceOf(HTMLButtonElement)
151150
expect((accessSubmitButton as HTMLButtonElement).disabled).toBe(true)
152151

152+
const accessLoadingToast = element.querySelector('#access-preview-toast-loading')
153+
expect(accessLoadingToast).not.toBeNull()
154+
expect(accessLoadingToast?.classList.contains('hidden')).toBe(false)
155+
expect(accessLoadingToast?.textContent).toContain('Sending Request')
156+
expect(accessLoadingToast?.textContent).toContain('Your request is being prepared and submitted.')
157+
153158
expect(elements.deleteForm.dataset.privacyState).toBe('validation')
154-
expect(elements.deleteMessage.textContent).toBe(
155-
'Please enter a valid email address and confirm the deletion request.'
156-
)
157-
expect(elements.deleteMessage.classList.contains('border-danger')).toBe(true)
158159
expect(elements.deleteEmailInput.getAttribute('aria-invalid')).toBe('true')
159160
expect(elements.deleteConfirmCheckbox.getAttribute('aria-invalid')).toBe('true')
161+
162+
const deleteValidationToast = element.querySelector('#delete-preview-toast-validation')
163+
expect(deleteValidationToast).not.toBeNull()
164+
expect(deleteValidationToast?.classList.contains('hidden')).toBe(false)
165+
expect(deleteValidationToast?.textContent).toContain('Check Your Details')
166+
expect(deleteValidationToast?.textContent).toContain(
167+
'Enter a valid email address and confirm the deletion request before submitting.'
168+
)
169+
160170
expect(requestDataMock).not.toHaveBeenCalled()
161171
},
162172
})
@@ -187,7 +197,8 @@ describe('PrivacyForm behavior', () => {
187197
await flushMicrotasks()
188198

189199
expect(requestDataMock).not.toHaveBeenCalled()
190-
expect(elements.deleteMessage.textContent).toBe(
200+
expect(elements.deleteMessage.textContent).toContain('Check Your Details')
201+
expect(elements.deleteMessage.textContent).toContain(
191202
'Please confirm you understand the deletion request.'
192203
)
193204
expect(elements.deleteMessage.classList.contains('border-danger')).toBe(true)

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

Lines changed: 153 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type DsarVerifyResult =
1818

1919
type RequestDataResult = { message: string }
2020

21+
type RequestToastTone = 'success' | 'error' | 'info'
22+
2123
const requestPreviewStates = ['loading', 'success', 'error', 'validation'] as const
2224

2325
const requestPreviewQueryParams: Record<RequestFormType, string> = {
@@ -67,6 +69,13 @@ export class PrivacyFormElement extends LitElement {
6769
private deleteConfirmCheckbox!: HTMLInputElement
6870
private deleteMessage!: HTMLElement
6971

72+
private getPreviewToastElements(requestType: RequestType): HTMLElement[] {
73+
const prefix = requestType === 'ACCESS' ? 'access' : 'delete'
74+
return ['success', 'loading', 'error', 'validation']
75+
.map(state => this.querySelector<HTMLElement>(`#${prefix}-preview-toast-${state}`))
76+
.filter((el): el is HTMLElement => el !== null)
77+
}
78+
7079
override connectedCallback(): void {
7180
super.connectedCallback()
7281

@@ -146,9 +155,20 @@ export class PrivacyFormElement extends LitElement {
146155
})
147156
}
148157

149-
private setRequestState(form: HTMLFormElement, state: RequestPreviewState | 'idle'): void {
158+
private setRequestState(
159+
form: HTMLFormElement,
160+
state: RequestPreviewState | 'idle',
161+
requestType?: RequestType
162+
): void {
150163
form.dataset.privacyState = state
151164
form.setAttribute('aria-busy', String(state === 'loading'))
165+
166+
if (!requestType) {
167+
return
168+
}
169+
170+
const rootAttributeName = requestType === 'ACCESS' ? 'data-access-state' : 'data-delete-state'
171+
this.setAttribute(rootAttributeName, state)
152172
}
153173

154174
private setSubmitLoading(form: HTMLFormElement, loading: boolean): void {
@@ -168,20 +188,116 @@ export class PrivacyFormElement extends LitElement {
168188
this.deleteConfirmCheckbox.setAttribute('aria-invalid', String(invalid))
169189
}
170190

191+
private hidePreviewToast(requestType: RequestType): void {
192+
for (const el of this.getPreviewToastElements(requestType)) {
193+
el.classList.add('hidden')
194+
}
195+
}
196+
197+
private resolveRequestToastConfig(
198+
requestType: RequestType,
199+
message: string,
200+
type: RequestToastTone
201+
): {
202+
title: string
203+
icon: 'check-stylized' | 'warning' | 'spinner'
204+
containerClasses: string[]
205+
iconContainerClasses: string[]
206+
titleClasses: string[]
207+
} {
208+
if (type === 'success') {
209+
return {
210+
title: requestType === 'ACCESS' ? 'Request Sent' : 'Deletion Request Sent',
211+
icon: 'check-stylized',
212+
containerClasses: ['border-success', 'bg-success-inverse', 'text-success'],
213+
iconContainerClasses: ['bg-success', 'text-content-inverse'],
214+
titleClasses: ['text-success'],
215+
}
216+
}
217+
218+
if (type === 'info') {
219+
return {
220+
title: 'Sending Request',
221+
icon: 'spinner',
222+
containerClasses: ['border-info', 'bg-info-inverse', 'text-info'],
223+
iconContainerClasses: ['bg-info', 'text-content-inverse'],
224+
titleClasses: ['text-info'],
225+
}
226+
}
227+
228+
const isValidationMessage =
229+
message.includes('valid email') || message.includes('confirm you understand') || message.includes('confirm the deletion request')
230+
231+
return {
232+
title: isValidationMessage
233+
? 'Check Your Details'
234+
: requestType === 'ACCESS'
235+
? 'Request Failed'
236+
: 'Deletion Request Failed',
237+
icon: 'warning',
238+
containerClasses: ['border-danger', 'bg-danger-inverse', 'text-danger'],
239+
iconContainerClasses: ['bg-danger', 'text-content-inverse'],
240+
titleClasses: ['text-danger'],
241+
}
242+
}
243+
244+
private renderRequestToast(target: HTMLElement, requestType: RequestType, message: string, type: RequestToastTone): void {
245+
const config = this.resolveRequestToastConfig(requestType, message, type)
246+
247+
target.replaceChildren()
248+
target.classList.remove('px-4', 'py-3', 'text-sm', 'bg-danger-offset')
249+
target.classList.add('w-full', 'rounded-xl', 'border')
250+
target.classList.add(...config.containerClasses)
251+
252+
const wrapper = document.createElement('div')
253+
wrapper.className = 'flex items-start gap-4 p-6'
254+
255+
const iconContainer = document.createElement('div')
256+
iconContainer.className = 'shrink-0 flex h-6 w-6 items-center justify-center rounded-full'
257+
iconContainer.classList.add(...config.iconContainerClasses)
258+
259+
const icon = document.createElement('span')
260+
icon.setAttribute('aria-hidden', 'true')
261+
icon.textContent = config.icon === 'check-stylized' ? '✓' : config.icon === 'spinner' ? '◌' : '!'
262+
iconContainer.appendChild(icon)
263+
264+
const content = document.createElement('div')
265+
266+
const title = document.createElement('h3')
267+
title.className = 'mb-2 text-lg font-semibold'
268+
title.classList.add(...config.titleClasses)
269+
title.textContent = config.title
270+
271+
const body = document.createElement('p')
272+
body.textContent = message
273+
274+
content.append(title, body)
275+
wrapper.append(iconContainer, content)
276+
target.appendChild(wrapper)
277+
}
278+
171279
private resetMessage(target: HTMLElement): void {
172-
target.textContent = ''
280+
target.replaceChildren()
173281
target.classList.add('hidden')
174282
target.classList.remove(
283+
'w-full',
284+
'rounded-xl',
285+
'border',
175286
'border-success',
176287
'bg-success-inverse',
177288
'text-success',
178289
'border-danger',
179290
'bg-danger-offset',
291+
'bg-danger-inverse',
180292
'text-danger',
181293
'border-info',
182294
'bg-info-inverse',
183-
'text-info'
295+
'text-info',
296+
'px-4',
297+
'py-3',
298+
'text-sm'
184299
)
300+
target.classList.add('rounded-xl', 'border', 'px-4', 'py-3', 'text-sm')
185301
}
186302

187303
private resetRequestState(requestType: RequestType): void {
@@ -190,8 +306,9 @@ export class PrivacyFormElement extends LitElement {
190306
const emailInput = isAccessRequest ? this.accessEmailInput : this.deleteEmailInput
191307
const message = isAccessRequest ? this.accessMessage : this.deleteMessage
192308

309+
this.hidePreviewToast(requestType)
193310
this.resetMessage(message)
194-
this.setRequestState(form, 'idle')
311+
this.setRequestState(form, 'idle', requestType)
195312
this.setSubmitLoading(form, false)
196313
this.setEmailInvalid(emailInput, false)
197314

@@ -208,9 +325,23 @@ export class PrivacyFormElement extends LitElement {
208325
): void {
209326
target.setAttribute('role', type === 'error' ? 'alert' : 'status')
210327
target.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite')
211-
target.textContent = message
212328
target.classList.remove('hidden')
213329

330+
const requestType =
331+
target.id === 'access-message' ? 'ACCESS' : target.id === 'delete-message' ? 'DELETE' : null
332+
333+
if (requestType) {
334+
this.renderRequestToast(target, requestType, message, type)
335+
336+
if (options.focus ?? true) {
337+
target.focus()
338+
}
339+
340+
return
341+
}
342+
343+
target.textContent = message
344+
214345
const variantClasses = [
215346
'border-success',
216347
'bg-success-inverse',
@@ -263,61 +394,25 @@ export class PrivacyFormElement extends LitElement {
263394
requestType: RequestType,
264395
previewState: RequestPreviewState
265396
): void {
266-
this.resetRequestState(requestType)
267-
268397
const isAccessRequest = requestType === 'ACCESS'
269398
const form = isAccessRequest ? this.accessForm : this.deleteForm
270399
const emailInput = isAccessRequest ? this.accessEmailInput : this.deleteEmailInput
271-
const message = isAccessRequest ? this.accessMessage : this.deleteMessage
272-
273-
switch (previewState) {
274-
case 'loading':
275-
this.setRequestState(form, 'loading')
276-
this.setSubmitLoading(form, true)
277-
this.setMessage(message, 'Sending request...', 'info', { focus: false })
278-
return
400+
const prefix = isAccessRequest ? 'access' : 'delete'
279401

280-
case 'success':
281-
this.setRequestState(form, 'success')
282-
this.setMessage(
283-
message,
284-
isAccessRequest
285-
? 'Data access request sent. Please check your inbox to verify the request.'
286-
: 'Deletion request sent. Please check your inbox to verify the request.',
287-
'success',
288-
{ focus: false }
289-
)
290-
return
291-
292-
case 'error':
293-
this.setRequestState(form, 'error')
294-
this.setMessage(
295-
message,
296-
isAccessRequest
297-
? 'Unable to submit your data request. Please try again.'
298-
: 'Unable to submit your deletion request. Please try again.',
299-
'error',
300-
{ focus: false }
301-
)
302-
return
303-
304-
case 'validation':
305-
this.setRequestState(form, 'validation')
306-
this.setEmailInvalid(emailInput, true)
402+
// Show only the matching static SSR preview toast; hide all others for this form.
403+
for (const el of this.getPreviewToastElements(requestType)) {
404+
el.classList.add('hidden')
405+
}
406+
this.querySelector<HTMLElement>(`#${prefix}-preview-toast-${previewState}`)?.classList.remove('hidden')
307407

308-
if (isAccessRequest) {
309-
this.setMessage(message, 'Please enter a valid email address.', 'error', { focus: false })
310-
return
311-
}
408+
this.setRequestState(form, previewState, requestType)
409+
this.setSubmitLoading(form, previewState === 'loading')
312410

411+
if (previewState === 'validation') {
412+
this.setEmailInvalid(emailInput, true)
413+
if (!isAccessRequest) {
313414
this.setDeleteConfirmationInvalid(true)
314-
this.setMessage(
315-
message,
316-
'Please enter a valid email address and confirm the deletion request.',
317-
'error',
318-
{ focus: false }
319-
)
320-
return
415+
}
321416
}
322417
}
323418

@@ -408,33 +503,33 @@ export class PrivacyFormElement extends LitElement {
408503
this.resetRequestState(requestType)
409504

410505
if (requestType === 'DELETE' && !this.deleteConfirmCheckbox.checked) {
411-
this.setRequestState(formEl, 'validation')
506+
this.setRequestState(formEl, 'validation', requestType)
412507
this.setDeleteConfirmationInvalid(true)
413508
this.setMessage(messageEl, 'Please confirm you understand the deletion request.', 'error')
414509
return
415510
}
416511

417512
const email = emailInput.value
418-
this.setRequestState(formEl, 'loading')
513+
this.setRequestState(formEl, 'loading', requestType)
419514
this.setSubmitLoading(formEl, true)
420515
this.setMessage(messageEl, 'Sending request...', 'info')
421516

422517
try {
423518
const { data, error } = await actions.gdpr.requestData({ email, requestType })
424519
if (error || !data) {
425-
this.setRequestState(formEl, 'error')
520+
this.setRequestState(formEl, 'error', requestType)
426521
this.setSubmitLoading(formEl, false)
427522
this.setMessage(messageEl, error?.message || 'Request failed', 'error')
428523
return
429524
}
430525

431526
const resultData = data as RequestDataResult
432-
this.setRequestState(formEl, 'success')
527+
this.setRequestState(formEl, 'success', requestType)
433528
this.setSubmitLoading(formEl, false)
434529
this.setMessage(messageEl, resultData.message, 'success')
435530
formEl.reset()
436531
} catch (error) {
437-
this.setRequestState(formEl, 'error')
532+
this.setRequestState(formEl, 'error', requestType)
438533
this.setSubmitLoading(formEl, false)
439534
this.setMessage(
440535
messageEl,

0 commit comments

Comments
 (0)