refactor(web): resolve wizard form behavior in loaders #90

Merged
eleith merged 1 commits from refactor/wizard-form-behavior into main 2026-10-25 00:01:08 +00:00
8 changed files with 97 additions and 66 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Resolve booking and rescheduling form destinations and guest-field editability in server loaders. Keep the shared wizard focused on rendering the supplied behavior instead of inferring it from host or guest identity, without changing routes, permissions or submission feedback.
+2
View File
@@ -261,7 +261,9 @@ test('guest can change a booked host-only 90 to configured 25, but cannot invent
await expect(page.locator('#appointment-form')).toHaveAttribute('action', publicPath);
await expect(page.locator('input[name="duration"]')).toHaveValue('25');
await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name);
await expect(page.getByLabel('What is your name?')).toBeEnabled();
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
await expect(page.getByLabel('What is your email?')).toBeEnabled();
const slot = await page.locator('input[name="slot"]').inputValue();
await page.getByLabel('Reason for rescheduling').fill(reason);
await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click();
+3
View File
@@ -93,6 +93,9 @@ test('host rescheduling shows validation and conflict errors without losing the
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 expect(page.getByLabel('What is your name?')).toBeDisabled();
await expect(page.getByLabel('What is your email?')).toBeDisabled();
await expect(page.getByLabel('Reason for rescheduling')).toBeEnabled();
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');
@@ -17,7 +17,6 @@
import { getPreferredTimezone } from '$lib/preferredTimezone.svelte';
import type { GuestAnswer, Appearance, FormField } from '@when/config';
import type { PublicEventType } from '$lib/server/appointment/sanitize';
import type { AppointmentRole } from '$lib/server/appointment/actions';
// Shared shape served by both the new-appointment route and the reschedule route.
export interface AppointmentWizardData {
@@ -47,7 +46,8 @@
error: string | null;
token: string | null;
} | null;
role: AppointmentRole;
formAction: string;
guestFieldsDisabled: boolean;
}
interface Props {
@@ -58,8 +58,6 @@
let { data, form, deepLink = false }: Props = $props();
const isHost = $derived(data.role === 'host');
const rescheduleAppt = $derived(data.reschedule?.appt ?? null);
const rescheduleError = $derived(data.reschedule?.error ?? null);
const rescheduleToken = $derived(data.reschedule?.token ?? null);
@@ -84,20 +82,6 @@
let isGuestFormValid = $state(false);
let durationOpen = $state(false);
let formAction = $derived.by(() => {
if (isHost) {
if (rescheduleAppt) {
return `/admin/appointment/${rescheduleAppt.id}/reschedule`;
} else {
return `/admin/schedule/${data.eventType.slug}`;
}
}
if (rescheduleAppt) {
return `/appointment/${rescheduleAppt.id}/reschedule`;
}
return `/schedule/${data.eventType.slug}`;
});
let previousAppointmentHref = $derived(
rescheduleAppt
? `/appointment/${rescheduleAppt.id}?token=${encodeURIComponent(rescheduleToken || '')}`
@@ -119,8 +103,6 @@
return t ? Math.round(t.totalMs / 3600000) : 0;
});
let fieldsDisabled = $derived(isHost && !!rescheduleAppt);
// Set the viewer timezone before the URL sync applies a deep-linked slot, so it
// resolves to the right day. The effect re-applies once tz resolves client-side.
if (ptz.current) flow.setTz(ptz.current);
@@ -270,9 +252,9 @@
formFields={data.formFields}
{rescheduleAppt}
{rescheduleToken}
{fieldsDisabled}
fieldsDisabled={data.guestFieldsDisabled}
{form}
{formAction}
formAction={data.formAction}
requireApproval={data.eventType.require_approval}
onvaliditychange={(v) => (isGuestFormValid = v)}
/>
@@ -51,9 +51,15 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
row
);
const formAction =
role === 'host'
? `/admin/appointment/${row.id}/reschedule`
: `/appointment/${row.id}/reschedule`;
return {
eventType: toPublicEventType(row.event_type_id, eventType, role, bookingDurations),
formFields: resolveFormFields(eventType),
formAction,
guestFieldsDisabled: role === 'host' && rescheduleError === null,
availability: { slotsByDuration, workingWindows, busyBlocks },
reschedule: {
appt: rescheduleError ? null : toPublicAppointment(row, role),
@@ -170,11 +170,21 @@ describe('/appointment/[id]/reschedule load', () => {
});
test.for([
{ role: 'guest', session: null },
{ role: 'host', session: { user: { name: 'Host' } } }
{
role: 'guest',
session: null,
formAction: '/appointment/appt-1/reschedule',
guestFieldsDisabled: false
},
{
role: 'host',
session: { user: { name: 'Host' } },
formAction: '/admin/appointment/appt-1/reschedule',
guestFieldsDisabled: true
}
])(
'returns the appointment and loads $role availability excluding self',
async ({ role, session }) => {
async ({ role, session, formAction, guestFieldsDisabled }) => {
const loaded = await load(loadEvent('tok-1', session));
const result = loaded as LoadResult;
expect(result.reschedule.appt).toEqual({ id: 'appt-1' });
@@ -196,6 +206,8 @@ describe('/appointment/[id]/reschedule load', () => {
);
expect(h.toPublicEventType.mock.calls[0][3]).toBe(availability.bookingDurations);
expect(result.role).toBe(role);
expect(result.formAction).toBe(formAction);
expect(result.guestFieldsDisabled).toBe(guestFieldsDisabled);
expect(result).not.toHaveProperty('viewer');
expect(result).not.toHaveProperty('isAdmin');
expect(result).not.toHaveProperty('isHost');
@@ -216,6 +228,16 @@ describe('/appointment/[id]/reschedule load', () => {
const result = loaded as LoadResult;
expect(result.reschedule.error).toBe('too_late');
expect(result.reschedule.appt).toBeNull();
expect(result.guestFieldsDisabled).toBe(false);
});
test('does not disable an unrendered guest form for a host reschedule error', async () => {
h.classify.mockReturnValue({ kind: 'error', code: 'too_late' });
const loaded = await load(loadEvent('tok-1', { user: { name: 'Host' } }));
const result = loaded as LoadResult;
expect(result.reschedule.appt).toBeNull();
expect(result.formAction).toBe('/admin/appointment/appt-1/reschedule');
expect(result.guestFieldsDisabled).toBe(false);
});
});
@@ -57,9 +57,13 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
role
);
const formAction =
role === 'host' ? `/admin/schedule/${params.slug}` : `/schedule/${params.slug}`;
return {
eventType: toPublicEventType(params.slug, eventType, role, bookingDurations),
formFields: resolveFormFields(eventType),
formAction,
guestFieldsDisabled: false,
availability: { slotsByDuration, workingWindows, busyBlocks },
reschedule: null,
role
@@ -163,47 +163,52 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
});
test.for([
{ role: 'guest', session: null },
{ role: 'host', session: { user: { name: 'Host' } } }
])('returns canonical page data and loads $role availability', async ({ role, session }) => {
h.cfg.current = structuredClone(validConfig);
Object.assign(h.cfg.current.meetings['30-min-chat'], {
location: 'Private host location',
padding_before_minutes: 15,
padding_after_minutes: 10
});
const loaded = await load(loadEvent(`?slot=${SLOT}`, session));
const result = loaded as LoadResult;
const availability = await h.loadAvailability.mock.results[0].value;
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
'30-min-chat',
h.cfg.current.meetings['30-min-chat'],
role,
availability.bookingDurations
);
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(availability.bookingDurations);
expect(result.eventType.duration_minutes).toBe(availability.bookingDurations.defaultDuration);
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
[...result.eventType.durations].sort((a, b) => a - b)
);
expect(result.eventType.slug).toBe('30-min-chat');
expect(result.eventType.location).toBe(role === 'host' ? 'Private host location' : undefined);
expect(result.eventType.padding_before_minutes).toBe(role === 'host' ? 0 : 15);
expect(result.eventType.padding_after_minutes).toBe(role === 'host' ? 0 : 10);
expect(result.role).toBe(role);
expect(result).not.toHaveProperty('viewer');
expect(result).not.toHaveProperty('isAdmin');
expect(result).not.toHaveProperty('isHost');
expect(h.loadAvailability).toHaveBeenCalledExactlyOnceWith(
h.db,
h.cfg.current,
'30-min-chat',
h.cfg.current.meetings['30-min-chat'],
role
);
});
{ role: 'guest', session: null, formAction: '/schedule/30-min-chat' },
{ role: 'host', session: { user: { name: 'Host' } }, formAction: '/admin/schedule/30-min-chat' }
])(
'returns canonical page data and loads $role availability',
async ({ role, session, formAction }) => {
h.cfg.current = structuredClone(validConfig);
Object.assign(h.cfg.current.meetings['30-min-chat'], {
location: 'Private host location',
padding_before_minutes: 15,
padding_after_minutes: 10
});
const loaded = await load(loadEvent(`?slot=${SLOT}`, session));
const result = loaded as LoadResult;
const availability = await h.loadAvailability.mock.results[0].value;
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
'30-min-chat',
h.cfg.current.meetings['30-min-chat'],
role,
availability.bookingDurations
);
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(availability.bookingDurations);
expect(result.eventType.duration_minutes).toBe(availability.bookingDurations.defaultDuration);
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
[...result.eventType.durations].sort((a, b) => a - b)
);
expect(result.eventType.slug).toBe('30-min-chat');
expect(result.eventType.location).toBe(role === 'host' ? 'Private host location' : undefined);
expect(result.eventType.padding_before_minutes).toBe(role === 'host' ? 0 : 15);
expect(result.eventType.padding_after_minutes).toBe(role === 'host' ? 0 : 10);
expect(result.role).toBe(role);
expect(result.formAction).toBe(formAction);
expect(result.guestFieldsDisabled).toBe(false);
expect(result).not.toHaveProperty('viewer');
expect(result).not.toHaveProperty('isAdmin');
expect(result).not.toHaveProperty('isHost');
expect(h.loadAvailability).toHaveBeenCalledExactlyOnceWith(
h.db,
h.cfg.current,
'30-min-chat',
h.cfg.current.meetings['30-min-chat'],
role
);
}
);
});
describe('/schedule/[slug] book action', () => {