feat(PBI-98/T-1092): empty-state + component-tests

- components/dashboard/products-empty-state.tsx: nieuwe component met
  rounded-xl container, NL-tekst, en NewProductButton (verborgen in demo).
- ProductsTable: vroege return ProductsEmptyState bij products.length===0.
- __tests__/components/dashboard/products-empty-state.test.tsx: 4 tests
  (tekst, NewProductButton zichtbaar/verborgen op demo, container-styling).
- __tests__/components/dashboard/product-row-actions.test.tsx: 7 tests
  (Activeer/Actief-badge zichtbaarheid per state, Docs/Backlog aria-labels,
  Meer-acties-knop disabled in demo). Mocks: next/navigation, sonner,
  actions/products + actions/active-product.
- 1035 tests groen totaal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 15:41:48 +02:00
parent a6fcfe685a
commit 27728296ff
4 changed files with 152 additions and 0 deletions

View file

@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
vi.mock('@/actions/products', () => ({
archiveProductAction: vi.fn().mockResolvedValue({ success: true }),
restoreProductAction: vi.fn().mockResolvedValue({ success: true }),
deleteProductAction: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/actions/active-product', () => ({
setActiveProductAction: vi.fn().mockResolvedValue({ success: true }),
}))
import { ProductRowActions } from '@/components/dashboard/product-row-actions'
const baseProps = {
productId: 'p-1',
productName: 'Scrum4Me',
isActive: false,
isArchived: false,
isDemo: false,
}
describe('ProductRowActions', () => {
it('toont Activeer-knop voor inactief, niet-archived product', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByText(/activeer/i)).not.toBeNull()
})
it('toont "Actief"-badge voor active product (geen Activeer-knop)', () => {
render(<ProductRowActions {...baseProps} isActive={true} />)
expect(screen.queryByText('Actief')).not.toBeNull()
expect(screen.queryByText(/^activeer$/i)).toBeNull()
})
it('verbergt Activeer-knop én badge bij archived product', () => {
render(<ProductRowActions {...baseProps} isArchived={true} />)
expect(screen.queryByText(/^activeer$/i)).toBeNull()
expect(screen.queryByText('Actief')).toBeNull()
})
it('rendert Docs-knop met aria-label', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByLabelText('Docs')).not.toBeNull()
})
it('rendert Open-backlog-knop met aria-label', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByLabelText('Open backlog')).not.toBeNull()
})
it('Meer-acties-knop is disabled in demo-modus', () => {
render(<ProductRowActions {...baseProps} isDemo={true} />)
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
expect(more.disabled).toBe(true)
})
it('Meer-acties-knop is enabled voor reguliere user', () => {
render(<ProductRowActions {...baseProps} />)
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
expect(more.disabled).toBe(false)
})
})

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}))
vi.mock('@/components/dialogs/product-dialog', () => ({
ProductDialog: () => null,
}))
import { ProductsEmptyState } from '@/components/dashboard/products-empty-state'
describe('ProductsEmptyState', () => {
it('toont de empty-tekst', () => {
render(<ProductsEmptyState isDemo={false} />)
expect(screen.queryByText(/nog geen producten aangemaakt/i)).not.toBeNull()
})
it('toont NewProductButton voor reguliere user', () => {
render(<ProductsEmptyState isDemo={false} />)
expect(screen.queryByText(/nieuw product/i)).not.toBeNull()
})
it('toont GEEN NewProductButton in demo-modus', () => {
render(<ProductsEmptyState isDemo={true} />)
expect(screen.queryByText(/nieuw product/i)).toBeNull()
})
it('rendert in een gestylde container', () => {
const { container } = render(<ProductsEmptyState isDemo={false} />)
const root = container.firstChild as HTMLElement
expect(root.className).toContain('rounded-xl')
expect(root.className).toContain('border')
})
})

View file

@ -0,0 +1,35 @@
// Empty-state voor de Dashboard products-tabel: wordt getoond wanneer de
// gebruiker (en alle leden van zijn ProductMember-set) nog géén producten
// hebben. Hergebruikt NewProductButton zodat de CTA dezelfde dialog opent
// als de header-knop.
import { NewProductButton } from '@/components/dashboard/new-product-button'
import { debugProps } from '@/lib/debug'
interface Props {
isDemo: boolean
}
export function ProductsEmptyState({ isDemo }: Props) {
return (
<div
className="rounded-xl border border-border bg-surface-container-low p-12 text-center space-y-4"
{...debugProps(
'products-empty-state',
'ProductsEmptyState',
'components/dashboard/products-empty-state.tsx',
)}
>
<div>
<p className="text-base font-medium">
Nog geen producten aangemaakt
</p>
<p className="text-sm text-muted-foreground mt-1">
Een product groepeert je backlog, sprints en docs. Maak er eentje aan
om te beginnen.
</p>
</div>
{!isDemo && <NewProductButton />}
</div>
)
}

View file

@ -28,6 +28,7 @@ import {
type ProductDialogProduct,
} from '@/components/dialogs/product-dialog'
import { ProductRowActions } from '@/components/dashboard/product-row-actions'
import { ProductsEmptyState } from '@/components/dashboard/products-empty-state'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { debugProps } from '@/lib/debug'
import { cn } from '@/lib/utils'
@ -124,6 +125,13 @@ export function ProductsTable({
void setPref(['views', 'productsTable', 'sortDir'], newDir)
}
// Vroege return: empty-state als de user nog geen enkel product heeft.
// Filter-resultaten (incl. archived-toggle) tonen we wel via tabel met
// "geen resultaten"-rij, zodat de filter-context zichtbaar blijft.
if (products.length === 0) {
return <ProductsEmptyState isDemo={isDemo} />
}
return (
<div
{...debugProps(