feat(11-04): add reminder picker to EventForm (allDay swap, edit pre-population, payload mapping)
- TIMED_REMINDER_PRESETS + ALLDAY_REMINDER_PRESETS sets for preset classification - humanizeReminderLead() + deriveReminderValue() helpers for off-list synthetic options - reminderValue state (__none__ / numeric string / __custom__ sentinels) - allDay toggle resets reminderValue to __none__ (D-03 — no carry-over) - Reset effect derives reminderValue from occurrence.reminderLeadMinutes on mount/cache update - Reminder <select id=event-reminder> after Recurrence picker: allDay-conditional option swap (D-02), timed presets when !allDay, day-granularity presets when allDay; synthetic off-list option; Custom (kept) read-only disabled option - Helper text under select in edit mode for off-list/custom states (D-07) - executeSubmit payload: __none__ → null, numeric → integer, __custom__ → field omitted (D-08) - 10 new Phase 11 reminder picker tests (create/allDay-swap/edit-pre-pop/payload-mapping); fix pre-existing None-ambiguity test - pwa vitest 201/201 pass; tsc --noEmit clean
This commit is contained in:
@@ -239,7 +239,10 @@ describe('EventForm', () => {
|
||||
|
||||
it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => {
|
||||
renderForm();
|
||||
expect(screen.getByText('None')).toBeDefined();
|
||||
// Use getAllByText since "None" now appears in both the recurrence picker and the
|
||||
// reminder picker (Phase 11 — two <select> elements both have a "None" option).
|
||||
const noneOptions = screen.getAllByText('None');
|
||||
expect(noneOptions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Daily')).toBeDefined();
|
||||
expect(screen.getByText('Weekly')).toBeDefined();
|
||||
expect(screen.getByText('Monthly')).toBeDefined();
|
||||
@@ -986,3 +989,245 @@ describe('EventForm — Plan 06-06 end-tracking + recurrence-bound', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 11 Plan 04: Reminder picker tests ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fixture: edit occurrence with timed reminder (30 minutes before).
|
||||
*/
|
||||
const TIMED_REMINDER_OCCURRENCE: CalendarOccurrence = {
|
||||
id: 'reminder-uid-001::2026-06-15T10:00:00',
|
||||
uid: 'reminder-uid-001',
|
||||
calendarId: 1,
|
||||
calendarName: 'My Calendar',
|
||||
ownerUserId: 1,
|
||||
ownerName: 'Alice',
|
||||
color: '#4A90D9',
|
||||
isShared: false,
|
||||
title: 'Meeting with reminder',
|
||||
start: '2026-06-15T10:00:00-04:00',
|
||||
end: '2026-06-15T11:00:00-04:00',
|
||||
allDay: false,
|
||||
location: null,
|
||||
description: null,
|
||||
hasRrule: false,
|
||||
reminderLeadMinutes: 30,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixture: edit occurrence with all-day reminder (1440 min = 1 day before).
|
||||
*/
|
||||
const ALLDAY_REMINDER_OCCURRENCE: CalendarOccurrence = {
|
||||
id: 'reminder-uid-002::2026-06-15',
|
||||
uid: 'reminder-uid-002',
|
||||
calendarId: 1,
|
||||
calendarName: 'My Calendar',
|
||||
ownerUserId: 1,
|
||||
ownerName: 'Alice',
|
||||
color: '#4A90D9',
|
||||
isShared: false,
|
||||
title: 'All-day event with reminder',
|
||||
start: '2026-06-15',
|
||||
end: '2026-06-16',
|
||||
allDay: true,
|
||||
location: null,
|
||||
description: null,
|
||||
hasRrule: false,
|
||||
reminderLeadMinutes: 1440,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixture: edit occurrence with off-list timed reminder (45 min).
|
||||
*/
|
||||
const OFFLIST_REMINDER_OCCURRENCE: CalendarOccurrence = {
|
||||
id: 'reminder-uid-003::2026-06-15T10:00:00',
|
||||
uid: 'reminder-uid-003',
|
||||
calendarId: 1,
|
||||
calendarName: 'My Calendar',
|
||||
ownerUserId: 1,
|
||||
ownerName: 'Alice',
|
||||
color: '#4A90D9',
|
||||
isShared: false,
|
||||
title: 'Meeting with off-list reminder',
|
||||
start: '2026-06-15T10:00:00-04:00',
|
||||
end: '2026-06-15T11:00:00-04:00',
|
||||
allDay: false,
|
||||
location: null,
|
||||
description: null,
|
||||
hasRrule: false,
|
||||
reminderLeadMinutes: 45,
|
||||
};
|
||||
|
||||
describe('EventForm — Phase 11 reminder picker (Plan 04)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEventFormOpen = true;
|
||||
mockEventFormMode = 'create';
|
||||
mockEventFormUid = null;
|
||||
});
|
||||
|
||||
// ── D-01: default None in create mode ─────────────────────────────────────
|
||||
|
||||
it('D-01: reminder picker present with id=event-reminder and defaults to None in create mode', () => {
|
||||
renderForm({ mode: 'create' });
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelect).not.toBeNull();
|
||||
expect(reminderSelect.value).toBe('__none__');
|
||||
});
|
||||
|
||||
it('D-01: timed preset labels visible in create mode (allDay=false)', () => {
|
||||
renderForm({ mode: 'create' });
|
||||
// Timed preset labels should be present
|
||||
expect(screen.getByText('30 minutes before')).toBeDefined();
|
||||
expect(screen.getByText('1 hour before')).toBeDefined();
|
||||
expect(screen.getByText('1 day before')).toBeDefined();
|
||||
});
|
||||
|
||||
// ── D-02/D-03: allDay toggle swaps preset set and resets to None ──────────
|
||||
|
||||
it('D-02/D-03: toggling All-day swaps picker to day-granularity labels', () => {
|
||||
renderForm({ mode: 'create' });
|
||||
|
||||
// Before toggle: timed preset
|
||||
expect(screen.getByText('30 minutes before')).toBeDefined();
|
||||
|
||||
// Toggle all-day ON
|
||||
const allDaySwitch = screen.getByRole('switch');
|
||||
fireEvent.click(allDaySwitch);
|
||||
|
||||
// After toggle: all-day presets present
|
||||
expect(screen.getByText('Same day (9 AM)')).toBeDefined();
|
||||
expect(screen.getByText('1 day before (9 AM)')).toBeDefined();
|
||||
expect(screen.getByText('1 week before (9 AM)')).toBeDefined();
|
||||
});
|
||||
|
||||
it('D-03: toggling All-day resets picker selection to None (no carry-over)', () => {
|
||||
renderForm({ mode: 'create' });
|
||||
|
||||
// Select a timed preset
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
fireEvent.change(reminderSelect, { target: { value: '30' } });
|
||||
expect(reminderSelect.value).toBe('30');
|
||||
|
||||
// Toggle all-day ON — picker must reset to None
|
||||
const allDaySwitch = screen.getByRole('switch');
|
||||
fireEvent.click(allDaySwitch);
|
||||
|
||||
const reminderSelectAfter = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelectAfter.value).toBe('__none__');
|
||||
});
|
||||
|
||||
// ── Edit-mode pre-population ───────────────────────────────────────────────
|
||||
|
||||
it('edit mode: reminderLeadMinutes=30 (timed) pre-selects "30 minutes before"', () => {
|
||||
renderForm({
|
||||
mode: 'edit',
|
||||
uid: 'reminder-uid-001',
|
||||
eventOccurrence: TIMED_REMINDER_OCCURRENCE,
|
||||
});
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelect).not.toBeNull();
|
||||
expect(reminderSelect.value).toBe('30');
|
||||
// Option label should be "30 minutes before"
|
||||
const selectedOption = reminderSelect.options[reminderSelect.selectedIndex];
|
||||
expect(selectedOption.text).toBe('30 minutes before');
|
||||
});
|
||||
|
||||
it('edit mode: reminderLeadMinutes=1440 all-day pre-selects "1 day before (9 AM)"', () => {
|
||||
renderForm({
|
||||
mode: 'edit',
|
||||
uid: 'reminder-uid-002',
|
||||
eventOccurrence: ALLDAY_REMINDER_OCCURRENCE,
|
||||
});
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelect).not.toBeNull();
|
||||
expect(reminderSelect.value).toBe('1440');
|
||||
const selectedOption = reminderSelect.options[reminderSelect.selectedIndex];
|
||||
expect(selectedOption.text).toBe('1 day before (9 AM)');
|
||||
});
|
||||
|
||||
it('edit mode: reminderLeadMinutes=45 (off-list) shows synthetic "45 min before" option', () => {
|
||||
renderForm({
|
||||
mode: 'edit',
|
||||
uid: 'reminder-uid-003',
|
||||
eventOccurrence: OFFLIST_REMINDER_OCCURRENCE,
|
||||
});
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelect).not.toBeNull();
|
||||
expect(reminderSelect.value).toBe('45');
|
||||
const selectedOption = reminderSelect.options[reminderSelect.selectedIndex];
|
||||
expect(selectedOption.text).toBe('45 min before');
|
||||
});
|
||||
|
||||
// ── Payload mapping ────────────────────────────────────────────────────────
|
||||
|
||||
it('payload mapping: None selection → reminderLeadMinutes: null', async () => {
|
||||
renderForm({ mode: 'create' });
|
||||
fireEvent.change(screen.getByPlaceholderText('Event title'), {
|
||||
target: { value: 'Test Event' },
|
||||
});
|
||||
// Picker already at None (default)
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
expect(reminderSelect.value).toBe('__none__');
|
||||
|
||||
fireEvent.click(screen.getByText('Create Event'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reminderLeadMinutes: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('payload mapping: preset selection (30) → reminderLeadMinutes: 30', async () => {
|
||||
renderForm({ mode: 'create' });
|
||||
fireEvent.change(screen.getByPlaceholderText('Event title'), {
|
||||
target: { value: 'Test Event' },
|
||||
});
|
||||
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
fireEvent.change(reminderSelect, { target: { value: '30' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Create Event'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reminderLeadMinutes: 30 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('payload mapping: Custom (kept) selection → reminderLeadMinutes field omitted (D-08)', async () => {
|
||||
// We cannot reach __custom__ via occurrence (occurrence only has number|null),
|
||||
// but we can set it directly via the state simulation — programmatically
|
||||
// select __custom__ via a forced renderForm with custom occurrence scenario.
|
||||
// The plan notes: with only number|null, __custom__ is unreachable from occurrence;
|
||||
// we test the omit-behavior by directly firing a change to __custom__ value.
|
||||
renderForm({
|
||||
mode: 'edit',
|
||||
uid: 'reminder-uid-001',
|
||||
eventOccurrence: TIMED_REMINDER_OCCURRENCE,
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Event title'), {
|
||||
target: { value: 'Meeting with reminder' },
|
||||
});
|
||||
|
||||
// Force the select to __custom__ sentinel (simulates a custom alarm preserved state)
|
||||
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||
// We need to inject the __custom__ option and select it
|
||||
const customOption = document.createElement('option');
|
||||
customOption.value = '__custom__';
|
||||
customOption.text = 'Custom (kept)';
|
||||
reminderSelect.appendChild(customOption);
|
||||
fireEvent.change(reminderSelect, { target: { value: '__custom__' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save Changes'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateEvent).toHaveBeenCalled();
|
||||
const callPayload = mockUpdateEvent.mock.calls[0][1] as Record<string, unknown>;
|
||||
// D-08: field must be absent (not null, not 0 — truly missing)
|
||||
expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user