diff --git a/frontend/src/components/common/InlineConfirm.test.tsx b/frontend/src/components/common/InlineConfirm.test.tsx new file mode 100644 index 0000000..f81f479 --- /dev/null +++ b/frontend/src/components/common/InlineConfirm.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InlineConfirm } from './InlineConfirm' + +describe('InlineConfirm', () => { + it('renders the prompt and both buttons', () => { + render( + , + ) + expect(screen.getByText('Are you sure?')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument() + }) + + it('defaults to cancel before confirm', () => { + render( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.map((b) => b.textContent)).toEqual(['No', 'Yes']) + }) + + it('shows confirm before cancel when confirmFirst is set', () => { + render( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.map((b) => b.textContent)).toEqual(['Yes', 'No']) + }) + + it('calls onConfirm when the confirm button is clicked', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'Yes' })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when the cancel button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'No' })) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('disables both buttons while busy', () => { + render( + , + ) + expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'No' })).toBeDisabled() + }) + + it('leaves both buttons enabled when not busy', () => { + render( + , + ) + expect(screen.getByRole('button', { name: 'Yes' })).not.toBeDisabled() + expect(screen.getByRole('button', { name: 'No' })).not.toBeDisabled() + }) + + it('appends btn--sm to both buttons when small is set', () => { + render( + , + ) + expect(screen.getByRole('button', { name: 'Yes' })).toHaveClass('btn--sm') + expect(screen.getByRole('button', { name: 'No' })).toHaveClass('btn--sm') + }) + + it('does not append btn--sm by default', () => { + render( + , + ) + expect(screen.getByRole('button', { name: 'Yes' })).not.toHaveClass('btn--sm') + expect(screen.getByRole('button', { name: 'No' })).not.toHaveClass('btn--sm') + }) + + it('wires up confirmTestId and cancelTestId', () => { + render( + , + ) + expect(screen.getByTestId('confirm-test')).toBeInTheDocument() + expect(screen.getByTestId('cancel-test')).toBeInTheDocument() + }) + + it('wires up the wrapper testId and className', () => { + const { container } = render( + , + ) + const wrapper = screen.getByTestId('wrapper-test') + expect(wrapper).toHaveClass('my-wrapper') + expect(container.firstChild).toBe(wrapper) + }) + + it('wires up promptClassName on the prompt span', () => { + render( + , + ) + expect(screen.getByText('Are you sure?')).toHaveClass('prompt-class') + }) +}) diff --git a/frontend/src/components/common/InlineConfirm.tsx b/frontend/src/components/common/InlineConfirm.tsx new file mode 100644 index 0000000..31e1567 --- /dev/null +++ b/frontend/src/components/common/InlineConfirm.tsx @@ -0,0 +1,97 @@ +import { ReactNode } from 'react' + +export interface InlineConfirmProps { + prompt: ReactNode + confirmLabel: ReactNode + cancelLabel: ReactNode + onConfirm: () => void + onCancel: () => void + isBusy?: boolean + confirmTestId?: string + cancelTestId?: string + /** Class for the outer wrapper — the two callers use different elements + * for this (`.modal__delete-confirm`, `.settings-panel__remove-confirm`), + * so the wrapper's look stays theirs rather than being unified here. */ + className?: string + /** Class for the span wrapping `prompt` — only `PlannedDayModal` needs + * one (`.modal__delete-confirm-text`, which pushes the buttons right via + * `margin-right: auto`); `Settings`' row has no such class. */ + promptClassName?: string + /** `Settings`' remove-member row shows the danger confirm before cancel; + * `PlannedDayModal`'s delete row shows cancel first. Defaults to cancel + * first. */ + confirmFirst?: boolean + /** Appends `btn--sm` to both buttons — `Settings`' compact row uses it, + * `PlannedDayModal`'s footer-sized row doesn't. */ + small?: boolean + /** `data-testid` on the outer wrapper — `PlannedDayModal`'s row has one + * (`delete-confirm`); `Settings`' row doesn't. */ + testId?: string +} + +/** + * The shared "are you sure" row — a prompt plus a danger confirm and a + * secondary cancel, both disabled while busy. Replaces the hand-copied + * versions in `PlannedDayModal` (delete confirm) and `Settings` (remove- + * member confirm), which differ only in wrapper class, prompt wrapping, + * button order and button size — all exposed as props here so neither + * caller's DOM has to change. + */ +export function InlineConfirm({ + prompt, + confirmLabel, + cancelLabel, + onConfirm, + onCancel, + isBusy = false, + confirmTestId, + cancelTestId, + className, + promptClassName, + confirmFirst = false, + small = false, + testId, +}: InlineConfirmProps): ReactNode { + const sizeClass = small ? ' btn--sm' : '' + + const confirmButton = ( + + ) + + const cancelButton = ( + + ) + + return ( +
+ {prompt} + {confirmFirst ? ( + <> + {confirmButton} + {cancelButton} + + ) : ( + <> + {cancelButton} + {confirmButton} + + )} +
+ ) +} diff --git a/frontend/src/components/common/Modal.test.tsx b/frontend/src/components/common/Modal.test.tsx new file mode 100644 index 0000000..6333b57 --- /dev/null +++ b/frontend/src/components/common/Modal.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Modal } from './Modal' + +describe('Modal', () => { + it('renders the title, close button and children', () => { + render( + +

Body content

+
, + ) + expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByText('Body content')).toBeInTheDocument() + }) + + it('defaults to the md content size', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__content--md')).not.toBeNull() + }) + + it('applies the sm content size when requested', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__content--sm')).not.toBeNull() + }) + + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + +

Body

+
, + ) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('disables the close button when closeDisabled is set', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled() + }) + + it('closes on overlay click by default', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const { container } = render( + +

Body

+
, + ) + await user.click(container.querySelector('.modal__overlay') as Element) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not close on overlay click when closeOnOverlayClick is false', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const { container } = render( + +

Body

+
, + ) + await user.click(container.querySelector('.modal__overlay') as Element) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not close when clicking inside the content box', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + +

Body content

+
, + ) + await user.click(screen.getByText('Body content')) + expect(onClose).not.toHaveBeenCalled() + }) + + it('calls onClose on Escape', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('ignores other keys', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not call onClose on Escape when closeDisabled is set', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('removes the Escape listener on unmount', () => { + const onClose = vi.fn() + const { unmount } = render( + +

Body

+
, + ) + unmount() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('renders a footer when provided', () => { + render( + Save}> +

Body

+
, + ) + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + it('renders no footer when omitted', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__footer')).toBeNull() + }) + + it('wires up testId, contentTestId, titleTestId, titleId and role', () => { + render( + +

Body

+
, + ) + expect(screen.getByTestId('overlay-test')).toHaveAttribute('role', 'dialog') + expect(screen.getByTestId('content-test')).toBeInTheDocument() + expect(screen.getByTestId('title-test')).toHaveAttribute('id', 'hello-title') + }) + + it('omits role when not provided', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__overlay')).not.toHaveAttribute('role') + }) +}) diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx new file mode 100644 index 0000000..d878a36 --- /dev/null +++ b/frontend/src/components/common/Modal.tsx @@ -0,0 +1,96 @@ +import { ReactNode, useEffect } from 'react' +import { Icon } from './Icon' + +export interface ModalProps { + onClose: () => void + title: ReactNode + /** Maps to `.modal__content--{size}` in `src/styles/primitives.css`. */ + size?: 'sm' | 'md' + /** Disables the close button and stops Escape from closing — mirrors + * `QRCodeModal`'s "can't close while the scrape is running" rule. */ + closeDisabled?: boolean + /** Whether clicking the overlay (outside the content box) closes the + * modal. Defaults to true; `QRCodeModal` sets it false so a stray click + * can't drop a running fetch. */ + closeOnOverlayClick?: boolean + footer?: ReactNode + /** `data-testid` on the overlay div. */ + testId?: string + /** `data-testid` on the content box. */ + contentTestId?: string + /** `data-testid` on the title `

`. */ + titleTestId?: string + /** `id` on the title `

`, for a caller that wants to wire up + * `aria-labelledby` on the overlay itself. */ + titleId?: string + /** Set to `'dialog'` when the caller wants that role on the overlay + * (`PlannedDayModal` does; the others don't add one). */ + role?: 'dialog' + closeAriaLabel: string + children: ReactNode +} + +/** + * The shared `.modal__overlay > .modal__content > .modal__header (h2 + + * close button) > .modal__body > .modal__footer` shell — hand-copied across + * `ParentBalanceForm`, `PlannedDayModal` and `QRCodeModal` before this. + * Standardises the close button on ``; the caller still + * supplies its own `aria-label` text. No focus trap — out of scope here. + */ +export function Modal({ + onClose, + title, + size = 'md', + closeDisabled = false, + closeOnOverlayClick = true, + footer, + testId, + contentTestId, + titleTestId, + titleId, + role, + closeAriaLabel, + children, +}: ModalProps): ReactNode { + useEffect(() => { + if (closeDisabled) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [closeDisabled, onClose]) + + const handleOverlayClick = () => { + if (closeOnOverlayClick) onClose() + } + + return ( +
+
e.stopPropagation()} + data-testid={contentTestId} + > +
+

+ {title} +

+ +
+ +
{children}
+ + {footer &&
{footer}
} +
+
+ ) +} diff --git a/frontend/src/components/modals/ParentBalanceForm.tsx b/frontend/src/components/modals/ParentBalanceForm.tsx index df6ee38..4a7b7fa 100644 --- a/frontend/src/components/modals/ParentBalanceForm.tsx +++ b/frontend/src/components/modals/ParentBalanceForm.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@/i18n' import { isApiError, type ApiRequestError } from '@/services/errors' import { FormField } from '@/components/common/FormField' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' import './parent-balance-form.css' export interface ParentBalanceInitial { @@ -206,149 +207,131 @@ export function ParentBalanceForm({ } } + const footer = ( + <> + + + + ) + return ( -
-
e.stopPropagation()}> -
-

{isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}

- + +

+ {t('parentBalance.summary', { left: daysLeft, total: totalDays })} +
+ {t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })} +

+ {totalError &&

{totalError}

} + + + + {showAdvanced && ( + - × - -
- -
-

{t('parentBalance.description')}

- - {(error || saveError) && {error || saveError}} - -
- - { - setSjukpenningLeft(parseField(e.target.value)) - clearBackendFieldError('sjukpenning') - }} - disabled={isSaving} - className="field__control" - /> - - - - { - setLagstaNivaLeft(parseField(e.target.value)) - clearBackendFieldError('lagsta') - }} - disabled={isSaving} - className="field__control" - /> - - - - { - setDubbeldagarLeft(parseField(e.target.value)) - clearBackendFieldError('dubbeldagar') - }} - disabled={isSaving} - className="field__control" - /> - - - - -

- {t('parentBalance.summary', { left: daysLeft, total: totalDays })} -
- {t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })} -

- {totalError &&

{totalError}

} - - - - {showAdvanced && ( - - { - setReservedDaysLeft(parseField(e.target.value)) - clearBackendFieldError('reserved') - }} - disabled={isSaving} - className="field__control" - /> - - )} -
-
- -
- - -
+ className="field__control" + /> + + )}
-
+ ) } diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 5b658e3..c784e33 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -4,6 +4,8 @@ import { useTranslation } from '@/i18n' import { Icon } from '@/components/common/Icon' import { FormField } from '@/components/common/FormField' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' +import { InlineConfirm } from '@/components/common/InlineConfirm' import './planned-day-modal.css' /** The default path — todo.md #10/#11: the planner decides which day type @@ -268,59 +270,205 @@ export function PlannedDayModal({ const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode - return ( -
-
e.stopPropagation()} - data-testid="planned-day-modal__content" - > -
-

{modalTitle}

- + )} + -
+ + + )} + + ) -
- {!isRangeMode &&

{displayDate}

} + return ( + + {!isRangeMode &&

{displayDate}

} - {(error || localError) && {error || localError}} + {(error || localError) && {error || localError}} - {!isRangeMode && - excludedEntries.map((entry) => ( -
( +
+ {t('plannedDayModal.excludedNotice', { parent: entry.parentName })} + {onRestoreExcluded && ( + - )} -
- ))} + {t('plannedDayModal.restoreExcludedButton')} + + )} +
+ ))} -
- {!forcedAdvanced && hasMultipleParents && ( - +
+ {!forcedAdvanced && hasMultipleParents && ( + + + + )} + + {!forcedAdvanced && hasMultipleParents && ( +
+ +

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

+
+ )} + + {/* The default path's own copy of the "will stay off" notice — + * `showPinnedControls` is false here (Advanced isn't open), so + * the copy inside that section below never renders for this + * case. Gated on `!showPinnedControls` rather than re-deriving + * "default path" so the two copies can never both render. */} + {!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && ( +

+ {t('plannedDayModal.freeDaysStayOffNotice')} +

+ )} + + {!forcedAdvanced && hasMultipleParents && ( + + )} + + {showPinnedControls && ( +
+ + + + + {freeDaysWillStayOff && ( +

+ {t('plannedDayModal.freeDaysStayOffNotice')} +

+ )} + + {showPinExclusionNotice && ( +

+ {t('plannedDayModal.pinExcludesFromSyncNotice')} +

+ )} + + {hasMultipleParents && ( + setWantsDubbeldag(e.target.checked)} - disabled={isSaving || parentMode !== 'both'} - data-testid="wants-dubbeldag-checkbox" + checked={isDubbeldag} + onChange={(e) => setIsDubbeldag(e.target.checked)} + disabled={isSaving || dayType === 'vacation'} + data-testid="dubbeldag-checkbox" /> - {' '}{t('plannedDayModal.wantsDubbeldagLabel')} + {' '}{t('plannedDayModal.doubleDayLabel')} -

- {parentMode === 'both' - ? t('plannedDayModal.wantsDubbeldagHint') - : t('plannedDayModal.wantsDubbeldagRequiresBothHint')} -

+ {dayType === 'vacation' && ( +

+ {t('plannedDayModal.doubleDayHint')} +

+ )}
)} - {/* The default path's own copy of the "will stay off" notice — - * `showPinnedControls` is false here (Advanced isn't open), so - * the copy inside that section below never renders for this - * case. Gated on `!showPinnedControls` rather than re-deriving - * "default path" so the two copies can never both render. */} - {!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && ( -

- {t('plannedDayModal.freeDaysStayOffNotice')} + {existingDay?.isPinned && ( +

+ + {t('plannedDayModal.pinnedNotice')}

)} - - {!forcedAdvanced && hasMultipleParents && ( - - )} - - {showPinnedControls && ( -
- - - - - {freeDaysWillStayOff && ( -

- {t('plannedDayModal.freeDaysStayOffNotice')} -

- )} - - {showPinExclusionNotice && ( -

- {t('plannedDayModal.pinExcludesFromSyncNotice')} -

- )} - - {hasMultipleParents && ( - - - - )} - - {dayType !== 'auto' && ( -
- - {dayType === 'vacation' && ( -

- {t('plannedDayModal.doubleDayHint')} -

- )} -
- )} - - {existingDay?.isPinned && ( -

- - {t('plannedDayModal.pinnedNotice')} -

- )} -
- )}
-
- -
- {!isRangeMode && existingDay && onDelete && isConfirmingDelete && ( -
- - {t('plannedDayModal.confirmDelete')} - - - -
- )} - {!isConfirmingDelete && ( - <> - {!isRangeMode && existingDay && onDelete && ( - - )} - - - - )} -
+ )}
-
+ ) } diff --git a/frontend/src/components/modals/QRCodeModal.tsx b/frontend/src/components/modals/QRCodeModal.tsx index d08dfe2..6beafd9 100644 --- a/frontend/src/components/modals/QRCodeModal.tsx +++ b/frontend/src/components/modals/QRCodeModal.tsx @@ -3,6 +3,7 @@ import type { ScraperStatus } from '@/hooks/useScraperFetch' import { useTranslation, type TranslationKey } from '@/i18n' import { Icon } from '@/components/common/Icon' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' import './qrcode-modal.css' // Every `ScraperStatus` maps to a translated `balance.status.*` label — the @@ -47,95 +48,83 @@ export function QRCodeModal({ const { t } = useTranslation() if (!isOpen) return null - const handleClose = () => { - if (isRunning) return - onClose() - } + const footer = + status === 'done' || isRunning ? ( + <> + {status === 'done' && ( + + )} + + {isRunning && ( + + )} + + ) : null return ( -
-
-
-

{t('qrModal.title')}

- -
-
- {status && ( -
-
- {t('qrModal.statusLabel')} - - {t(STATUS_LABEL_KEYS[status])} - - {message && — {message}} -
+ + {status && ( +
+
+ {t('qrModal.statusLabel')} + + {t(STATUS_LABEL_KEYS[status])} + + {message && — {message}} +
- {status === 'awaiting_scan' && qrData && ( -
-

{t('qrModal.qrInstruction')}

- {t('qrModal.qrAlt')} -
- )} - - {(status === 'authenticating' || status === 'fetching') && ( -

- {' '} - {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')} -

- )} - - {status === 'done' && result && ( -

- {t('qrModal.success')} -

- )} - - {status === 'cancelled' && ( -

{t('qrModal.cancelled')}

- )} + {status === 'awaiting_scan' && qrData && ( +
+

{t('qrModal.qrInstruction')}

+ {t('qrModal.qrAlt')}
)} - {error && ( - - {t('qrModal.errorLabel')} {error} - + {(status === 'authenticating' || status === 'fetching') && ( +

+ {' '} + {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')} +

+ )} + + {status === 'done' && result && ( +

+ {t('qrModal.success')} +

+ )} + + {status === 'cancelled' && ( +

{t('qrModal.cancelled')}

)}
+ )} - {(status === 'done' || isRunning) && ( -
- {status === 'done' && ( - - )} - - {isRunning && ( - - )} -
- )} -
-
+ {error && ( + + {t('qrModal.errorLabel')} {error} + + )} + ) } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 99460f1..07a0ac3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -24,6 +24,7 @@ import { CalendarExceptions } from '@/components/calendar/CalendarExceptions' import { BalanceFlow } from '@/components/balance/BalanceFlow' import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel' import { Alert } from '@/components/common/Alert' +import { InlineConfirm } from '@/components/common/InlineConfirm' import { orderSelfFirst } from '@/utils/members' import type { VacationDays } from '@/types' import './settings.css' @@ -780,25 +781,19 @@ export function Settings(): ReactNode { {!isSelf && (isConfirmingRemove ? ( - - {t('settings.removeConfirmPrompt', { name: member.name })} - - - + handleConfirmRemove(member.uuid)} + onCancel={handleCancelRemove} + isBusy={isSavingFamily} + confirmTestId={`remove-confirm-${member.uuid}`} + cancelTestId={`remove-cancel-${member.uuid}`} + confirmFirst + small + /> ) : (