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:
parent
98526f9f20
commit
2c6d356acf
3 changed files with 41 additions and 253 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue