From c5587c5b4665f63a0eff9d5a4ddd8ed73650a639 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 18 Sep 2026 00:49:39 +0200 Subject: [PATCH] Ask before deleting without leaving the app Deleting a period or a day went through the browser's own confirmation box. It halts the page while it is open, which meant the delete path could not be driven by anything automated, and it looks nothing like the rest of the app. Both now ask inline: the first click offers the choice, and only the second one deletes. That matters more than it did, since deleting a period now takes its days with it. --- .../calendar/PeriodPopover.test.tsx | 33 ++++-- .../src/components/calendar/PeriodPopover.tsx | 110 ++++++++++++------ .../components/calendar/period-popover.css | 13 +++ .../modals/PlannedDayModal.test.tsx | 69 ++++++----- .../src/components/modals/PlannedDayModal.tsx | 104 +++++++++++------ .../components/modals/planned-day-modal.css | 13 +++ frontend/src/i18n/en/calendar.ts | 2 + frontend/src/i18n/sv/calendar.ts | 2 + 8 files changed, 229 insertions(+), 117 deletions(-) diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 2f8cbeb..9988ceb 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -46,19 +46,28 @@ describe('PeriodPopover', () => { expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar') }) - it('calls onDelete after confirmation', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true) - mockOnDelete.mockResolvedValue(undefined) - render() - fireEvent.click(screen.getByTestId('period-delete-button')) - await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) - }) - - it('does not call onDelete when confirmation is cancelled', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false) + it('shows an inline confirmation instead of deleting immediately', () => { render() fireEvent.click(screen.getByTestId('period-delete-button')) expect(mockOnDelete).not.toHaveBeenCalled() + expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument() + }) + + it('calls onDelete after confirming inline', async () => { + mockOnDelete.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) + }) + + 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-cancel-button')) + expect(mockOnDelete).not.toHaveBeenCalled() + expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument() + expect(screen.getByTestId('period-delete-button')).toBeInTheDocument() }) }) @@ -217,20 +226,20 @@ describe('PeriodPopover', () => { }) it('displays a generic local error when onDelete rejects with a non-Error value', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true) mockOnDelete.mockRejectedValue('boom') render() fireEvent.click(screen.getByTestId('period-delete-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 () => { - vi.spyOn(window, 'confirm').mockReturnValue(true) mockOnDelete.mockResolvedValue(undefined) render() fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) }) diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index f7dd306..20a5e0b 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -101,6 +101,7 @@ export function PeriodPopover({ lastYear: null, }) const [localError, setLocalError] = useState(null) + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) useEffect(() => { if (wish) { @@ -117,6 +118,7 @@ export function PeriodPopover({ }) } setLocalError(null) + setIsConfirmingDelete(false) }, [isOpen, wish, startDate, endDate, t]) if (!isOpen) return null @@ -141,14 +143,22 @@ export function PeriodPopover({ } } - const handleDelete = async () => { - if (confirm(t('calendar.periodDeleteConfirm'))) { - try { - setLocalError(null) - await onDelete() - } catch (err) { - setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) - } + const handleDeleteClick = () => { + setIsConfirmingDelete(true) + } + + const handleCancelDelete = () => { + setIsConfirmingDelete(false) + } + + const handleConfirmDelete = async () => { + try { + setLocalError(null) + await onDelete() + } catch (err) { + setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) + } finally { + setIsConfirmingDelete(false) } } @@ -339,35 +349,61 @@ export function PeriodPopover({
- - - {wish && ( - + {isConfirmingDelete ? ( +
+ + {t('calendar.periodDeleteConfirm')} + + + +
+ ) : ( + <> + + + {wish && ( + + )} + )}
diff --git a/frontend/src/components/calendar/period-popover.css b/frontend/src/components/calendar/period-popover.css index 0fced05..f8f895b 100644 --- a/frontend/src/components/calendar/period-popover.css +++ b/frontend/src/components/calendar/period-popover.css @@ -175,3 +175,16 @@ .period-popover__button--danger:hover:not(:disabled) { background-color: #b71c1c; } + +.period-popover__delete-confirm { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.period-popover__delete-confirm-text { + font-size: 14px; + color: #333; + margin-right: auto; +} diff --git a/frontend/src/components/modals/PlannedDayModal.test.tsx b/frontend/src/components/modals/PlannedDayModal.test.tsx index ef80570..a73f18b 100644 --- a/frontend/src/components/modals/PlannedDayModal.test.tsx +++ b/frontend/src/components/modals/PlannedDayModal.test.tsx @@ -429,9 +429,6 @@ describe('PlannedDayModal', () => { }) mockOnDelete.mockRejectedValue(new Error('Delete failed')) - // Mock the confirm dialog - vi.spyOn(window, 'confirm').mockReturnValue(true) - render( { const deleteButton = screen.getByTestId('delete-button') await userEvent.click(deleteButton) + await userEvent.click(screen.getByTestId('delete-confirm-button')) await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('Delete failed') @@ -455,7 +453,6 @@ describe('PlannedDayModal', () => { parentPersonalDataUuid: 'p1', }) mockOnDelete.mockRejectedValue('Some error') - vi.spyOn(window, 'confirm').mockReturnValue(true) render( { const deleteButton = screen.getByTestId('delete-button') await userEvent.click(deleteButton) + await userEvent.click(screen.getByTestId('delete-confirm-button')) await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att ta bort den planerade dagen') @@ -711,38 +709,12 @@ describe('PlannedDayModal', () => { expect(screen.getByTestId('delete-button')).toBeInTheDocument() }) - it('shows confirmation dialog before deleting', async () => { + it('shows an inline confirmation instead of deleting immediately', async () => { const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', }) - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) - mockOnDelete.mockResolvedValue(undefined) - - render( - - ) - - const deleteButton = screen.getByTestId('delete-button') - await userEvent.click(deleteButton) - - expect(confirmSpy).toHaveBeenCalledWith('Är du säker på att du vill ta bort den här planerade dagen?') - confirmSpy.mockRestore() - }) - - it('does not call onDelete if confirmation is cancelled', async () => { - const existingDay = makePlannedLeaveDay({ - id: 'day-1', - parentPersonalDataUuid: 'p1', - }) - - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) mockOnDelete.mockResolvedValue(undefined) render( @@ -758,16 +730,17 @@ describe('PlannedDayModal', () => { await userEvent.click(deleteButton) expect(mockOnDelete).not.toHaveBeenCalled() - confirmSpy.mockRestore() + expect(screen.getByTestId('delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort den här planerade dagen?', + ) }) - it('calls onDelete if confirmation is accepted', async () => { + it('does not call onDelete if the inline confirmation is cancelled', async () => { const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', }) - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) mockOnDelete.mockResolvedValue(undefined) render( @@ -781,11 +754,37 @@ describe('PlannedDayModal', () => { const deleteButton = screen.getByTestId('delete-button') await userEvent.click(deleteButton) + await userEvent.click(screen.getByTestId('delete-cancel-button')) + + expect(mockOnDelete).not.toHaveBeenCalled() + expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument() + expect(screen.getByTestId('delete-button')).toBeInTheDocument() + }) + + it('calls onDelete if the inline confirmation is accepted', async () => { + const existingDay = makePlannedLeaveDay({ + id: 'day-1', + parentPersonalDataUuid: 'p1', + }) + + mockOnDelete.mockResolvedValue(undefined) + + render( + + ) + + const deleteButton = screen.getByTestId('delete-button') + await userEvent.click(deleteButton) + await userEvent.click(screen.getByTestId('delete-confirm-button')) await waitFor(() => { expect(mockOnDelete).toHaveBeenCalled() }) - confirmSpy.mockRestore() }) it('disables delete button when saving', () => { diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 0b7abd8..c2a24d6 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -55,6 +55,7 @@ export function PlannedDayModal({ const [isDubbeldag, setIsDubbeldag] = useState(false) const [localError, setLocalError] = useState(null) const [isTogglingOverride, setIsTogglingOverride] = useState(false) + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) useEffect(() => { if (existingDay) { @@ -67,6 +68,7 @@ export function PlannedDayModal({ setIsDubbeldag(false) } setLocalError(null) + setIsConfirmingDelete(false) }, [isOpen, existingDay, selectedParentId, defaultDayType]) useEffect(() => { @@ -88,15 +90,24 @@ export function PlannedDayModal({ } } - const handleDelete = async () => { + const handleDeleteClick = () => { if (!onDelete) return - if (confirm(t('plannedDayModal.confirmDelete'))) { - try { - setLocalError(null) - await onDelete() - } catch (err) { - setLocalError(err instanceof Error ? err.message : t('plannedDayModal.deleteError')) - } + setIsConfirmingDelete(true) + } + + const handleCancelDelete = () => { + setIsConfirmingDelete(false) + } + + const handleConfirmDelete = async () => { + if (!onDelete) return + try { + setLocalError(null) + await onDelete() + } catch (err) { + setLocalError(err instanceof Error ? err.message : t('plannedDayModal.deleteError')) + } finally { + setIsConfirmingDelete(false) } } @@ -256,32 +267,59 @@ export function PlannedDayModal({
- {!isRangeMode && existingDay && onDelete && ( - + {!isRangeMode && existingDay && onDelete && isConfirmingDelete && ( +
+ + {t('plannedDayModal.confirmDelete')} + + + +
+ )} + {!isConfirmingDelete && ( + <> + {!isRangeMode && existingDay && onDelete && ( + + )} + + + )} - -
diff --git a/frontend/src/components/modals/planned-day-modal.css b/frontend/src/components/modals/planned-day-modal.css index 3be985f..443fb8c 100644 --- a/frontend/src/components/modals/planned-day-modal.css +++ b/frontend/src/components/modals/planned-day-modal.css @@ -187,3 +187,16 @@ .planned-day-modal__button--danger:hover:not(:disabled) { background-color: #b71c1c; } + +.planned-day-modal__delete-confirm { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.planned-day-modal__delete-confirm-text { + font-size: 14px; + color: #333; + margin-right: auto; +} diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 38195bb..46d2485 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -40,6 +40,7 @@ export const calendar = { 'plannedDayModal.closeAria': 'Close', 'plannedDayModal.confirmDelete': 'Are you sure you want to delete this planned day?', 'plannedDayModal.deleteButton': 'Delete', + 'plannedDayModal.deleteConfirmButton': 'Yes, delete', 'plannedDayModal.deleteError': 'Failed to delete planned day', 'plannedDayModal.deletingButton': 'Deleting…', 'plannedDayModal.doubleDayHint': 'Double days do not apply to vacation', @@ -108,6 +109,7 @@ export const calendar = { 'calendar.periodDeleteButton': 'Delete period', 'calendar.periodDeletingButton': 'Deleting…', 'calendar.periodDeleteConfirm': 'Are you sure you want to delete this period?', + 'calendar.periodDeleteConfirmButton': 'Yes, delete', 'calendar.periodDeleteAdHocButton': 'Delete these days', 'calendar.periodCancelButton': 'Cancel', 'calendar.periodCloseAria': 'Close', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index ba2e208..f0e4f4d 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -40,6 +40,7 @@ export const calendar = { 'plannedDayModal.closeAria': 'Stäng', 'plannedDayModal.confirmDelete': 'Är du säker på att du vill ta bort den här planerade dagen?', 'plannedDayModal.deleteButton': 'Ta bort', + 'plannedDayModal.deleteConfirmButton': 'Ja, ta bort', 'plannedDayModal.deleteError': 'Det gick inte att ta bort den planerade dagen', 'plannedDayModal.deletingButton': 'Tar bort…', 'plannedDayModal.doubleDayHint': 'Dubbeldagar gäller inte för semester', @@ -108,6 +109,7 @@ export const calendar = { 'calendar.periodDeleteButton': 'Ta bort period', 'calendar.periodDeletingButton': 'Tar bort…', 'calendar.periodDeleteConfirm': 'Är du säker på att du vill ta bort den här perioden?', + 'calendar.periodDeleteConfirmButton': 'Ja, ta bort', 'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar', 'calendar.periodCancelButton': 'Avbryt', 'calendar.periodCloseAria': 'Stäng',