From 4ff67f2ab0b7c21f91baae8bcfeff1c6dfe144a5 Mon Sep 17 00:00:00 2001 From: eleith Date: Sat, 24 Oct 2026 14:09:36 -0700 Subject: [PATCH] fix(web): show cross-route host form errors --- .changeset/host-form-error-feedback.md | 7 + apps/web/e2e/host-durations.e2e.ts | 4 +- apps/web/e2e/host-form-errors.e2e.ts | 151 +++++++++++++++++++ apps/web/src/lib/components/GuestForm.svelte | 15 +- 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 .changeset/host-form-error-feedback.md create mode 100644 apps/web/e2e/host-form-errors.e2e.ts diff --git a/.changeset/host-form-error-feedback.md b/.changeset/host-form-error-feedback.md new file mode 100644 index 00000000..3c779709 --- /dev/null +++ b/.changeset/host-form-error-feedback.md @@ -0,0 +1,7 @@ +--- +'@when/web': patch +'@when/worker': patch +'@when/cli': patch +--- + +Show rejected host booking and rescheduling submissions using the existing form and field error displays, while preserving entered values, selected times and durations. Keep normal success and redirect handling unchanged. diff --git a/apps/web/e2e/host-durations.e2e.ts b/apps/web/e2e/host-durations.e2e.ts index 80636038..85f2b2fd 100644 --- a/apps/web/e2e/host-durations.e2e.ts +++ b/apps/web/e2e/host-durations.e2e.ts @@ -411,8 +411,10 @@ test('an enhanced host booking conflict preserves entered fields and the chosen const response = await responsePromise; const result = await response.json(); expect(result).toMatchObject({ type: 'failure', status: 409 }); - // Default enhance does not apply a different route's failure to the current page's form prop. expect(result.data).toContain('That time is no longer available. Please pick another.'); + await expect(page.getByRole('alert')).toHaveText( + 'That time is no longer available. Please pick another.' + ); await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible(); await expect(page.getByLabel('What is your name?')).toHaveValue(name); await expect(page.getByLabel('What is your email?')).toHaveValue(email); diff --git a/apps/web/e2e/host-form-errors.e2e.ts b/apps/web/e2e/host-form-errors.e2e.ts new file mode 100644 index 00000000..c9f09079 --- /dev/null +++ b/apps/web/e2e/host-form-errors.e2e.ts @@ -0,0 +1,151 @@ +import { expect, test, type Page } from '@playwright/test'; +import { signInAsAdmin } from './support/auth.ts'; +import { readAppointment } from './support/database.ts'; +import { seedAppointment } from './support/seed.ts'; + +function futureWeekday(offset: number, weekdayIndex = 0): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + offset); + for (let index = 0; index <= weekdayIndex; index++) { + if (index > 0) date.setUTCDate(date.getUTCDate() + 1); + while (date.getUTCDay() === 0 || date.getUTCDay() === 6) { + date.setUTCDate(date.getUTCDate() + 1); + } + } + return date.toISOString().slice(0, 10); +} + +async function changeNextSubmittedField(page: Page, name: string, value: string) { + // Reach server validation while keeping the visible, client-valid input unchanged. + await page.locator('#appointment-form').evaluate( + (element, change) => { + if (!(element instanceof HTMLFormElement)) throw new Error('Expected appointment form'); + element.addEventListener( + 'formdata', + (event) => { + event.formData.set(change.name, change.value); + }, + { once: true } + ); + }, + { name, value } + ); +} + +test('host creation shows server field errors and preserves inputs for a successful retry', async ({ + page +}, testInfo) => { + const day = futureWeekday(5 + testInfo.retry * 7); + await signInAsAdmin(page); + await page.goto(`/schedule/chat?date=${day}`); + await page.getByRole('radio', { name: /0?9:00\s*AM\s*–\s*0?9:30\s*AM/i }).click(); + await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + const name = 'Retained Validation Guest'; + const email = 'validation-feedback@example.test'; + await page.getByLabel('What is your name?').fill(name); + await page.getByLabel('What is your email?').fill(email); + const slot = await page.locator('input[name="slot"]').inputValue(); + const duration = await page.locator('input[name="duration"]').inputValue(); + await changeNextSubmittedField(page, 'name', ''); + const rejectionPromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname === '/admin/schedule/chat' + ); + await page.getByRole('button', { name: 'Request', exact: true }).click(); + const rejection = await rejectionPromise; + const result = await rejection.json(); + expect(result).toMatchObject({ type: 'failure', status: 400 }); + expect(result.data).toContain('Please enter your name.'); + await expect(page.getByRole('alert')).toHaveText('Please enter your name.'); + await expect(page.getByLabel('What is your name?')).toHaveValue(name); + await expect(page.getByLabel('What is your name?')).toHaveAttribute('aria-invalid', 'true'); + await expect(page.getByLabel('What is your email?')).toHaveValue(email); + await expect(page.locator('input[name="slot"]')).toHaveValue(slot); + await expect(page.locator('input[name="duration"]')).toHaveValue(duration); + await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible(); + + await page.getByRole('button', { name: 'Request', exact: true }).click(); + await page.waitForURL(/\/appointment\/[^/?]+$/); + const id = new URL(page.url()).pathname.split('/').at(-1)!; + const created = await readAppointment(id); + expect(created).toMatchObject({ status: 'confirmed', guest_name: name, guest_email: email }); + expect(Date.parse(created.start_time)).toBe(Date.parse(slot)); + expect(Date.parse(created.end_time) - Date.parse(created.start_time)).toBe( + Number(duration) * 60_000 + ); +}); + +test('host rescheduling shows validation and conflict errors without losing the reason or duration', async ({ + page +}, testInfo) => { + const day = futureWeekday(52, testInfo.retry); + const original = await seedAppointment({ + status: 'confirmed', + start: new Date(`${day}T14:00:00Z`), + durationMinutes: 90 + }); + const actionPath = `/admin/appointment/${original.id}/reschedule`; + await signInAsAdmin(page); + await page.goto( + `/appointment/${original.id}/reschedule?token=${original.cancel_token}&date=${day}` + ); + await page.getByRole('radio', { name: /0?3:30\s*PM\s*–\s*0?5:00\s*PM/i }).click(); + await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + const reason = 'Keep this explanation while choosing another time'; + await page.getByLabel('Reason for rescheduling').fill(reason); + const slot = await page.locator('input[name="slot"]').inputValue(); + await expect(page.locator('input[name="duration"]')).toHaveValue('90'); + await changeNextSubmittedField(page, 'reschedule_reason', ''); + const validationPromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === actionPath + ); + await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click(); + const validation = await validationPromise; + const validationResult = await validation.json(); + expect(validationResult).toMatchObject({ type: 'failure', status: 400 }); + await expect(page.getByRole('alert')).toHaveText('Please provide a reason for rescheduling.'); + await expect(page.getByLabel('Reason for rescheduling')).toHaveValue(reason); + await expect(page.locator('input[name="duration"]')).toHaveValue('90'); + await expect(page.locator('input[name="slot"]')).toHaveValue(slot); + + const blocker = await seedAppointment({ status: 'confirmed', start: new Date(slot) }); + const conflictPromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === actionPath + ); + await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click(); + const conflict = await conflictPromise; + const conflictResult = await conflict.json(); + expect(conflictResult).toMatchObject({ type: 'failure', status: 409 }); + await expect(page.getByRole('alert')).toHaveText( + 'That time is no longer available. Please pick another.' + ); + await expect(page.getByLabel('Reason for rescheduling')).toHaveValue(reason); + await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name); + await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!); + await expect(page.locator('input[name="duration"]')).toHaveValue('90'); + await expect(page.locator('input[name="slot"]')).toHaveValue(slot); + const unchanged = await readAppointment(original.id); + const unchangedBlocker = await readAppointment(blocker.id); + expect(unchanged).toEqual(original); + expect(unchangedBlocker).toEqual(blocker); + + await page.getByRole('button', { name: 'Back', exact: true }).click(); + // Keyboard activation avoids overlapping hit targets in the continuous timeline. + await page.getByRole('radio', { name: /12:00\s*PM\s*–\s*0?1:30\s*PM/i }).press('Enter'); + await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + await page.getByLabel('Reason for rescheduling').fill(reason); + const replacementSlot = await page.locator('input[name="slot"]').inputValue(); + await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click(); + await page.waitForURL(/\/appointment\/[^/?]+$/); + const id = new URL(page.url()).pathname.split('/').at(-1)!; + const moved = await readAppointment(id); + const old = await readAppointment(original.id); + expect(id).not.toBe(original.id); + expect(old.status).toBe('rescheduled'); + expect(moved).toMatchObject({ status: 'confirmed', origin_id: original.id }); + expect(Date.parse(moved.start_time)).toBe(Date.parse(replacementSlot)); + expect(Date.parse(moved.end_time) - Date.parse(moved.start_time)).toBe(90 * 60_000); +}); diff --git a/apps/web/src/lib/components/GuestForm.svelte b/apps/web/src/lib/components/GuestForm.svelte index eb5426e6..0d3aedc6 100644 --- a/apps/web/src/lib/components/GuestForm.svelte +++ b/apps/web/src/lib/components/GuestForm.svelte @@ -1,6 +1,7 @@
@@ -100,7 +111,7 @@ {/if} -
+ -- 2.54.0