Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,191 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, fireEvent, screen } from '@testing-library/react'
|
||||
import { VacationDaysTable } from './VacationDaysTable'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, fireEvent, screen, act } from '@testing-library/react'
|
||||
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
|
||||
|
||||
const ROWS = [
|
||||
const ROWS: VacationDaysTableRow[] = [
|
||||
{ year: 2026, totalDays: 10, savedDaysIn: 2 },
|
||||
{ year: 2027, totalDays: 25, savedDaysIn: 0 },
|
||||
{ year: 2028, totalDays: 25, savedDaysIn: 0 },
|
||||
]
|
||||
|
||||
function renderTable(
|
||||
rows: VacationDaysTableRow[],
|
||||
onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise<void>,
|
||||
) {
|
||||
return render(<VacationDaysTable rows={rows} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
}
|
||||
|
||||
describe('VacationDaysTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders one row per year', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} />)
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
expect(screen.getByTestId('vacation-year-row-2026')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vacation-year-row-2027')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vacation-year-row-2028')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the total days and saved days for each year', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} />)
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
const totalInput = screen.getByTestId('vacation-total-days-2026') as HTMLInputElement
|
||||
const savedInput = screen.getByTestId('vacation-saved-days-2026') as HTMLInputElement
|
||||
expect(totalInput.value).toBe('10')
|
||||
expect(savedInput.value).toBe('2')
|
||||
})
|
||||
|
||||
it('calls onSave with the edited year and total days when Spara is clicked', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('autosaves a total-days edit after the debounce delay, without a Save button', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const totalInput = screen.getByTestId('vacation-total-days-2027')
|
||||
fireEvent.change(totalInput, { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2027'))
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('vacation-save-2027')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 30, 0)
|
||||
})
|
||||
|
||||
it('calls onSave with edited saved-days-in value', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('autosaves a saved-days-in edit', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const savedInput = screen.getByTestId('vacation-saved-days-2026')
|
||||
fireEvent.change(savedInput, { target: { value: '5' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2026'))
|
||||
fireEvent.change(screen.getByTestId('vacation-saved-days-2026'), { target: { value: '5' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2026, 10, 5)
|
||||
})
|
||||
|
||||
it('does not mix up edits between rows', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('does not mix up edits between rows', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2026'), { target: { value: '15' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2027'))
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '20' } })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 25, 0)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2026, 15, 2)
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 20, 0)
|
||||
expect(onSave).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disables save buttons while saving', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} isSaving />)
|
||||
expect(screen.getByTestId('vacation-save-2026')).toBeDisabled()
|
||||
it('collapses rapid successive edits to the same row into a single save', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const input = screen.getByTestId('vacation-total-days-2027')
|
||||
fireEvent.change(input, { target: { value: '26' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
})
|
||||
fireEvent.change(input, { target: { value: '27' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 27, 0)
|
||||
})
|
||||
|
||||
it('shows a saving then saved indicator around the autosave', async () => {
|
||||
let resolveSave: () => void = () => {}
|
||||
const onSave = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
}),
|
||||
)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparar')
|
||||
|
||||
await act(async () => {
|
||||
resolveSave()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparat')
|
||||
})
|
||||
|
||||
it('shows an error indicator when the autosave fails, without losing the typed value', async () => {
|
||||
const onSave = vi.fn().mockRejectedValue(new Error('network blew up'))
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '99' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent(/gick inte|kunde inte|error/i)
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('99')
|
||||
})
|
||||
|
||||
it('does not clobber an unsaved edit when the parent re-renders with fresh rows mid-debounce', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
||||
|
||||
// Simulate a background refetch (e.g. from another row's save bumping
|
||||
// `data`) landing mid-debounce, still carrying the OLD server value for
|
||||
// this row.
|
||||
rerender(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
||||
})
|
||||
|
||||
it('accepts a fresh server value once the row is no longer dirty', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
||||
|
||||
// Once the save resolved the row is clean again — a later prop update
|
||||
// (e.g. from a real refetch confirming the save) should be reflected.
|
||||
const updatedRows = ROWS.map((r) => (r.year === 2027 ? { ...r, totalDays: 40 } : r))
|
||||
rerender(<VacationDaysTable rows={updatedRows} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
||||
})
|
||||
|
||||
it('shows an empty message when there are no rows', () => {
|
||||
render(<VacationDaysTable rows={[]} onSave={() => {}} />)
|
||||
expect(screen.queryByTestId('vacation-days-table')).not.toBeInTheDocument()
|
||||
render(<VacationDaysTable rows={[]} onSave={vi.fn().mockResolvedValue(undefined)} personalDataUuid="pd-1" />)
|
||||
expect(screen.queryByTestId('vacation-days-table-pd-1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Inga år att visa/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scopes its root testid to the given personalDataUuid, for multiple tables on one page', () => {
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
expect(screen.getByTestId('vacation-days-table-pd-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
|
||||
const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }]
|
||||
render(<VacationDaysTable rows={rows} onSave={() => {}} />)
|
||||
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user