ProductDialog: create + edit met alle velden (#68)

* feat(ST-?): createProductAction + updateProductAction (data-object API)

Voegt data-object-gebaseerde createProductAction(data) en
updateProductAction(id, data) toe aan actions/products.ts voor gebruik
door ProductDialog. Bevat Zod-validatie (incl. github-regex op repo_url),
productAccessFilter voor update, pg_notify bij update, en productMember-
aanleg bij create. FormData-varianten hernoemd naar ...FormAction; callers
bijgewerkt. 9 nieuwe tests groen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-?): ProductDialog component (create + edit modes)

Voegt components/dialogs/product-dialog.tsx toe op basis van het
entity-dialog-patroon. Gebruikt react-hook-form + zodResolver voor
client-side validatie. Roept createProductAction/updateProductAction
aan en werkt stores/products-store.ts optimistisch bij. Demo-modus
disabled alle velden + submit-knop via DemoTooltip. 7 tests groen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-?): UI triggers voor ProductDialog op dashboard en product-detail

Voegt NewProductButton toe op het dashboard (vervangt de /products/new
link) en EditProductButton op de product-detail pagina. Bewerken-knop
is alleen zichtbaar voor de product-eigenaar en verborgen in demo-modus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): cast toast via unknown to satisfy strict TS

`toast as { success, error }` direct-cast faalt omdat sonner's toast een
callable + properties is. TS2352. Cast via unknown lost het op zonder
gedrag te wijzigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-03 17:56:33 +02:00 committed by GitHub
parent 60e2b62bbe
commit 0ee03c6b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 777 additions and 10 deletions

View file

@ -0,0 +1,23 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { ProductDialog } from '@/components/dialogs/product-dialog'
export function NewProductButton() {
const [open, setOpen] = useState(false)
const router = useRouter()
return (
<>
<Button onClick={() => setOpen(true)}>+ Nieuw product</Button>
<ProductDialog
mode="create"
open={open}
onOpenChange={setOpen}
onSaved={(id) => router.push(`/products/${id}`)}
/>
</>
)
}

View file

@ -0,0 +1,270 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
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 { DemoTooltip } from '@/components/shared/demo-tooltip'
import { createProductAction, updateProductAction } from '@/actions/products'
import { useProductsStore } from '@/stores/products-store'
const formSchema = z.object({
name: z.string().min(1, 'Naam is verplicht').max(200),
code: z.string().max(20).optional(),
description: z.string().max(4000).optional(),
repo_url: z.string().max(200).optional(),
definition_of_done: z.string().max(4000).optional(),
auto_pr: z.boolean(),
})
type FormValues = z.infer<typeof formSchema>
export interface ProductDialogProduct {
id: string
name: string
code?: string | null
description?: string | null
repo_url?: string | null
definition_of_done?: string | null
auto_pr?: boolean
}
type Props =
| { mode: 'create'; open: boolean; onOpenChange: (v: boolean) => void; onSaved?: (id: string) => void; isDemo?: boolean }
| { mode: 'edit'; open: boolean; onOpenChange: (v: boolean) => void; product: ProductDialogProduct; onSaved?: (id: string) => void; isDemo?: boolean }
export function ProductDialog(props: Props) {
const { mode, open, onOpenChange, isDemo = false } = props
const product = mode === 'edit' ? props.product : null
const addProduct = useProductsStore((s) => s.addProduct)
const updateProduct = useProductsStore((s) => s.updateProduct)
const [isPending, setIsPending] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: product?.name ?? '',
code: product?.code ?? '',
description: product?.description ?? '',
repo_url: product?.repo_url ?? '',
definition_of_done: product?.definition_of_done ?? '',
auto_pr: product?.auto_pr ?? false,
},
})
// Reset form when dialog opens or switches product
useEffect(() => {
if (open) {
form.reset({
name: product?.name ?? '',
code: product?.code ?? '',
description: product?.description ?? '',
repo_url: product?.repo_url ?? '',
definition_of_done: product?.definition_of_done ?? '',
auto_pr: product?.auto_pr ?? false,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, product?.id])
async function onSubmit(values: FormValues) {
if (isDemo) {
toast.error('Niet beschikbaar in demo-modus')
return
}
setIsPending(true)
try {
const payload = {
name: values.name,
code: values.code || undefined,
description: values.description || undefined,
repo_url: values.repo_url || null,
definition_of_done: values.definition_of_done || undefined,
auto_pr: values.auto_pr,
}
if (mode === 'create') {
const result = await createProductAction(payload)
if ('error' in result) {
toast.error(result.error)
return
}
addProduct({
id: result.productId,
name: values.name,
code: values.code ?? null,
description: values.description ?? null,
repo_url: values.repo_url ?? null,
definition_of_done: values.definition_of_done ?? '',
auto_pr: values.auto_pr,
})
toast.success('Product aangemaakt')
onOpenChange(false)
props.onSaved?.(result.productId)
} else {
const result = await updateProductAction(product!.id, payload)
if ('error' in result) {
toast.error(result.error)
return
}
updateProduct(product!.id, {
name: values.name,
code: values.code ?? null,
description: values.description ?? null,
repo_url: values.repo_url ?? null,
definition_of_done: values.definition_of_done ?? '',
auto_pr: values.auto_pr,
})
toast.success('Product opgeslagen')
onOpenChange(false)
props.onSaved?.(product!.id)
}
} finally {
setIsPending(false)
}
}
const autoPr = form.watch('auto_pr')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}</DialogTitle>
</DialogHeader>
<form
id="product-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
<div className="grid gap-1.5">
<label htmlFor="product-name" className="text-sm font-medium">
Naam <span className="text-error">*</span>
</label>
<Input
id="product-name"
autoFocus={mode === 'create'}
disabled={isDemo}
maxLength={200}
{...form.register('name')}
className={form.formState.errors.name ? 'border-error' : ''}
/>
{form.formState.errors.name && (
<p className="text-xs text-error">{form.formState.errors.name.message}</p>
)}
</div>
<div className="grid gap-1.5">
<label htmlFor="product-code" className="text-sm font-medium">
Code <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-code"
disabled={isDemo}
maxLength={20}
placeholder="korte slug, bv. SCRUM4ME"
className="font-mono text-sm"
{...form.register('code')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-description" className="text-sm font-medium">
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-description"
disabled={isDemo}
rows={3}
maxLength={4000}
className="resize-none"
{...form.register('description')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-repo-url" className="text-sm font-medium">
Repository URL <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-repo-url"
disabled={isDemo}
placeholder="https://github.com/owner/repo"
{...form.register('repo_url')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-dod" className="text-sm font-medium">
Definition of Done <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-dod"
disabled={isDemo}
rows={4}
maxLength={4000}
className="resize-none"
{...form.register('definition_of_done')}
/>
</div>
<div className="flex items-start gap-3">
<button
type="button"
role="switch"
aria-checked={autoPr}
disabled={isDemo}
onClick={() => form.setValue('auto_pr', !autoPr)}
className={cn(
'relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
autoPr ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm',
'transition-transform duration-200',
autoPr ? 'translate-x-4' : 'translate-x-0',
)}
/>
</button>
<div className="grid gap-0.5">
<span className="text-sm font-medium">Automatisch PR aanmaken na voltooide story</span>
<span className="text-xs text-muted-foreground">
Bij elke voltooide story automatisch een PR aanmaken in <code>repo_url</code>
</span>
</div>
</div>
</form>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Annuleren
</DialogClose>
<DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo}>
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,27 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog'
interface Props {
product: ProductDialogProduct
}
export function EditProductButton({ product }: Props) {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Bewerken
</Button>
<ProductDialog
mode="edit"
open={open}
onOpenChange={setOpen}
product={product}
/>
</>
)
}