diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 73beda7..9577f95 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -5,10 +5,14 @@ * specs only check rendering (bands, red days, age-4 figures) of state the * API seeded directly. * - * A drag no longer creates a `LeaveWish`/synced period on its own — it opens - * the day popover pre-selected on every dragged date, and saving writes - * exactly those days (ad-hoc `planned_leave_day` rows, no wish). See - * `Calendar.tsx`'s pointerup handler and `handleSavePlannedDay`. + * A drag opens the day popover pre-selected on every dragged date, same as + * before. What that popover does on save changed (todo.md #10/#11): by + * default it now creates a fixed, one-off `LeaveWish` and lets the planner + * fund it (a `calendar-band-wish:` band, not `adhoc:`) — see + * `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc + * `planned_leave_day` rows" behaviour still exists, behind the modal's + * "Advanced" disclosure (`handleSavePinnedDay`) — the first test below + * opens it explicitly to keep exercising that path. * * The drag deliberately crosses a month boundary (the last two days of one * month into the first day of the next) — a regression guard for the @@ -53,7 +57,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } { return { month, dates } } -test('dragging a range across a month boundary plans every day, and deleting it removes the days for good', async ({ +test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({ page, }) => { await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson') @@ -75,6 +79,10 @@ test('dragging a range across a month boundary plans every day, and deleting it // A multi-day drag opens the day popover pre-selected on every dragged // date — it does not create a period/wish by itself. await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + // The default save path would create a fixed wish instead (see the next + // test) — opening "Advanced" keeps this test on the exact-pinned-days + // path todo.md #10/#11 kept as an escape hatch. + await page.getByTestId('advanced-toggle').click() await page.getByTestId('save-button').click() await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() @@ -104,7 +112,11 @@ test('dragging a range across a month boundary plans every day, and deleting it await expect(page.getByTestId('period-adhoc-notice')).toBeVisible() await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`) - await page.getByTestId('period-delete-button').click() + // todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is + // disabled; "this day"/"this period" are the only meaningful choices, and + // "this period" removes the whole ad-hoc range (today's only behaviour). + await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled() + await page.getByTestId('period-delete-year-button').click() await expect(page.getByTestId('period-delete-confirm')).toBeVisible() // Regression guard for the "Yes, delete" footer bug: the confirm button's @@ -137,3 +149,47 @@ test('dragging a range across a month boundary plans every day, and deleting it await calendarDayCell(page, dates[1]).click() await expect(page.getByTestId('modal-title')).toHaveText('Planera en dag') }) + +test('a drag saved without opening Advanced creates a planner-funded wish, and the three-way remove deletes it', async ({ + page, +}) => { + // todo.md #10/#11 — the default path no longer writes pinned days + // directly: it creates a fixed, one-off `LeaveWish` and lets the sync + // fund it, so the resulting band is wish-linked (`calendar-band-wish:`), + // not `adhoc:`. + await registerAndReachOnboarding(page, uniqueEmail('drag-wish'), 'Robin Testsson') + await fillChildStep(page, 'Nova') + await continueSoloAndSkipBalances(page) + + const { month, dates } = targetRange() + + await goToCalendarMonth(page, month) + + const wishBand = page.locator('[data-testid^="calendar-band-wish:"]') + await expect(wishBand).toHaveCount(0) + + await dragSelectDates(page, dates) + await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + // A solo account has no parent-mode selector to touch — save directly. + await page.getByTestId('save-button').click() + await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() + + await expect(wishBand.first()).toBeVisible() + + // Open the wish-linked band. Its own three-way remove: "this day" cannot + // be expressed for a wish (it would just reappear on the next sync) and + // is disabled; "this period" on a `recurrence: 'once'` wish (there is + // only one occurrence) removes the whole thing, same as "every year". + await wishBand.first().click() + await expect(page.getByTestId('period-popover__content')).toBeVisible() + await expect(page.getByTestId('period-delete-day-button')).toBeDisabled() + + await page.getByTestId('period-delete-year-button').click() + await expect(page.getByTestId('period-delete-confirm')).toBeVisible() + await page.getByTestId('period-delete-confirm-button').click() + await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() + + await page.reload() + await goToCalendarMonth(page, month) + await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0) +}) diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 1d6e630..91426e5 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -41,33 +41,47 @@ describe('PeriodPopover', () => { expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument() }) - it('shows the ad-hoc delete label', () => { + it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => { render() - expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar') + expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar') + expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).toHaveAttribute( + 'title', + 'Den här perioden är inte kopplad till en återkommande plan.', + ) }) it('shows an inline confirmation instead of deleting immediately', () => { render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) expect(mockOnDelete).not.toHaveBeenCalled() expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument() }) - it('calls onDelete after confirming inline', async () => { + it('calls onDelete with the "year" scope after confirming inline', async () => { mockOnDelete.mockResolvedValue(undefined) render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-button')) - await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year')) + }) + + it('calls onDelete with the "day" scope for the day button', async () => { + mockOnDelete.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-delete-day-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day')) }) it('does not call onDelete when the inline confirmation is cancelled', () => { render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-cancel-button')) expect(mockOnDelete).not.toHaveBeenCalled() expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument() - expect(screen.getByTestId('period-delete-button')).toBeInTheDocument() + expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument() }) }) @@ -223,6 +237,7 @@ describe('PeriodPopover', () => { parentPersonalDataUuid: null, recurrence: 'yearly', lastYear: null, + wantsDubbeldag: false, }) }) }) @@ -280,27 +295,88 @@ describe('PeriodPopover', () => { }) }) + describe('dubbeldagar checkbox', () => { + it('is enabled only when parentMode is "both"', () => { + render() + expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).not.toBeDisabled() + }) + + it('is disabled for a non-"both" parentMode', () => { + render() + expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled() + }) + + it('sends wantsDubbeldag: true only when checked in "both" mode', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-wants-dubbeldag-checkbox')) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true }))) + }) + + it('never sends wantsDubbeldag: true outside "both" mode, even if it was checked before switching', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'either' } }) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: false }))) + }) + }) + + it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => { + render() + expect(screen.getByTestId('period-delete-day-button')).toBeDisabled() + expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute( + 'title', + "Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.", + ) + expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled() + }) + it('displays a generic local error when onDelete rejects with a non-Error value', async () => { mockOnDelete.mockRejectedValue('boom') render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-button')) await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden') }) }) - it('calls onDelete for a wish-linked band after confirmation', async () => { + it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => { mockOnDelete.mockResolvedValue(undefined) render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-all-years-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-button')) - await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears')) }) - it('shows the wish-linked delete label', () => { + it('calls onDelete with the "year" scope for a wish-linked band', async () => { + mockOnDelete.mockResolvedValue(undefined) render() - expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period') + fireEvent.click(screen.getByTestId('period-delete-year-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year')) + }) + + it('shows the wish-linked "this period" and "every year" labels', () => { + render() + expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden') + expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år') + }) + + it('shows a scope-specific confirmation message for each delete button', () => { + render() + fireEvent.click(screen.getByTestId('period-delete-year-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort den här perioden (bara det här året)?', + ) + fireEvent.click(screen.getByTestId('period-delete-cancel-button')) + fireEvent.click(screen.getByTestId('period-delete-all-years-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort perioden — alla år?', + ) }) it('displays a local error when onSave rejects', async () => { diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index f11b450..1730dac 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -19,8 +19,20 @@ export interface PeriodPopoverSavePayload { // Meaningful only when `recurrence === 'yearly'`. `null` = "until the // child turns 4" (no explicit end year). lastYear: number | null + // §Decision A (co-leave, opt-in dubbeldagar) — only meaningful/sent when + // `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and + // dubbeldagar.md at the repo root. + wantsDubbeldag: boolean } +/** todo.md #9 — the three-way remove. `'day'` removes just the clicked + * occurrence's first date (ad-hoc bands only — see Calendar.tsx's + * `handleDeletePeriod`); `'year'` removes this occurrence/year only (for a + * yearly wish, narrows or splits `firstYear`/`lastYear`; for an ad-hoc band, + * removes the whole contiguous range, same as today); `'allYears'` removes + * every occurrence (today's only wish-branch behaviour, unchanged). */ +export type PeriodDeleteScope = 'day' | 'year' | 'allYears' + export interface PeriodPopoverProps { isOpen: boolean /** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band @@ -35,7 +47,7 @@ export interface PeriodPopoverProps { isSaving?: boolean | undefined error?: string | null | undefined onSave: (payload: PeriodPopoverSavePayload) => Promise - onDelete: () => Promise + onDelete: (scope: PeriodDeleteScope) => Promise onClose: () => void } @@ -47,6 +59,7 @@ interface FormState { parentPersonalDataUuid: string | null recurrence: 'yearly' | 'once' lastYear: number | null + wantsDubbeldag: boolean } /** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py): @@ -89,6 +102,7 @@ function formStateFromWish( parentPersonalDataUuid: wish.parentPersonalDataUuid, recurrence: wish.recurrence, lastYear: wish.lastYear, + wantsDubbeldag: wish.wantsDubbeldag, } } @@ -113,9 +127,10 @@ export function PeriodPopover({ parentPersonalDataUuid: null, recurrence: 'once', lastYear: null, + wantsDubbeldag: false, }) const [localError, setLocalError] = useState(null) - const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const [deleteScope, setDeleteScope] = useState(null) useEffect(() => { if (wish) { @@ -129,10 +144,11 @@ export function PeriodPopover({ parentPersonalDataUuid: null, recurrence: 'once', lastYear: null, + wantsDubbeldag: false, }) } setLocalError(null) - setIsConfirmingDelete(false) + setDeleteScope(null) }, [isOpen, wish, startDate, endDate, t]) if (!isOpen) return null @@ -151,31 +167,39 @@ export function PeriodPopover({ parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null, recurrence: form.recurrence, lastYear: form.recurrence === 'yearly' ? form.lastYear : null, + wantsDubbeldag: form.parentMode === 'both' ? form.wantsDubbeldag : false, }) } catch (err) { setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } } - const handleDeleteClick = () => { - setIsConfirmingDelete(true) + const handleDeleteClick = (scope: PeriodDeleteScope) => { + setDeleteScope(scope) } const handleCancelDelete = () => { - setIsConfirmingDelete(false) + setDeleteScope(null) } const handleConfirmDelete = async () => { + if (!deleteScope) return try { setLocalError(null) - await onDelete() + await onDelete(deleteScope) } catch (err) { setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } finally { - setIsConfirmingDelete(false) + setDeleteScope(null) } } + const deleteConfirmMessage: Record = { + day: t('calendar.periodDeleteConfirmDay'), + year: t('calendar.periodDeleteConfirmYear'), + allYears: t('calendar.periodDeleteConfirmAllYears'), + } + const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle') return ( @@ -297,6 +321,24 @@ export function PeriodPopover({ +
+ +

+ {form.parentMode === 'both' + ? t('calendar.wantsDubbeldagHint') + : t('calendar.wantsDubbeldagRequiresBothHint')} +

+
+
{t('calendar.periodRepeatLabel')}