Initial commit

This commit is contained in:
2026-06-01 16:48:21 +02:00
commit 53d157535a
159 changed files with 13641 additions and 0 deletions
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LoadingSpinner } from './LoadingSpinner'
describe('LoadingSpinner', () => {
it('renders loading spinner', () => {
render(<LoadingSpinner />)
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
it('displays default message', () => {
render(<LoadingSpinner />)
expect(screen.getByTestId('spinner-message')).toHaveTextContent('Loading...')
})
it('displays custom message', () => {
render(<LoadingSpinner message="Please wait..." />)
expect(screen.getByTestId('spinner-message')).toHaveTextContent('Please wait...')
})
it('has correct aria attributes for accessibility', () => {
render(<LoadingSpinner ariaLabel="Loading data" />)
const spinner = screen.getByTestId('loading-spinner')
expect(spinner).toHaveAttribute('role', 'status')
expect(spinner).toHaveAttribute('aria-label', 'Loading data')
expect(spinner).toHaveAttribute('aria-busy', 'true')
})
it('uses default aria label', () => {
render(<LoadingSpinner />)
const spinner = screen.getByTestId('loading-spinner')
expect(spinner).toHaveAttribute('aria-label', 'Loading')
})
})
@@ -0,0 +1,15 @@
import { ReactNode } from 'react'
interface LoadingSpinnerProps {
message?: string
ariaLabel?: string
}
export function LoadingSpinner({ message = 'Loading...', ariaLabel = 'Loading' }: LoadingSpinnerProps): ReactNode {
return (
<div role="status" aria-label={ariaLabel} aria-busy={true} data-testid="loading-spinner">
<div data-testid="spinner-icon" role="presentation">⊙</div>
<p data-testid="spinner-message">{message}</p>
</div>
)
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { OverviewMetrics } from './OverviewMetrics'
describe('OverviewMetrics', () => {
it('renders metrics section', () => {
render(<OverviewMetrics metrics={[]} />)
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
})
it('renders default title', () => {
render(<OverviewMetrics metrics={[]} />)
expect(screen.getByTestId('metrics-title')).toHaveTextContent('Overview')
})
it('renders custom title', () => {
render(<OverviewMetrics metrics={[]} title="Custom Title" />)
expect(screen.getByTestId('metrics-title')).toHaveTextContent('Custom Title')
})
it('renders metrics list with items', () => {
const metrics = [
{ label: 'Days Used', value: '10', testId: 'days-used' },
{ label: 'Days Left', value: '15', testId: 'days-left' },
]
render(<OverviewMetrics metrics={metrics} />)
expect(screen.getByTestId('metric-label-0')).toHaveTextContent('Days Used:')
expect(screen.getByTestId('metric-value-0')).toHaveTextContent('10')
expect(screen.getByTestId('metric-label-1')).toHaveTextContent('Days Left:')
expect(screen.getByTestId('metric-value-1')).toHaveTextContent('15')
})
it('renders empty metrics list', () => {
render(<OverviewMetrics metrics={[]} />)
expect(screen.getByTestId('metrics-list')).toBeInTheDocument()
})
it('uses custom test ids when provided', () => {
const metrics = [{ label: 'Test', value: '100', testId: 'custom-test-id' }]
render(<OverviewMetrics metrics={metrics} />)
expect(screen.getByTestId('custom-test-id')).toBeInTheDocument()
})
it('has proper accessibility structure', () => {
const metrics = [{ label: 'Metric', value: '50' }]
render(<OverviewMetrics metrics={metrics} title="Metrics Section" />)
const section = screen.getByTestId('overview-metrics')
expect(section).toHaveAttribute('role', 'region')
expect(section).toHaveAttribute('aria-label', 'Metrics Section')
expect(screen.getByTestId('metrics-list')).toHaveAttribute('role', 'list')
})
})
@@ -0,0 +1,33 @@
import { ReactNode } from 'react'
interface Metric {
label: string
value: string | number
testId?: string
}
interface OverviewMetricsProps {
metrics: Metric[]
title?: string
}
export function OverviewMetrics({ metrics, title = 'Overview' }: OverviewMetricsProps): ReactNode {
return (
<section role="region" aria-label={title} data-testid="overview-metrics">
<h2 data-testid="metrics-title">{title}</h2>
<div role="list" data-testid="metrics-list">
{metrics.map((metric, index) => (
<div
key={index}
role="listitem"
data-testid={metric.testId || `metric-${index}`}
data-label={metric.label}
>
<span data-testid={`metric-label-${index}`}>{metric.label}:</span>
<span data-testid={`metric-value-${index}`}>{metric.value}</span>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ChildBirthdayInput } from './ChildBirthdayInput'
describe('ChildBirthdayInput', () => {
it('renders birth date input', () => {
const onBirthDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
/>
)
expect(screen.getByTestId('birth-date-input')).toBeInTheDocument()
})
it('renders custom label', () => {
const onBirthDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
label="Custom Label"
/>
)
expect(screen.getByTestId('birthday-legend')).toHaveTextContent('Custom Label')
})
it('calls onBirthDateChange when birth date changes', async () => {
const onBirthDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
/>
)
const input = screen.getByTestId('birth-date-input')
await userEvent.clear(input)
await userEvent.type(input, '2024-07-20')
expect(onBirthDateChange).toHaveBeenCalled()
})
it('renders preschool date input when callback provided', () => {
const onBirthDateChange = vi.fn()
const onPreschoolStartDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
onPreschoolStartDateChange={onPreschoolStartDateChange}
/>
)
expect(screen.getByTestId('preschool-start-date-input')).toBeInTheDocument()
})
it('does not render preschool date input when callback not provided', () => {
const onBirthDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
/>
)
expect(screen.queryByTestId('preschool-start-date-input')).not.toBeInTheDocument()
})
it('calls onPreschoolStartDateChange when preschool date changes', async () => {
const onBirthDateChange = vi.fn()
const onPreschoolStartDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
preschoolStartDate="2027-08-15"
onPreschoolStartDateChange={onPreschoolStartDateChange}
/>
)
const input = screen.getByTestId('preschool-start-date-input')
await userEvent.clear(input)
await userEvent.type(input, '2027-09-01')
expect(onPreschoolStartDateChange).toHaveBeenCalled()
})
it('has proper fieldset structure', () => {
const onBirthDateChange = vi.fn()
render(
<ChildBirthdayInput
birthDate="2024-06-15"
onBirthDateChange={onBirthDateChange}
/>
)
expect(screen.getByTestId('birthday-input-fieldset')).toBeInTheDocument()
})
})
@@ -0,0 +1,55 @@
import { ReactNode, ChangeEvent } from 'react'
import { formatDateForInput } from '@/utils/dates'
interface ChildBirthdayInputProps {
birthDate: string
onBirthDateChange: (date: string) => void
preschoolStartDate?: string | null
onPreschoolStartDateChange?: (date: string | null) => void
label?: string
}
export function ChildBirthdayInput({
birthDate,
onBirthDateChange,
preschoolStartDate,
onPreschoolStartDateChange,
label = 'Child Birthday',
}: ChildBirthdayInputProps): ReactNode {
const handleBirthDateChange = (e: ChangeEvent<HTMLInputElement>) => {
onBirthDateChange(e.target.value)
}
const handlePreschoolDateChange = (e: ChangeEvent<HTMLInputElement>) => {
onPreschoolStartDateChange?.(e.target.value || null)
}
return (
<fieldset data-testid="birthday-input-fieldset">
<legend data-testid="birthday-legend">{label}</legend>
<div data-testid="birth-date-group">
<label htmlFor="birth-date">Birth Date</label>
<input
id="birth-date"
type="date"
value={formatDateForInput(birthDate)}
onChange={handleBirthDateChange}
data-testid="birth-date-input"
required
/>
</div>
{onPreschoolStartDateChange && (
<div data-testid="preschool-date-group">
<label htmlFor="preschool-start-date">Preschool Start Date (optional)</label>
<input
id="preschool-start-date"
type="date"
value={preschoolStartDate ? formatDateForInput(preschoolStartDate) : ''}
onChange={handlePreschoolDateChange}
data-testid="preschool-start-date-input"
/>
</div>
)}
</fieldset>
)
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Footer } from './Footer'
describe('Footer', () => {
it('renders footer', () => {
render(<Footer />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('displays current year', () => {
render(<Footer />)
const currentYear = new Date().getFullYear()
expect(screen.getByTestId('footer-text')).toHaveTextContent(currentYear.toString())
})
it('displays copyright text', () => {
render(<Footer />)
expect(screen.getByTestId('footer-text')).toHaveTextContent('© ')
expect(screen.getByTestId('footer-text')).toHaveTextContent('Föräldraledigplannerare')
})
})
+10
View File
@@ -0,0 +1,10 @@
import { ReactNode } from 'react'
export function Footer(): ReactNode {
const currentYear = new Date().getFullYear()
return (
<footer role="contentinfo" data-testid="footer">
<p data-testid="footer-text">© {currentYear} Föräldraledigplannerare. All rights reserved.</p>
</footer>
)
}
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Header } from './Header'
describe('Header', () => {
it('renders header with default title', () => {
render(<Header />)
expect(screen.getByTestId('header-title')).toHaveTextContent('Föräldraledigplannerare')
})
it('renders header with custom title', () => {
render(<Header title="Custom Title" />)
expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Title')
})
it('renders logout button when onLogout provided', () => {
const onLogout = vi.fn()
render(<Header onLogout={onLogout} />)
const button = screen.getByTestId('logout-button')
expect(button).toBeInTheDocument()
})
it('calls onLogout when logout button clicked', async () => {
const onLogout = vi.fn()
render(<Header onLogout={onLogout} />)
const button = screen.getByTestId('logout-button')
await userEvent.click(button)
expect(onLogout).toHaveBeenCalled()
})
it('renders workspace selector button when onWorkspaceSelect provided', () => {
const onWorkspaceSelect = vi.fn()
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
expect(screen.getByTestId('workspace-selector')).toBeInTheDocument()
})
it('calls onWorkspaceSelect when workspace button clicked', async () => {
const onWorkspaceSelect = vi.fn()
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
const button = screen.getByTestId('workspace-selector')
await userEvent.click(button)
expect(onWorkspaceSelect).toHaveBeenCalled()
})
it('does not render buttons when no handlers provided', () => {
render(<Header />)
expect(screen.queryByTestId('logout-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('workspace-selector')).not.toBeInTheDocument()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { ReactNode } from 'react'
interface HeaderProps {
title?: string
onLogout?: () => void
onWorkspaceSelect?: () => void
}
export function Header({ title = 'Föräldraledigplannerare', onLogout, onWorkspaceSelect }: HeaderProps): ReactNode {
return (
<header role="banner" data-testid="header">
<div data-testid="header-title">{title}</div>
<nav role="navigation" data-testid="header-nav">
{onWorkspaceSelect && (
<button
data-testid="workspace-selector"
onClick={onWorkspaceSelect}
aria-label="Select workspace"
>
Select Workspace
</button>
)}
{onLogout && (
<button
data-testid="logout-button"
onClick={onLogout}
aria-label="Log out"
>
Logout
</button>
)}
</nav>
</header>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { ReactNode } from 'react'
import { Header, HeaderProps } from './Header'
import { Footer } from './Footer'
interface LayoutProps {
children: ReactNode
header?: HeaderProps
}
export function Layout({ children, header }: LayoutProps): ReactNode {
return (
<div role="main" data-testid="layout">
<Header {...(header || {})} />
<main data-testid="layout-main">{children}</main>
<Footer />
</div>
)
}
@@ -0,0 +1,132 @@
/**
* REFERENCE TEST — for the VacationDaysList component + useVacationDays hook.
*
* Pattern to copy for other component tests:
* - vi.mock('@/services/api') to isolate from the real HTTP layer.
* - Use @testing-library/react renderHook for hooks.
* - Use render + waitFor for async component tests.
* - Always cover: loading state, error state, empty state, and the happy path.
*/
import { render, renderHook, act, screen, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { VacationDaysList } from './VacationDaysList'
import { useVacationDays } from '@/hooks/useVacationDays'
import * as api from '@/services/api'
import type { VacationDays } from '@/types'
vi.mock('@/services/api')
const MOCK_WS = 'workspace-1'
const MOCK_VACATION_DAYS: VacationDays[] = [
{
id: 'vd-1',
personalDataUuid: 'pd-1',
workspaceId: MOCK_WS,
year: 2026,
totalDays: 25,
savedDaysIn: 5,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
{
id: 'vd-2',
personalDataUuid: 'pd-2',
workspaceId: MOCK_WS,
year: 2025,
totalDays: 25,
savedDaysIn: 0,
createdAt: '2025-01-01T00:00:00',
updatedAt: '2025-01-01T00:00:00',
},
]
beforeEach(() => {
vi.resetAllMocks()
})
// ---------------------------------------------------------------------------
// Component tests
// ---------------------------------------------------------------------------
describe('VacationDaysList', () => {
it('shows loading state while fetching', () => {
vi.mocked(api.getVacationDaysByWorkspace).mockReturnValue(new Promise(() => {}))
render(<VacationDaysList workspaceId={MOCK_WS} />)
expect(screen.getByRole('status')).toHaveTextContent('Laddar')
})
it('renders vacation days list on success', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue(MOCK_VACATION_DAYS)
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByTestId('vacation-days-list')).toBeInTheDocument()
})
expect(screen.getByTestId('vacation-days-item-vd-1')).toHaveTextContent('2026')
expect(screen.getByTestId('vacation-days-item-vd-1')).toHaveTextContent('+5 sparade')
expect(screen.getByTestId('vacation-days-item-vd-2')).toHaveTextContent('2025')
})
it('shows error message on API failure', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValue(new Error('Network error'))
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Network error')
})
})
it('shows empty state when no vacation days exist', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([])
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByText(/inga semesterdagar/i)).toBeInTheDocument()
})
})
})
// ---------------------------------------------------------------------------
// Hook tests
// ---------------------------------------------------------------------------
describe('useVacationDays', () => {
it('fetches vacation days on mount', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue(MOCK_VACATION_DAYS)
const { result } = renderHook(() => useVacationDays(MOCK_WS))
expect(result.current.isLoading).toBe(true)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.data).toEqual(MOCK_VACATION_DAYS)
expect(result.current.error).toBeNull()
})
it('sets error on fetch failure', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValue(new Error('Timeout'))
const { result } = renderHook(() => useVacationDays(MOCK_WS))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Timeout')
expect(result.current.data).toEqual([])
})
it('upsert calls api and refreshes list', async () => {
const updated: VacationDays = { ...MOCK_VACATION_DAYS[0]!, totalDays: 30 }
vi.mocked(api.getVacationDaysByWorkspace)
.mockResolvedValueOnce(MOCK_VACATION_DAYS)
.mockResolvedValueOnce([updated])
vi.mocked(api.upsertVacationDays).mockResolvedValue(updated)
const { result } = renderHook(() => useVacationDays(MOCK_WS))
await waitFor(() => expect(result.current.isLoading).toBe(false))
await act(async () => {
await result.current.upsert({
personalDataUuid: 'pd-1',
workspaceId: MOCK_WS,
year: 2026,
totalDays: 30,
})
})
expect(api.upsertVacationDays).toHaveBeenCalledOnce()
expect(result.current.data[0]?.totalDays).toBe(30)
})
})
@@ -0,0 +1,42 @@
/**
* REFERENCE COMPONENT — renders the VacationDays list.
*
* Shows the established pattern:
* - Accept workspaceId as a prop (not from context directly — easier to test).
* - Delegate data fetching to the hook.
* - Handle loading / error / empty states explicitly.
*
* Copy this structure for other list components.
*/
import { useVacationDays } from '@/hooks/useVacationDays'
import type { VacationDays } from '@/types'
interface Props {
workspaceId: string
}
export function VacationDaysList({ workspaceId }: Props) {
const { data, isLoading, error } = useVacationDays(workspaceId)
if (isLoading) return <p role="status">Laddar semesterdagar…</p>
if (error) return <p role="alert">{error}</p>
if (data.length === 0) return <p>Inga semesterdagar registrerade.</p>
return (
<ul data-testid="vacation-days-list">
{data.map((vd) => (
<VacationDaysItem key={vd.id} vacationDays={vd} />
))}
</ul>
)
}
function VacationDaysItem({ vacationDays: vd }: { vacationDays: VacationDays }) {
return (
<li data-testid={`vacation-days-item-${vd.id}`}>
<strong>{vd.year}</strong>: {vd.totalDays} dagar
{vd.savedDaysIn > 0 && <span> (+{vd.savedDaysIn} sparade)</span>}
</li>
)
}