feat(PBI-98/T-1090): /dashboard rendert ProductsTable; ProductList weg

- app/(app)/dashboard/page.tsx: vervang ProductList-grid door
  ProductsTable + ProductsTableToolbar. Data-fetch met
  include: { _count: { select: { pbis: true } } } — geen N+1 voor
  #PBI's-kolom. Geen archived-searchParam meer; filter zit nu
  client-side in useUserSettingsStore via toolbar.
- max-w-4xl → max-w-6xl voor de bredere tabel.
- Verwijderd:
  - components/dashboard/product-list.tsx (grid, obsolete)
  - __tests__/components/dashboard/product-list.test.tsx
- 1024 tests blijven groen (1028 → 1024 door obsolete ProductList-tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 15:39:21 +02:00
parent 98526f9f20
commit 2c6d356acf
3 changed files with 41 additions and 253 deletions

View file

@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: vi.fn() }) }))
vi.mock('@/actions/products', () => ({ restoreProductAction: vi.fn() }))
vi.mock('@/actions/active-product', () => ({ setActiveProductAction: vi.fn() }))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/dialogs/product-dialog', () => ({
ProductDialog: ({ open }: { open: boolean }) => (open ? <div role="dialog">ProductDialog</div> : null),
}))
import { ProductList } from '@/components/dashboard/product-list'
const PRODUCT = {
id: 'p1',
name: 'Mijn Product',
code: 'MP',
description: 'Een product',
repo_url: 'https://github.com/foo/bar',
definition_of_done: 'klaar als het werkt',
auto_pr: false,
}
beforeEach(() => {
pushMock.mockClear()
})
describe('ProductList — edit-icoon (todo cmoq3ox51)', () => {
it('rendert pencil-icoon (Bewerk product) op active card, geen tekstknop "Bewerken"', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.getByLabelText('Bewerk product')).toBeTruthy()
// Oude tekstknop is weg
expect(screen.queryByText('Bewerken')).toBeNull()
})
it('opent ProductDialog op klik (en stopt propagation zodat card-click niet navigeert)', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByLabelText('Bewerk product'))
expect(screen.getByRole('dialog')).toBeTruthy()
expect(pushMock).not.toHaveBeenCalled() // card-navigation niet getriggerd
})
it('demo-user: knop is disabled', () => {
render(<ProductList products={[PRODUCT]} isDemo={true} activeProductId="p1" />)
const btn = screen.getByLabelText('Bewerk product') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('toont geen edit-icoon bij gearchiveerde producten', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} showArchived={true} activeProductId={null} />)
expect(screen.queryByLabelText('Bewerk product')).toBeNull()
})
})

View file

@ -1,56 +1,63 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import Link from 'next/link'
import { ProductList } from '@/components/dashboard/product-list'
import { NewProductButton } from '@/components/dashboard/new-product-button'
import { ProductsTable } from '@/components/dashboard/products-table'
import { ProductsTableToolbar } from '@/components/dashboard/products-table-toolbar'
import type { ProductsTableRow } from '@/components/dashboard/products-table'
interface Props {
searchParams: Promise<{ archived?: string }>
}
export default async function DashboardPage({ searchParams }: Props) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const { archived } = await searchParams
const showArchived = archived === '1'
export default async function DashboardPage() {
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions,
)
// Tabel-filter (archived) leeft client-side in useUserSettingsStore;
// server haalt alle toegankelijke producten + #PBI's in één query
// (geen N+1) en geeft alles door aan ProductsTable.
const [products, user] = await Promise.all([
prisma.product.findMany({
where: { archived: showArchived, ...productAccessFilter(session.userId) },
orderBy: { created_at: 'desc' },
where: productAccessFilter(session.userId ?? ''),
include: { _count: { select: { pbis: true } } },
orderBy: { updated_at: 'desc' },
}),
session.userId
? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } })
? prisma.user.findUnique({
where: { id: session.userId },
select: { active_product_id: true },
})
: null,
])
const rows: ProductsTableRow[] = products.map((p) => ({
id: p.id,
name: p.name,
code: p.code,
description: p.description,
repo_url: p.repo_url,
definition_of_done: p.definition_of_done,
auto_pr: p.auto_pr,
archived: p.archived,
pbiCount: p._count.pbis,
updated_at: p.updated_at,
}))
return (
<div className="p-6 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-medium text-foreground">
{showArchived ? 'Gearchiveerde producten' : 'Mijn Producten'}
</h1>
{showArchived ? (
<Link href="/dashboard" className="text-xs text-primary hover:underline">
Actief
</Link>
) : (
<Link href="/dashboard?archived=1" className="text-xs text-muted-foreground hover:text-foreground">
Toon gearchiveerd
</Link>
)}
</div>
{!session.isDemo && !showArchived && <NewProductButton />}
<div className="p-6 max-w-6xl mx-auto w-full space-y-4">
<div className="flex items-center justify-between gap-3">
<h1 className="text-xl font-medium text-foreground">Producten</h1>
{!session.isDemo && <NewProductButton />}
</div>
<ProductList
products={products}
isDemo={session.isDemo ?? false}
showArchived={showArchived}
<ProductsTableToolbar />
<ProductsTable
products={rows}
activeProductId={user?.active_product_id ?? null}
isDemo={session.isDemo ?? false}
/>
</div>
)

View file

@ -1,163 +0,0 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { restoreProductAction } from '@/actions/products'
import { setActiveProductAction } from '@/actions/active-product'
import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog'
import { debugProps } from '@/lib/debug'
interface Product {
id: string
name: string
code: string | null
description: string | null
repo_url: string | null
definition_of_done: string | null
auto_pr: boolean
}
interface ProductListProps {
products: Product[]
isDemo: boolean
showArchived?: boolean
activeProductId: string | null
}
export function ProductList({ products, isDemo, showArchived = false, activeProductId }: ProductListProps) {
const router = useRouter()
const [, startTransition] = useTransition()
const [editingProduct, setEditingProduct] = useState<ProductDialogProduct | null>(null)
function handleRestore(id: string) {
startTransition(async () => {
const result = await restoreProductAction(id)
if ('error' in result) toast.error(result.error ?? 'Herstellen mislukt')
else { toast.success('Product hersteld'); router.refresh() }
})
}
function handleActivate(id: string) {
startTransition(async () => {
const result = await setActiveProductAction(id)
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
else router.push(`/products/${id}`)
})
}
if (products.length === 0) {
return (
<div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}>
<p className="text-muted-foreground">
{showArchived
? 'Geen gearchiveerde producten.'
: 'Je hebt nog geen producten aangemaakt.'}
</p>
<DemoTooltip show={isDemo}>
<Button variant="outline" nativeButton={false} render={<Link href={isDemo ? '#' : '/products/new'} />} disabled={isDemo}>
Maak je eerste product aan
</Button>
</DemoTooltip>
</div>
)
}
return (
<div className="grid gap-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}>
{products.map(product => (
<div
key={product.id}
onClick={() => !showArchived && router.push(`/products/${product.id}`)}
className={`group bg-surface-container-low border border-border rounded-xl p-4 transition-colors ${
showArchived ? 'opacity-60' : 'cursor-pointer hover:border-primary'
}`}
data-debug-id="product-list__items"
>
<div className="flex items-start justify-between gap-4" data-debug-id="product-list__header">
<div className="min-w-0">
<div className="flex items-center gap-2">
{product.code && <CodeBadge code={product.code} />}
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{product.name}
</p>
</div>
{product.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''}
</p>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{product.repo_url && (
<a
href={product.repo_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="text-xs text-muted-foreground hover:text-primary underline"
>
Repo
</a>
)}
{!showArchived && (
<>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) setEditingProduct(product) }}
className="opacity-0 group-hover:opacity-100 inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk product"
disabled={isDemo}
>
<Pencil size={14} />
</button>
</DemoTooltip>
{product.id === activeProductId
? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge>
: (
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }}
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
disabled={isDemo}
>
Activeer
</button>
</DemoTooltip>
)
}
</>
)}
{showArchived && (
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleRestore(product.id) }}
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
disabled={isDemo}
>
Herstellen
</button>
</DemoTooltip>
)}
</div>
</div>
</div>
))}
{editingProduct && (
<ProductDialog
mode="edit"
open={!!editingProduct}
onOpenChange={(v) => { if (!v) setEditingProduct(null) }}
product={editingProduct}
isDemo={isDemo}
/>
)}
</div>
)
}