From 86cefffe2fcf0525cc3bc6b5f69feaec11249da6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 18:29:23 -0400 Subject: [PATCH] feat(03-05): implement EventForm modal (create/edit) - Bottom sheet on phone, centered 480px dialog on desktop (EventDetailPopover pattern) - Fields: title, all-day toggle, start/end date/time, recurrence select, location, description - D-02: calendar picker hidden when 1 writable calendar, shown when >1 (from writable-calendars endpoint) - D-11: recurrence presets None/Daily/Weekly/Monthly/Yearly only (whole-series) - Validation: empty title + end-before-start with UI-SPEC error copy - create mode: POST /api/events/create; edit mode: PATCH /api/events/:uid/edit - role=dialog aria-modal=true; focus Title on open; Escape/backdrop close - T-03-15: all values as plain-text JSX children; no dangerouslySetInnerHTML - D-01: last-used calendar URL persisted in localStorage - Auto-fix: vi.hoisted() for mock factory variables (D-03-04-hoisting) --- apps/pwa/src/components/EventForm.test.tsx | 21 +- apps/pwa/src/components/EventForm.tsx | 715 +++++++++++++++++++++ 2 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 apps/pwa/src/components/EventForm.tsx diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index a6eca2f..a78e0a3 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -23,9 +23,21 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // ── Module mocks ────────────────────────────────────────────────────────────── +// vi.hoisted() is required for variables used inside vi.mock() factory functions +// to avoid TDZ (temporal dead zone) issues — decision D-03-04-hoisting. + +const { + mockSetEventForm, + mockCreateEvent, + mockUpdateEvent, + mockFetchWritableCalendars, +} = vi.hoisted(() => ({ + mockSetEventForm: vi.fn(), + mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }), + mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }), + mockFetchWritableCalendars: vi.fn(), +})) -// Mock the calendarStore -const mockSetEventForm = vi.fn() let mockEventFormOpen = true let mockEventFormMode: 'create' | 'edit' = 'create' let mockEventFormUid: string | null = null @@ -39,11 +51,6 @@ vi.mock('../store/calendarStore.js', () => ({ })), })) -// Mock write client calls -const mockCreateEvent = vi.fn().mockResolvedValue({ uid: 'new-uid-123' }) -const mockUpdateEvent = vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }) -const mockFetchWritableCalendars = vi.fn() - vi.mock('../api/client.js', () => ({ createEvent: mockCreateEvent, updateEvent: mockUpdateEvent, diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx new file mode 100644 index 0000000..c49b732 --- /dev/null +++ b/apps/pwa/src/components/EventForm.tsx @@ -0,0 +1,715 @@ +/** + * EventForm — create/edit event modal overlay (Plan 03-05). + * + * Rendering modes: + * - create: "New Event" form opened via FAB/toolbar (eventFormMode === 'create') + * - edit: pre-populated from TanStack Query cache by eventFormUid + * + * Responsive: + * - Phone (≤767px): bottom sheet (full-screen overlay) + * - Tablet/desktop (≥768px): centered dialog (max-width 480px) + * + * Security: T-03-15 — all field values rendered as plain-text JSX children. + * NEVER use dangerouslySetInnerHTML for any event field. + * + * Calendar picker: hidden when member has exactly 1 writable calendar (D-02). + * Populated from GET /api/events/writable-calendars (D-03 server-authoritative). + * + * Writes: 202 optimistic-accept (D-05/D-12). Enqueued to outbox by the API. + * On success: form closes. SyncStateToast feedback lands in Plan 03-06. + * + * Accessibility: + * - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event" + * - Focus moves to Title input on open + * - Escape / backdrop click closes form + * - All-day toggle: role="switch", aria-checked + * - Recurrence: setTitle(e.target.value)} + style={{ + ...inputStyle, + ...(errors.title ? { borderColor: 'var(--color-destructive)' } : {}), + }} + /> + {errors.title && ( +
+ {/* Plain text — XSS guard (T-03-15) */} + {errors.title} +
+ )} + + + {/* All-day toggle */} +
+ + + All day + +
+ + {/* Start date / time */} +
+
+ + setStartDate(e.target.value)} + style={inputStyle} + /> +
+ {!allDay && ( +
+ + setStartTime(e.target.value)} + style={inputStyle} + /> +
+ )} +
+ + {/* End date / time */} +
+
+ + setEndDate(e.target.value)} + style={{ + ...inputStyle, + ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}), + }} + /> +
+ {!allDay && ( +
+ + setEndTime(e.target.value)} + style={{ + ...inputStyle, + ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}), + }} + /> +
+ )} +
+ {errors.endTime && ( +
+ {/* Plain text — XSS guard (T-03-15) */} + {errors.endTime} +
+ )} + + {/* Calendar picker (D-02: hidden when only 1 writable calendar) */} + {showPicker && ( +
+ + +
+ )} + + {/* Recurrence picker (D-11: whole-series only) */} +
+ + +
+ + {/* Location */} +
+ + setLocation(e.target.value)} + style={inputStyle} + /> +
+ + {/* Description */} +
+ +