diff --git a/__tests__/components/use-dialog-submit-shortcut.test.ts b/__tests__/components/use-dialog-submit-shortcut.test.ts new file mode 100644 index 0000000..a53e041 --- /dev/null +++ b/__tests__/components/use-dialog-submit-shortcut.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' + +function makeEvent(opts: Partial) { + return { + metaKey: false, + ctrlKey: false, + key: '', + preventDefault: vi.fn(), + ...opts, + } as unknown as React.KeyboardEvent +} + +describe('useDialogSubmitShortcut', () => { + it('triggert submit op Cmd+Enter', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ metaKey: true, key: 'Enter' }) + + handler(e) + + expect(submit).toHaveBeenCalledTimes(1) + expect(e.preventDefault).toHaveBeenCalled() + }) + + it('triggert submit op Ctrl+Enter', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ ctrlKey: true, key: 'Enter' }) + + handler(e) + + expect(submit).toHaveBeenCalledTimes(1) + }) + + it('triggert NIET op Enter zonder modifier', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ key: 'Enter' }) + + handler(e) + + expect(submit).not.toHaveBeenCalled() + expect(e.preventDefault).not.toHaveBeenCalled() + }) + + it('triggert NIET op Cmd+andere toets', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ metaKey: true, key: 'a' }) + + handler(e) + + expect(submit).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/components/use-dirty-close-guard.test.tsx b/__tests__/components/use-dirty-close-guard.test.tsx new file mode 100644 index 0000000..1220817 --- /dev/null +++ b/__tests__/components/use-dirty-close-guard.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard' + +describe('useDirtyCloseGuard', () => { + it('sluit direct als form niet dirty is', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(false, onClose)) + + act(() => result.current.attemptClose()) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(result.current.confirmOpen).toBe(false) + }) + + it('opent confirm als form dirty is', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.confirmOpen).toBe(true) + }) + + it('confirmDiscard sluit confirm en roept onClose', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + expect(result.current.confirmOpen).toBe(true) + + act(() => result.current.confirmDiscard()) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(result.current.confirmOpen).toBe(false) + }) + + it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + act(() => result.current.setConfirmOpen(false)) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.confirmOpen).toBe(false) + }) +}) diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 2426dc1..abb9ce3 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -28,6 +28,17 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { + useDirtyCloseGuard, + DirtyCloseGuardDialog, +} from '@/components/shared/use-dirty-close-guard' +import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' +import { + entityDialogBodyClasses, + entityDialogContentClasses, + entityDialogFooterClasses, + entityDialogHeaderClasses, +} from '@/components/shared/entity-dialog-layout' import { PrioritySegmented } from './priority-segmented' import { StatusSelect } from './status-select' import { cn } from '@/lib/utils' @@ -70,7 +81,6 @@ const textareaClass = cn( export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() - const [confirmClose, setConfirmClose] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const isEdit = !!task @@ -90,20 +100,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false router.push(closePath) } - function handleAttemptClose() { - if (form.formState.isDirty) { - setConfirmClose(true) - } else { - handleClose() - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault() - form.handleSubmit(onSubmit)() - } - } + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) + const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { startTransition(async () => { @@ -167,19 +165,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false return ( <> - { if (!open) handleAttemptClose() }}> + { if (!open) closeGuard.attemptClose() }}> {/* Sticky header */} -
+
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'} @@ -196,7 +189,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Scrollable form body */} -
+
{/* Title */}
{/* Sticky footer */} -
+
{isEdit ? ( @@ -344,7 +337,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Dirty-check confirm */} - - - - Wijzigingen niet opgeslagen - - Wil je de wijzigingen weggooien? - - - - setConfirmClose(false)}> - Terug - - { setConfirmClose(false); handleClose() }} - > - Weggooien - - - - + {/* Delete confirm */} diff --git a/components/shared/entity-dialog-layout.ts b/components/shared/entity-dialog-layout.ts new file mode 100644 index 0000000..c97ddfb --- /dev/null +++ b/components/shared/entity-dialog-layout.ts @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils' + +export const entityDialogContentClasses = cn( + 'flex flex-col p-0 gap-0', + 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', + 'sm:max-w-[90vw] sm:max-h-[85vh]', + 'lg:max-w-[50vw] lg:min-w-[480px]', +) + +export const entityDialogHeaderClasses = + 'flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0' + +export const entityDialogBodyClasses = 'flex-1 overflow-y-auto px-6 py-6 space-y-6' + +export const entityDialogFooterClasses = + 'border-t border-outline-variant px-6 py-4 shrink-0' diff --git a/components/shared/use-dialog-submit-shortcut.ts b/components/shared/use-dialog-submit-shortcut.ts new file mode 100644 index 0000000..669eea6 --- /dev/null +++ b/components/shared/use-dialog-submit-shortcut.ts @@ -0,0 +1,10 @@ +import type { KeyboardEvent } from 'react' + +export function useDialogSubmitShortcut(submit: () => void) { + return (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + submit() + } + } +} diff --git a/components/shared/use-dirty-close-guard.tsx b/components/shared/use-dirty-close-guard.tsx new file mode 100644 index 0000000..2d8005a --- /dev/null +++ b/components/shared/use-dirty-close-guard.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +export interface DirtyCloseGuard { + confirmOpen: boolean + setConfirmOpen: (v: boolean) => void + attemptClose: () => void + confirmDiscard: () => void +} + +export function useDirtyCloseGuard( + isDirty: boolean, + onClose: () => void, +): DirtyCloseGuard { + const [confirmOpen, setConfirmOpen] = useState(false) + + function attemptClose() { + if (isDirty) setConfirmOpen(true) + else onClose() + } + + function confirmDiscard() { + setConfirmOpen(false) + onClose() + } + + return { confirmOpen, setConfirmOpen, attemptClose, confirmDiscard } +} + +export function DirtyCloseGuardDialog({ + guard, +}: { + guard: DirtyCloseGuard +}) { + return ( + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + guard.setConfirmOpen(false)}> + Terug + + + Weggooien + + + + + ) +}