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 */} +
+ +