feat(answer-modal): conform aan dialog-pattern + entity-profile

Story 7 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- lib/schemas/question-answer.ts — gedeeld zod-schema +
  ANSWER_MAX_CHARS constant
- actions/questions.ts gebruikt het gedeelde schema
- AnswerModal: entityDialog* layout-classes, useDirtyCloseGuard,
  useDialogSubmitShortcut, DemoTooltip rond submit + multiple-choice
  knoppen
- docs/specs/dialogs/answer-modal.md — entity-profile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:34:56 +02:00
parent 0a58557e9d
commit 4b0ab8e4b2
5 changed files with 192 additions and 100 deletions

View file

@ -2,11 +2,11 @@
// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11).
//
// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de
// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via
// useTransition; bij succes wordt de vraag uit de store verwijderd
// Free-text Textarea (max ANSWER_MAX_CHARS) of multiple-choice via knoppen
// wanneer de vraag `options` heeft. Submit roept answerQuestion-Server-Action
// aan via useTransition; bij succes wordt de vraag uit de store verwijderd
// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit
// disabled met tooltip.
// disabled met DemoTooltip.
import { useState, useTransition } from 'react'
import Link from 'next/link'
@ -16,18 +16,25 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
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 {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { ANSWER_MAX_CHARS } from '@/lib/schemas/question-answer'
import { answerQuestion } from '@/actions/questions'
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
const MAX_ANSWER_CHARS = 4000
interface AnswerModalProps {
question: NotificationQuestion | null
isDemo: boolean
@ -38,26 +45,23 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
const [answer, setAnswer] = useState('')
const [pending, startTransition] = useTransition()
if (!question) return null
const closeGuard = useDirtyCloseGuard(answer.trim().length > 0, () => {
setAnswer('')
onClose()
})
const charsLeft = MAX_ANSWER_CHARS - answer.length
const charsLeft = ANSWER_MAX_CHARS - answer.length
const tooLong = charsLeft < 0
const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong
function submit(text: string) {
if (!question) return
if (isDemo) {
toast.error('Niet beschikbaar in demo-modus')
return
}
startTransition(async () => {
const res = await answerQuestion(question.id, text)
if (!res.ok) {
toast.error(res.error)
return
}
// Optimistisch verwijderen — SSE-event komt anders later met dezelfde
// remove en kost een extra render
useNotificationsStore.getState().remove(question.id)
toast.success('Antwoord verstuurd')
setAnswer('')
@ -65,93 +69,107 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
})
}
const handleKeyDown = useDialogSubmitShortcut(() => {
if (!submitDisabled) submit(answer)
})
if (!question) return null
return (
<Dialog open={!!question} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Beantwoord Claude</DialogTitle>
<DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span>
{' — '}
{question.story_title}
</DialogDescription>
</DialogHeader>
<Link
href={`/products/${question.product_id}/sprint`}
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
<>
<Dialog open={!!question} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<ExternalLink className="h-3.5 w-3.5" />
<span>Open in Sprint</span>
</Link>
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
{question.question}
</div>
{question.options && question.options.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
<div className="flex flex-col gap-2">
{question.options.map((opt) => (
<Button
key={opt}
type="button"
variant="outline"
className="justify-start"
disabled={isDemo || pending}
onClick={() => submit(opt)}
>
{opt}
</Button>
))}
<div className={entityDialogHeaderClasses}>
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle>
<DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span>
{' — '}
{question.story_title}
</DialogDescription>
</div>
</div>
) : (
<div className="space-y-1">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Typ je antwoord…"
rows={5}
maxLength={MAX_ANSWER_CHARS}
readOnly={isDemo}
aria-label="Antwoord op Claude's vraag"
/>
<div
className={
tooLong
? 'text-error text-right text-xs'
: 'text-muted-foreground text-right text-xs'
}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<Link
href={`/products/${question.product_id}/sprint`}
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
>
{charsLeft} tekens over
</div>
</div>
)}
<ExternalLink className="h-3.5 w-3.5" />
<span>Open in Sprint</span>
</Link>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={pending}>
Annuleren
</Button>
{(!question.options || question.options.length === 0) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
{question.question}
</div>
{question.options && question.options.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
<div className="flex flex-col gap-2">
{question.options.map((opt) => (
<DemoTooltip key={opt} show={isDemo}>
<Button
type="button"
variant="outline"
className="justify-start"
disabled={isDemo || pending}
onClick={() => submit(opt)}
>
{opt}
</Button>
</DemoTooltip>
))}
</div>
</div>
) : (
<div className="space-y-1">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Typ je antwoord…"
rows={5}
maxLength={ANSWER_MAX_CHARS}
disabled={isDemo}
aria-label="Antwoord op Claude's vraag"
/>
<div
className={
tooLong
? 'text-error text-right text-xs'
: 'text-muted-foreground text-right text-xs'
}
>
{charsLeft} tekens over
</div>
</div>
)}
</div>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
{(!question.options || question.options.length === 0) && (
<DemoTooltip show={isDemo}>
<Button
onClick={() => submit(answer)}
disabled={submitDisabled}
>
{pending ? 'Bezig…' : 'Verstuur'}
</Button>
</TooltipTrigger>
{isDemo && (
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</DemoTooltip>
)}
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}