refactor(web): resolve wizard form behavior in loaders #90
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user