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:
parent
0a58557e9d
commit
4b0ab8e4b2
5 changed files with 192 additions and 100 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
|||
|
||||
| Title | Status | Updated |
|
||||
|---|---|---|
|
||||
| [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-04 |
|
||||
| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 |
|
||||
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
|
||||
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
|
||||
|
|
|
|||
68
docs/specs/dialogs/answer-modal.md
Normal file
68
docs/specs/dialogs/answer-modal.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
title: "AnswerModal Profiel"
|
||||
status: active
|
||||
audience: [ai-agent, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-04
|
||||
---
|
||||
|
||||
# AnswerModal Profiel
|
||||
|
||||
> Volgt `docs/patterns/dialog.md`. Beschrijft alleen de Q&A-specifieke afwijkingen.
|
||||
|
||||
## Doel
|
||||
|
||||
Een Claude-agent vraagt tijdens een lopende job een verduidelijking aan de gebruiker. De vraag verschijnt als notification (bell + SSE event). Klik op de notification opent deze dialog waarin de gebruiker antwoordt — vrij tekst (max `ANSWER_MAX_CHARS`) of een keuze uit `options` als die meegegeven is.
|
||||
|
||||
## Velden
|
||||
|
||||
| Veld | Type | Mode | Validatie |
|
||||
|---|---|---|---|
|
||||
| `answer` | string | both | min 1, max `ANSWER_MAX_CHARS` (4000), trim |
|
||||
|
||||
`questionId` komt uit de prop `question.id`, niet uit het formulier.
|
||||
|
||||
## Schema
|
||||
|
||||
`lib/schemas/question-answer.ts`:
|
||||
- `answerQuestionSchema` — gedeeld door form + `answerQuestion` action
|
||||
- `ANSWER_MAX_CHARS` constant — gebruikt door textarea + char-counter
|
||||
|
||||
## URL- of state-pattern
|
||||
|
||||
- Gekozen: **state-based** — `question: NotificationQuestion | null`-prop uit `notifications-sheet`.
|
||||
|
||||
## Server action
|
||||
|
||||
- `answerQuestion(questionId, answer)` in `actions/questions.ts`
|
||||
- Result-shape: `{ ok: true } | { ok: false; error: string }`
|
||||
- Demo-policy: `session.isDemo`-check in actie blokkeert demo-writes (laag 2). Laag 3 wordt verzorgd door `<DemoTooltip>` rond submit-knop en disabled-state op de textarea.
|
||||
- Atomic transition met `updateMany` voorkomt double-submit races.
|
||||
|
||||
## Layout
|
||||
|
||||
Gebruikt `entityDialogContentClasses` (§4 spec). Body bevat naast de textarea ook de gestelde vraag (read-only block) en een link naar de bijbehorende sprint. Geen klassieke form-tag — de Textarea is een controlled component.
|
||||
|
||||
## Speciale gedragingen
|
||||
|
||||
### Multiple-choice mode
|
||||
|
||||
Als `question.options` niet leeg is, wordt de textarea vervangen door een lijst van knoppen. Klikken op een knop submit direct met die waarde. De submit-knop in de footer wordt dan verborgen (alleen Annuleren blijft).
|
||||
|
||||
### Optimistic remove
|
||||
|
||||
Na succesvolle submit wordt de vraag direct uit `useNotificationsStore` verwijderd. De SSE-event komt later met dezelfde verwijdering — voorkomt extra render.
|
||||
|
||||
### Dirty-tracking
|
||||
|
||||
Single-field form: dirty = `answer.trim().length > 0`. Esc/backdrop/Cancel met dirty-state opent de standaard guard.
|
||||
|
||||
## Foutcodes
|
||||
|
||||
Action geeft alleen `{ ok, error: string }` terug — geen 422-fieldErrors omdat het een single-field form is. Errors worden via toast getoond. Validatie (`min 1`, `max 4000`) wordt UI-side voorkomen via maxLength + submit-disable.
|
||||
|
||||
## Bewust NIET in v1
|
||||
|
||||
- ❌ **Markdown rendering** — antwoord wordt als plain text doorgegeven; Claude leest 'm direct als context.
|
||||
- ❌ **Cmd/Ctrl+Enter shortcut** — werkt wél voor de textarea-mode (via `useDialogSubmitShortcut`); voor multiple-choice mode is er geen submit om te triggeren.
|
||||
- ❌ **Bulk-answer** — één vraag tegelijk per dialog.
|
||||
10
lib/schemas/question-answer.ts
Normal file
10
lib/schemas/question-answer.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const ANSWER_MAX_CHARS = 4000
|
||||
|
||||
export const answerQuestionSchema = z.object({
|
||||
questionId: z.string().cuid(),
|
||||
answer: z.string().min(1, 'Antwoord mag niet leeg zijn').max(ANSWER_MAX_CHARS),
|
||||
})
|
||||
|
||||
export type AnswerQuestionInput = z.infer<typeof answerQuestionSchema>
|
||||
Loading…
Add table
Add a link
Reference in a new issue