diff --git a/CLAUDE.md b/CLAUDE.md index 72b4e92..4ed8cdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,8 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Story met UI-component | `docs/patterns/story-with-ui-component.md` | | Web Push | `docs/patterns/web-push.md` | | Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` | +| Debug-id op component-root | `docs/patterns/debug-id.md` | +| Debug-labels (BEM) | `docs/patterns/debug-labels.md` | --- diff --git a/__tests__/lib/debug.test.ts b/__tests__/lib/debug.test.ts new file mode 100644 index 0000000..12a1e33 --- /dev/null +++ b/__tests__/lib/debug.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' + +import { debugProps } from '@/lib/debug' + +describe('debugProps', () => { + it('returns data-debug-id attr in dev mode', () => { + const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') + expect(result).toEqual({ + 'data-debug-id': 'sprint-board', + }) + }) + + it('returns empty object in production mode', () => { + const original = process.env.NODE_ENV + try { + vi.stubEnv('NODE_ENV', 'production') + const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') + expect(result).toEqual({}) + } finally { + vi.stubEnv('NODE_ENV', original ?? 'test') + } + }) +}) diff --git a/app/globals.css b/app/globals.css index 6c32368..e8d3b09 100644 --- a/app/globals.css +++ b/app/globals.css @@ -11,7 +11,7 @@ body.debug-mode [data-debug-id] { position: relative; } body.debug-mode [data-debug-id]:hover::after { - content: attr(data-debug-label); + content: attr(data-debug-id); position: absolute; top: 0; left: 0; diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx index cddf90b..a236bed 100644 --- a/components/admin/jobs-table.tsx +++ b/components/admin/jobs-table.tsx @@ -12,6 +12,7 @@ import { TableRow, } from '@/components/ui/table' import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs' +import { debugProps } from '@/lib/debug' type Job = { id: string @@ -100,7 +101,7 @@ function JobRow({ job }: { job: Job }) { function StatusTable({ jobs }: { jobs: Job[] }) { return ( - +
ID @@ -171,7 +172,7 @@ function CostRow({ job }: { job: Job }) { function CostsTable({ jobs }: { jobs: Job[] }) { return ( -
+
ID @@ -203,8 +204,8 @@ export function JobsTable({ jobs }: { jobs: Job[] }) { const [view, setView] = useState<'status' | 'costs'>('status') return ( -
-
+
+
+ Naam Eigenaar @@ -90,7 +91,7 @@ export function ProductsTable({ products }: { products: Product[] }) { Acties - + {products.length === 0 && ( diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx index 172cd41..32161c5 100644 --- a/components/admin/users-table.tsx +++ b/components/admin/users-table.tsx @@ -26,6 +26,7 @@ import { updateUserRolesAction, setMustResetPasswordAction, } from '@/actions/admin/users' +import { debugProps } from '@/lib/debug' type UserWithRoles = { id: string @@ -190,8 +191,8 @@ export function UsersTable({ currentUserId: string }) { return ( -
- +
+ Gebruiker Email @@ -201,7 +202,7 @@ export function UsersTable({ Acties - + {users.map(user => ( {user.username} diff --git a/components/auth/auth-form.tsx b/components/auth/auth-form.tsx index 6ec179b..4879c57 100644 --- a/components/auth/auth-form.tsx +++ b/components/auth/auth-form.tsx @@ -4,13 +4,14 @@ import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { debugProps } from '@/lib/debug' type ActionResult = { error: string | Record } | undefined function SubmitButton({ label }: { label: string }) { const { pending } = useFormStatus() return ( - ) @@ -34,7 +35,7 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { const errorMessage = getErrorMessage(state) return ( -
+
@@ -64,6 +66,7 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { minLength={8} placeholder="••••••••" className="bg-input-background border-border focus-visible:ring-primary" + data-debug-id="auth-form__password" /> diff --git a/components/backlog/backlog-card.tsx b/components/backlog/backlog-card.tsx index 7a93910..26fab89 100644 --- a/components/backlog/backlog-card.tsx +++ b/components/backlog/backlog-card.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react' import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' +import { debugProps } from '@/lib/debug' export const PRIORITY_BORDER: Record = { 1: 'border-l-4 border-l-priority-critical', @@ -38,9 +39,10 @@ export const BacklogCard = forwardRef(function className, )} {...rest} + {...debugProps('backlog-card', 'BacklogCard', 'components/backlog/backlog-card.tsx')} >
-

{title}

+

{title}

{code && }
{(badge || actions) && ( diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx index e48f688..6fd531b 100644 --- a/components/backlog/empty-panel.tsx +++ b/components/backlog/empty-panel.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { debugProps } from '@/lib/debug' interface EmptyPanelProps { title?: string @@ -15,8 +16,8 @@ interface EmptyPanelProps { export function EmptyPanel({ title, message, action }: EmptyPanelProps) { return ( -
- {title &&

{title}

} +
+ {title &&

{title}

}

{message}

{action && ( @@ -25,6 +26,7 @@ export function EmptyPanel({ title, message, action }: EmptyPanelProps) { variant="outline" disabled={action.disabled} onClick={action.disabled ? undefined : action.onClick} + {...debugProps('empty-panel__cta')} > {action.label} diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 0efbba3..64664dc 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -27,6 +27,7 @@ import { } from '@/components/shared/entity-dialog-layout' import { createPbiAction, updatePbiAction } from '@/actions/pbis' import type { PbiStatusApi } from '@/lib/task-status' +import { debugProps } from '@/lib/debug' export interface PbiDialogPbi { id: string @@ -120,6 +121,7 @@ export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { showCloseButton={false} onKeyDown={handleKeyDown} className={entityDialogContentClasses} + {...debugProps('pbi-dialog', 'PbiDialog', 'components/backlog/pbi-dialog.tsx')} >
@@ -168,6 +170,7 @@ export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { disabled={isDemo} aria-invalid={!!fieldError('title')} className={fieldError('title') ? 'border-error' : ''} + {...debugProps('pbi-dialog__title')} /> {fieldError('title') &&

{fieldError('title')}

}
@@ -207,7 +210,7 @@ export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { Annuleren - diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 588e0b0..77d8511 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -31,6 +31,7 @@ import { useBacklogStore } from '@/stores/backlog-store' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' +import { debugProps } from '@/lib/debug' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' @@ -390,8 +391,8 @@ export function PbiList({ productId, isDemo }: PbiListProps) { const activePbi = activeDragId ? pbiMap[activeDragId] : null return ( -
-
+
+
{filterPriority !== 'all' && ( - diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 9707a62..c1dd2c1 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -30,6 +30,7 @@ import { usePlannerStore } from '@/stores/planner-store' import { useBacklogStore } from '@/stores/backlog-store' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' +import { debugProps } from '@/lib/debug' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' import { DemoTooltip } from '@/components/shared/demo-tooltip' @@ -210,7 +211,7 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { const hasActiveFilters = filterStatus !== null || filterPriority !== null return ( -
+
-
+
{selectedPbiId === null ? ( ) : rawStories.length === 0 ? ( diff --git a/components/backlog/task-panel.tsx b/components/backlog/task-panel.tsx index c3d7526..4f4f524 100644 --- a/components/backlog/task-panel.tsx +++ b/components/backlog/task-panel.tsx @@ -30,6 +30,7 @@ import { useSelectionStore } from '@/stores/selection-store' import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store' import { reorderTasksAction } from '@/actions/tasks' import { BacklogCard } from './backlog-card' +import { debugProps } from '@/lib/debug' import { EmptyPanel } from './empty-panel' import { cn } from '@/lib/utils' @@ -152,15 +153,18 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { if (!selectedStoryId) return router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`) }} + {...debugProps('task-panel__actions')} > + Nieuwe taak ) + const dp = debugProps('task-panel', 'TaskPanel', 'components/backlog/task-panel.tsx') + if (tasks === null) { return ( -
+
@@ -169,7 +173,7 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { if (tasks.length === 0) { return ( -
+
t.id === activeDragId) : null return ( -
+
- +
+ +
+

{showArchived ? 'Geen gearchiveerde producten.' @@ -69,7 +70,7 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd } return ( -

+
{products.map(product => (
-
+
{product.code && } diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx index a478fb9..20eeaea 100644 --- a/components/dialogs/product-dialog.tsx +++ b/components/dialogs/product-dialog.tsx @@ -28,6 +28,7 @@ import { import { productSchema, type ProductInput } from '@/lib/schemas/product' import { createProductAction, updateProductAction } from '@/actions/products' import { useProductsStore } from '@/stores/products-store' +import { debugProps } from '@/lib/debug' export interface ProductDialogProduct { id: string @@ -159,6 +160,7 @@ export function ProductDialog(props: Props) { showCloseButton={false} onKeyDown={handleKeyDown} className={entityDialogContentClasses} + {...debugProps('product-dialog', 'ProductDialog', 'components/dialogs/product-dialog.tsx')} >
@@ -170,6 +172,7 @@ export function ProductDialog(props: Props) { id="product-form" onSubmit={form.handleSubmit(onSubmit)} className={entityDialogBodyClasses} + data-debug-id="product-dialog__content" >
+
diff --git a/components/ideas/idea-md-editor.tsx b/components/ideas/idea-md-editor.tsx index 85c52bd..44a6368 100644 --- a/components/ideas/idea-md-editor.tsx +++ b/components/ideas/idea-md-editor.tsx @@ -16,6 +16,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +import { debugProps } from '@/lib/debug' import { parsePlanMd, type PlanParseError } from '@/lib/idea-plan-parser' import { updateGrillMdAction, updatePlanMdAction } from '@/actions/ideas' @@ -112,7 +113,7 @@ export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) { const dirty = value !== initialValue return ( -
+
{errors.length > 0 && (

@@ -138,6 +139,7 @@ export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) { onKeyDown={onKeyDown} rows={24} className="font-mono text-sm leading-relaxed" + data-debug-id="idea-md-editor__textarea" placeholder={ kind === 'grill' ? '# Idee — ...\n## Scope\n...' @@ -159,6 +161,7 @@ export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) { size="sm" onClick={save} disabled={!dirty || submitting || (errors.length > 0 && kind === 'plan')} + data-debug-id="idea-md-editor__save" > Opslaan diff --git a/components/ideas/idea-pbi-link-card.tsx b/components/ideas/idea-pbi-link-card.tsx index 1d47854..53a1490 100644 --- a/components/ideas/idea-pbi-link-card.tsx +++ b/components/ideas/idea-pbi-link-card.tsx @@ -11,6 +11,7 @@ import { ExternalLink, Link2Off } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { debugProps } from '@/lib/debug' import { relinkIdeaPlanAction } from '@/actions/ideas' import type { IdeaDto } from '@/lib/idea-dto' @@ -27,9 +28,9 @@ export function IdeaPbiLinkCard({ idea, isDemo }: Props) { if (idea.pbi && idea.product_id) { return ( -

+
-

+

Gepland

@@ -37,6 +38,7 @@ export function IdeaPbiLinkCard({ idea, isDemo }: Props) { {idea.pbi.code} — {idea.pbi.title} @@ -62,7 +64,7 @@ export function IdeaPbiLinkCard({ idea, isDemo }: Props) { } return ( -

+

diff --git a/components/ideas/idea-row-actions.tsx b/components/ideas/idea-row-actions.tsx index 769a6cd..1a9350d 100644 --- a/components/ideas/idea-row-actions.tsx +++ b/components/ideas/idea-row-actions.tsx @@ -36,6 +36,7 @@ import { } from '@/components/ui/tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { useSoloStore } from '@/stores/solo-store' +import { debugProps } from '@/lib/debug' import { startGrillJobAction, startMakePlanJobAction, @@ -134,7 +135,7 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) } return ( -

+
{/* Bekijk PBI — alleen zichtbaar in PLANNED */} {status === 'planned' && idea.pbi && idea.product_id && ( diff --git a/components/ideas/idea-sync-tab.tsx b/components/ideas/idea-sync-tab.tsx index b05e46b..a5dc0e0 100644 --- a/components/ideas/idea-sync-tab.tsx +++ b/components/ideas/idea-sync-tab.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge' import { StoryLog } from '@/components/shared/story-log' import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' import type { ClaudeJobStatusApi } from '@/lib/job-status' +import { debugProps } from '@/lib/debug' import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server' interface Props { @@ -88,9 +89,9 @@ export function IdeaSyncTab({ data }: Props) { if (!pbi) return null return ( -
+
{/* Header: PBI-link + PR-status */} - ) } diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx index c6265d6..9fab88a 100644 --- a/components/ideas/idea-timeline.tsx +++ b/components/ideas/idea-timeline.tsx @@ -25,6 +25,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +import { debugProps } from '@/lib/debug' import { answerQuestion } from '@/actions/questions' import { UserChatInput } from '@/components/ideas/user-chat-input' @@ -124,13 +125,13 @@ export function IdeaTimeline({ const showChatInput = planMd !== null return ( -
+
{merged.length === 0 ? (

Nog geen activiteit op dit idee.

) : ( -
    +
      {merged.map((entry, i) => { // Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen // (server-locale verschilde van browser-locale). diff --git a/components/ideas/user-chat-input.tsx b/components/ideas/user-chat-input.tsx index cbdb4bd..3ed7c7c 100644 --- a/components/ideas/user-chat-input.tsx +++ b/components/ideas/user-chat-input.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +import { debugProps } from '@/lib/debug' import { createUserQuestionAction } from '@/actions/user-questions' interface Props { @@ -39,7 +40,7 @@ export function UserChatInput({ ideaId, isDemo = false }: Props) { if (isDemo) { return ( -
      +

      Demo-modus: vragen stellen is niet beschikbaar.

      @@ -48,7 +49,7 @@ export function UserChatInput({ ideaId, isDemo = false }: Props) { } return ( -
      +
      @@ -58,12 +59,14 @@ export function UserChatInput({ ideaId, isDemo = false }: Props) { rows={3} placeholder="Bijv. Waarom is gekozen voor X in plaats van Y?" disabled={pending} + data-debug-id="user-chat-input__textarea" />
      ) : ( -
        +
          {questions.map((q) => { // story-questions: forYou wanneer assignee = ingelogd; idee-vragen // zijn altijd "voor jou" (idee is strikt user_id-only). diff --git a/components/notifications/push-toggle.tsx b/components/notifications/push-toggle.tsx index 0351335..92a497a 100644 --- a/components/notifications/push-toggle.tsx +++ b/components/notifications/push-toggle.tsx @@ -10,6 +10,7 @@ import { subscribeToPush, unsubscribeFromPush, } from '@/lib/push-client' +import { debugProps } from '@/lib/debug' type PushStatus = | 'loading' @@ -85,7 +86,7 @@ export function PushToggle({ vapidPublicKey }: PushToggleProps) { if (status === 'ios-needs-install') { return ( -
          +
          Op iPhone/iPad: tik op het delen-icoon en kies{' '} Zet op beginscherm. Daarna kun je notificaties activeren.
          @@ -94,7 +95,7 @@ export function PushToggle({ vapidPublicKey }: PushToggleProps) { if (status === 'denied') { return ( -

          +

          Notificaties zijn geblokkeerd. Schakel ze in via je browser-instellingen.

          ) @@ -102,15 +103,19 @@ export function PushToggle({ vapidPublicKey }: PushToggleProps) { if (status === 'unsubscribed') { return ( - +
          + +
          ) } return ( - +
          + +
          ) } diff --git a/components/products/archive-product-button.tsx b/components/products/archive-product-button.tsx index a083e77..6a2244e 100644 --- a/components/products/archive-product-button.tsx +++ b/components/products/archive-product-button.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from 'react' import { Button } from '@/components/ui/button' import { archiveProductAction } from '@/actions/products' +import { debugProps } from '@/lib/debug' interface ArchiveProductButtonProps { productId: string @@ -20,7 +21,7 @@ export function ArchiveProductButton({ productId }: ArchiveProductButtonProps) { if (confirming) { return ( -
          +
          diff --git a/components/products/auto-pr-toggle.tsx b/components/products/auto-pr-toggle.tsx index 0158627..114f426 100644 --- a/components/products/auto-pr-toggle.tsx +++ b/components/products/auto-pr-toggle.tsx @@ -4,6 +4,7 @@ import { useState, useTransition } from 'react' import { cn } from '@/lib/utils' import { updateAutoPrAction } from '@/actions/products' import { toast } from 'sonner' +import { debugProps } from '@/lib/debug' interface AutoPrToggleProps { productId: string @@ -27,13 +28,14 @@ export function AutoPrToggle({ productId, initialValue }: AutoPrToggleProps) { } return ( -
          +
          - Automatisch PR aanmaken na succesvolle agent-job + Automatisch PR aanmaken na succesvolle agent-job
          ) } diff --git a/components/products/edit-product-button.tsx b/components/products/edit-product-button.tsx index 958eb1c..0805a23 100644 --- a/components/products/edit-product-button.tsx +++ b/components/products/edit-product-button.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog' +import { debugProps } from '@/lib/debug' interface Props { product: ProductDialogProduct @@ -16,7 +17,7 @@ export function EditProductButton({ product, isDemo = false, size = 'sm', varian const [open, setOpen] = useState(false) return ( - <> + @@ -34,16 +35,18 @@ export function LeaveProductButton({ productId, isDemo = false }: LeaveProductBu } return ( - - - + + + + + ) } diff --git a/components/settings/min-quota-editor.tsx b/components/settings/min-quota-editor.tsx index feaf2d2..f1e8147 100644 --- a/components/settings/min-quota-editor.tsx +++ b/components/settings/min-quota-editor.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { updateMinQuotaPctAction } from '@/actions/settings' +import { debugProps } from '@/lib/debug' interface MinQuotaEditorProps { currentValue: number @@ -27,7 +28,7 @@ export function MinQuotaEditor({ currentValue, isDemo }: MinQuotaEditorProps) { } return ( -
          +