diff --git a/__tests__/actions/products.test.ts b/__tests__/actions/products.test.ts index b2cc766..ed538e0 100644 --- a/__tests__/actions/products.test.ts +++ b/__tests__/actions/products.test.ts @@ -107,7 +107,10 @@ describe('createProductAction', () => { const result = await createProductAction(VALID_DATA) - expect(result).toMatchObject({ error: expect.stringContaining('gebruik') }) + expect(result).toMatchObject({ + code: 422, + fieldErrors: { code: expect.arrayContaining([expect.stringContaining('gebruik')]) }, + }) expect(mockTransaction).not.toHaveBeenCalled() }) diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index 6cb59c2..eaa05db 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -53,10 +53,9 @@ describe('createSprintAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) - const result = await createSprintAction(undefined, fd) - expect(result.error).toBeTruthy() - const errors = result.error as Record - expect(errors.end_date?.[0]).toContain('Einddatum') + const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record } + expect(result.code).toBe(422) + expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') }) it('accepts no dates (both optional)', async () => { @@ -81,10 +80,9 @@ describe('updateSprintDatesAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) - const result = await updateSprintDatesAction(undefined, fd) - expect(result.error).toBeTruthy() - const errors = result.error as Record - expect(errors.end_date?.[0]).toContain('Einddatum') + const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record } + expect(result.code).toBe(422) + expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') }) it('blocks demo users', async () => { 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/actions/pbis.ts b/actions/pbis.ts index f2221e8..2ebf7f9 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -3,44 +3,24 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' -import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct } from '@/lib/product-access' -import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' +import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server' import { pbiStatusFromApi } from '@/lib/task-status' +import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi' async function getSession() { return getIronSession(await cookies(), sessionOptions) } -const codeField = z.string().max(MAX_CODE_LENGTH).optional() - -const statusField = z.enum(['ready', 'blocked', 'done']).optional() - -const createPbiSchema = z.object({ - productId: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), - status: statusField, -}) - -const updatePbiSchema = z.object({ - id: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), - status: statusField, -}) +type PbiFieldErrors = Record export async function createPbiAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = createPbiSchema.safeParse({ productId: formData.get('productId'), @@ -50,18 +30,34 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { priority: formData.get('priority'), status: (formData.get('status') as string) || undefined, }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors, + } + } const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden' } + if (!product) return { error: 'Product niet gevonden', code: 403 } const manualCode = normalizeCode(parsed.data.code) if (manualCode !== null && !isValidCode(manualCode)) { - return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors, + } } if (manualCode) { const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } }) - if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } + if (dup) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors, + } + } } const last = await prisma.pbi.findFirst({ @@ -94,7 +90,11 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { (code) => insert(code), ) } catch { - return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as PbiFieldErrors, + } } revalidatePath(`/products/${parsed.data.productId}`) @@ -103,8 +103,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { export async function updatePbiAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = updatePbiSchema.safeParse({ id: formData.get('id'), @@ -114,25 +114,41 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { priority: formData.get('priority'), status: (formData.get('status') as string) || undefined, }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors, + } + } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.id }, include: { product: true }, }) - if (!pbi) return { error: 'PBI niet gevonden' } + if (!pbi) return { error: 'PBI niet gevonden', code: 403 } const accessible = await getAccessibleProduct(pbi.product_id, session.userId) - if (!accessible) return { error: 'PBI niet gevonden' } + if (!accessible) return { error: 'PBI niet gevonden', code: 403 } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors, + } } if (code) { const dup = await prisma.pbi.findFirst({ where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } }, }) - if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } + if (dup) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors, + } + } } const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined @@ -154,16 +170,16 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { export async function deletePbiAction(id: string) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const pbi = await prisma.pbi.findFirst({ where: { id }, include: { product: true }, }) - if (!pbi) return { error: 'PBI niet gevonden' } + if (!pbi) return { error: 'PBI niet gevonden', code: 403 } const accessible = await getAccessibleProduct(pbi.product_id, session.userId) - if (!accessible) return { error: 'PBI niet gevonden' } + if (!accessible) return { error: 'PBI niet gevonden', code: 403 } await prisma.pbi.delete({ where: { id } }) diff --git a/actions/products.ts b/actions/products.ts index a43f492..7024c3b 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -10,8 +10,10 @@ import { SessionData, sessionOptions } from '@/lib/session' import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { productAccessFilter } from '@/lib/product-access' +import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product' -const productSchema = z.object({ +// Legacy FormData schema for ProductForm components (other constraints than dialog) +const productFormDataSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), code: z .string() @@ -29,25 +31,13 @@ const productSchema = z.object({ .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), }) -// Dialog-based schema (data-object API) -const productInput = z.object({ - name: z.string().min(1).max(200), - code: z.string().max(20).optional(), - description: z.string().max(4000).optional(), - repo_url: z - .string() - .url() - .regex(/^https:\/\/github\.com\//) - .optional() - .nullable(), - definition_of_done: z.string().max(4000).optional(), - auto_pr: z.boolean().default(false), -}) - -export type ProductInput = z.infer - -type ProductActionResult = { success: true; productId: string } | { error: string } -type UpdateProductResult = { success: true } | { error: string } +type ProductFieldErrors = Partial> +type ProductActionResult = + | { success: true; productId: string } + | { error: string; code?: number; fieldErrors?: ProductFieldErrors } +type UpdateProductResult = + | { success: true } + | { error: string; code?: number; fieldErrors?: ProductFieldErrors } async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -56,20 +46,30 @@ async function getSession() { // Data-object API used by ProductDialog export async function createProductAction(data: ProductInput): Promise { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = productInput.safeParse(data) - if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, + } + } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] }, + } } if (code) { const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) - if (dup) return { error: 'Code is al in gebruik' } + if (dup) return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Code is al in gebruik'] } } } const userId = session.userId @@ -97,21 +97,31 @@ export async function createProductAction(data: ProductInput): Promise { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = productInput.safeParse(data) - if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, + } + } const product = await prisma.product.findFirst({ where: { id, ...productAccessFilter(session.userId) }, select: { id: true }, }) - if (!product) return { error: 'Product niet gevonden of geen toegang' } + if (!product) return { error: 'Product niet gevonden of geen toegang', code: 403 } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] }, + } } const userId = session.userId @@ -149,7 +159,7 @@ export async function createProductFormAction(_prevState: unknown, formData: For if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const parsed = productSchema.safeParse({ + const parsed = productFormDataSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, @@ -198,7 +208,7 @@ export async function updateProductFormAction(_prevState: unknown, formData: For const id = formData.get('id') as string if (!id) return { error: 'Product niet gevonden' } - const parsed = productSchema.safeParse({ + const parsed = productFormDataSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, diff --git a/actions/questions.ts b/actions/questions.ts index 19a45bc..a14af59 100644 --- a/actions/questions.ts +++ b/actions/questions.ts @@ -12,15 +12,10 @@ // realtime updates voor andere clients. import { revalidatePath } from 'next/cache' -import { z } from 'zod' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { productAccessFilter } from '@/lib/product-access' - -const inputSchema = z.object({ - questionId: z.string().cuid(), - answer: z.string().min(1).max(4000), -}) +import { answerQuestionSchema } from '@/lib/schemas/question-answer' type ActionResult = { ok: true } | { ok: false; error: string } @@ -32,7 +27,7 @@ export async function answerQuestion( if (!session.userId) return { ok: false, error: 'Niet ingelogd' } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } - const parsed = inputSchema.safeParse({ questionId, answer }) + const parsed = answerQuestionSchema.safeParse({ questionId, answer }) if (!parsed.success) { const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' return { ok: false, error: first } diff --git a/actions/sprints.ts b/actions/sprints.ts index 8eb2292..60f109c 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -3,10 +3,14 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' -import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' +import { + createSprintSchema, + updateSprintDatesSchema, + updateSprintGoalSchema, +} from '@/lib/schemas/sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -16,39 +20,34 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } -const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) - -function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) { - if (data.start_date && data.end_date && data.end_date < data.start_date) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) - } -} +type SprintFieldErrors = Record export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const parsed = z.object({ - productId: z.string(), - sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), - start_date: dateField, - end_date: dateField, - }).superRefine(validateDateOrder).safeParse({ + const parsed = createSprintSchema.safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden' } + if (!product) return { error: 'Product niet gevonden', code: 403 } const existing = await prisma.sprint.findFirst({ where: { product_id: parsed.data.productId, status: 'ACTIVE' }, }) - if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id } + if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } const sprint = await prisma.sprint.create({ data: { @@ -66,24 +65,26 @@ export async function createSprintAction(_prevState: unknown, formData: FormData export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const parsed = z.object({ - id: z.string(), - start_date: dateField, - end_date: dateField, - }).superRefine(validateDateOrder).safeParse({ + const parsed = updateSprintDatesSchema.safeParse({ id: formData.get('id'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden' } + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } await prisma.sprint.update({ where: { id: parsed.data.id }, @@ -95,19 +96,27 @@ export async function updateSprintDatesAction(_prevState: unknown, formData: For export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const id = formData.get('id') as string - const sprint_goal = formData.get('sprint_goal') as string - if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' } + const parsed = updateSprintGoalSchema.safeParse({ + id: formData.get('id'), + sprint_goal: formData.get('sprint_goal'), + }) + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const sprint = await prisma.sprint.findFirst({ - where: { id, product: productAccessFilter(session.userId) }, + where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden' } + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } - await prisma.sprint.update({ where: { id }, data: { sprint_goal } }) + await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } diff --git a/actions/stories.ts b/actions/stories.ts index cb18a3d..d744b98 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -3,18 +3,20 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' -import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' -import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' +import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' +import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' async function getSession() { return getIronSession(await cookies(), sessionOptions) } +type StoryFieldErrors = Record + async function verifyStoryAccess(storyId: string, userId: string) { return prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(userId) }, @@ -26,31 +28,10 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } -const codeField = z.string().max(MAX_CODE_LENGTH).optional() - -const createStorySchema = z.object({ - pbiId: z.string(), - productId: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - acceptance_criteria: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), -}) - -const updateStorySchema = z.object({ - id: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - acceptance_criteria: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), -}) - export async function createStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = createStorySchema.safeParse({ pbiId: formData.get('pbiId'), @@ -61,20 +42,36 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority') ?? 2, }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, + } + } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) - if (!pbi) return { error: 'PBI niet gevonden' } + if (!pbi) return { error: 'PBI niet gevonden', code: 403 } const manualCode = normalizeCode(parsed.data.code) if (manualCode !== null && !isValidCode(manualCode)) { - return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, + } } if (manualCode) { const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } }) - if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } + if (dup) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, + } + } } const last = await prisma.story.findFirst({ @@ -107,7 +104,11 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) (code) => insert(code), ) } catch { - return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as StoryFieldErrors, + } } revalidatePath(`/products/${pbi.product_id}`) @@ -116,8 +117,8 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) export async function updateStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = updateStorySchema.safeParse({ id: formData.get('id'), @@ -127,20 +128,36 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority'), }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, + } + } const story = await verifyStoryAccess(parsed.data.id, session.userId) - if (!story) return { error: 'Story niet gevonden' } + if (!story) return { error: 'Story niet gevonden', code: 403 } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, + } } if (code) { const dup = await prisma.story.findFirst({ where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } }, }) - if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } + if (dup) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, + } + } } await prisma.story.update({ 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/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 6af393a..0efbba3 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -2,21 +2,29 @@ import { useEffect, useRef, useState } from 'react' import { useActionState } from 'react' -import { useFormStatus } from 'react-dom' import { toast } from 'sonner' import { Dialog, DialogContent, - DialogHeader, DialogTitle, - DialogFooter, - DialogClose, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { PrioritySelect } from '@/components/shared/priority-select' import { PbiStatusSelect } from '@/components/shared/pbi-status-select' +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 { createPbiAction, updatePbiAction } from '@/actions/pbis' import type { PbiStatusApi } from '@/lib/task-status' @@ -36,18 +44,18 @@ export type PbiDialogState = CreateState | EditState interface PbiDialogProps { state: PbiDialogState | null onClose: () => void + isDemo?: boolean } -function SubmitButton({ label }: { label: string }) { - const { pending } = useFormStatus() - return ( - - ) +interface ActionResult { + success?: boolean + error?: string + code?: number + fieldErrors?: Record + pbi?: unknown } -export function PbiDialog({ state, onClose }: PbiDialogProps) { +export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { const isEdit = state?.mode === 'edit' const pbi = isEdit ? state.pbi : null @@ -57,43 +65,43 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready' const [status, setStatus] = useState(initialStatus) - // Sync priority + status when dialog opens for a different PBI or switches create/edit mode + const [dirty, setDirty] = useState(false) + const formRef = useRef(null) + + // Sync priority + status + reset dirty when dialog opens for a different PBI or switches mode useEffect(() => { if (state) { // eslint-disable-next-line react-hooks/set-state-in-effect setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2)) - setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready') + setDirty(false) } }, [state, isEdit]) - const [createState, createAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await createPbiAction(_prev, fd) + const [createState, createAction, createPending] = useActionState( + async (_prev, fd) => { + const result = await createPbiAction(_prev, fd) as ActionResult if (result?.success) { toast.success('PBI aangemaakt'); onClose() } - else if (typeof result?.error === 'string') toast.error(result.error) + else if (result?.code !== 422 && result?.error) toast.error(result.error) return result }, - undefined + undefined, ) - const [updateState, updateAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await updatePbiAction(_prev, fd) + const [updateState, updateAction, updatePending] = useActionState( + async (_prev, fd) => { + const result = await updatePbiAction(_prev, fd) as ActionResult if (result?.success) { toast.success('PBI opgeslagen'); onClose() } - else if (typeof result?.error === 'string') toast.error(result.error) + else if (result?.code !== 422 && result?.error) toast.error(result.error) return result }, - undefined + undefined, ) + const pending = isEdit ? updatePending : createPending + const activeState = isEdit ? updateState : createState - const error = typeof activeState?.error === 'string' ? activeState.error : null - const fieldError = (field: string) => { - const err = activeState?.error - if (!err || typeof err === 'string') return undefined - return (err as Record)[field]?.[0] - } + const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0] const titleRef = useRef(null) useEffect(() => { @@ -102,84 +110,112 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { } }, [state]) + const closeGuard = useDirtyCloseGuard(dirty, onClose) + const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) + return ( - { if (!open) onClose() }}> - - - {isEdit ? 'PBI bewerken' : 'Nieuw PBI'} - + <> + { if (!open) closeGuard.attemptClose() }}> + +
+ + {isEdit ? 'PBI bewerken' : 'Nieuw PBI'} + +
-
- {isEdit && } - {!isEdit && } - - + setDirty(true)} + className={entityDialogBodyClasses} + > + {isEdit && } + {!isEdit && } + + + +
+
+ + + {fieldError('code') &&

{fieldError('code')}

} +
+
+ + + {fieldError('title') &&

{fieldError('title')}

} +
+
+ +
+
+ + { setPriority(v); setDirty(true) }} /> +
+
+ + { setStatus(v); setDirty(true) }} /> +
+
-
- - + Beschrijving (optioneel) + +