Author leave on the calendar itself
Planning used to mean filling in a wish list on one tab, pressing a button to see a suggestion, then pressing another to accept it — with nothing on screen telling you whether a plan was active. Dragging a range on the calendar now creates the period directly, clicking a band opens it for editing, and each change is recomputed and saved in one step. A period repeats never or yearly, matching what the planner can actually express. Clicking a day edits that day: its funding type, which pins it so the planner leaves it alone, and whether the day counts as worked or off. That switch used to be a mode covering the whole grid, which is what made the calendar confusing. The season presets move to a rail beside the grid. Picking one lets the planner choose the placement, then jumps there and flashes it, rather than asking the user to guess dates the engine already knows. A sync that funds nothing now says so, naming the period and how many days it fell short, instead of leaving the screen unchanged and looking broken.
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PeriodPopover } from './PeriodPopover'
|
||||
import { makeLeaveWish } from '../../../tests/fixtures'
|
||||
|
||||
describe('PeriodPopover', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnDelete = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
wish: null,
|
||||
startDate: '2028-06-15',
|
||||
endDate: '2028-06-20',
|
||||
parents: [{ id: 'p1', name: 'Parent 1' }],
|
||||
onSave: mockOnSave,
|
||||
onDelete: mockOnDelete,
|
||||
onClose: mockOnClose,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('ad-hoc band (no wish)', () => {
|
||||
it('shows the ad-hoc notice and date range instead of editable fields', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a save button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the ad-hoc delete label', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
|
||||
})
|
||||
|
||||
it('calls onDelete after confirmation', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not call onDelete when confirmation is cancelled', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('wish-linked band', () => {
|
||||
const wish = makeLeaveWish({
|
||||
id: 'lw-1',
|
||||
name: 'Sommar',
|
||||
windowStartMonth: 6,
|
||||
windowStartDay: 15,
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
firstYear: 2028,
|
||||
lastYear: null,
|
||||
recurrence: 'yearly',
|
||||
parentMode: 'either',
|
||||
})
|
||||
|
||||
it('prefills name/dates/parent/repeat from the wish', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar')
|
||||
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
|
||||
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
|
||||
expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
|
||||
expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => {
|
||||
const jul = makeLeaveWish({
|
||||
windowStartMonth: 12,
|
||||
windowStartDay: 20,
|
||||
windowEndMonth: 1,
|
||||
windowEndDay: 16,
|
||||
firstYear: 2028,
|
||||
})
|
||||
render(<PeriodPopover {...defaultProps} wish={jul} />)
|
||||
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20')
|
||||
expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16')
|
||||
})
|
||||
|
||||
it('shows "until age 4" checked when lastYear is null', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-until-age4')).toBeChecked()
|
||||
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an explicit last-year input when lastYear is set', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2030)
|
||||
})
|
||||
|
||||
it('unchecking "until age 4" reveals a last-year input seeded from the start date\'s year', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-until-age4')).toBeChecked()
|
||||
fireEvent.click(screen.getByTestId('period-until-age4'))
|
||||
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2028)
|
||||
})
|
||||
|
||||
it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.click(screen.getByTestId('period-until-age4'))
|
||||
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: null })))
|
||||
})
|
||||
|
||||
it('editing the last-year input updates the value sent on save', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
|
||||
})
|
||||
|
||||
it('toggles to "Never" repeat and hides the yearly options', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-repeat-never'))
|
||||
expect(screen.queryByTestId('period-yearly-options')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave with the edited name, dates and recurrence', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('period-name-input'), { target: { value: 'Sommarledighet' } })
|
||||
fireEvent.change(screen.getByTestId('period-end-input'), { target: { value: '2028-08-02' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
name: 'Sommarledighet',
|
||||
startDate: '2028-06-15',
|
||||
endDate: '2028-08-02',
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
lastYear: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends lastYear: null when switched to Never even if a last year was set', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.click(screen.getByTestId('period-repeat-never'))
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ recurrence: 'once', lastYear: null }))
|
||||
})
|
||||
})
|
||||
|
||||
it('switches parentMode to specific when a member is selected', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PeriodPopover
|
||||
{...defaultProps}
|
||||
wish={wish}
|
||||
parents={[
|
||||
{ id: 'p1', name: 'Alex' },
|
||||
{ id: 'p2', name: 'Sam' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('switches parentMode back to "both" after picking a specific member', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PeriodPopover
|
||||
{...defaultProps}
|
||||
wish={wish}
|
||||
parents={[
|
||||
{ id: 'p1', name: 'Alex' },
|
||||
{ id: 'p2', name: 'Sam' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'both' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'both', parentPersonalDataUuid: null }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockRejectedValue('boom')
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onDelete for a wish-linked band after confirmation', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows the wish-linked delete label', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
|
||||
})
|
||||
|
||||
it('displays a local error when onSave rejects', async () => {
|
||||
mockOnSave.mockRejectedValue(new Error('save exploded'))
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('save exploded')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic error when onSave rejects with a non-Error value', async () => {
|
||||
mockOnSave.mockRejectedValue('boom')
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('boom')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays the error prop directly', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} error="server said no" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('server said no')
|
||||
})
|
||||
|
||||
it('shows the loading state while saving', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} isSaving />)
|
||||
expect(screen.getByTestId('period-save-button')).toBeDisabled()
|
||||
expect(screen.getByTestId('period-save-button')).toHaveTextContent('Sparar…')
|
||||
})
|
||||
|
||||
it('resolves a preset-created title through its message key, not the stale stored name', () => {
|
||||
const presetWish = makeLeaveWish({ name: 'Stale Cached Name', nameMessageKey: 'wishEditor.seasonSommar' })
|
||||
render(<PeriodPopover {...defaultProps} wish={presetWish} />)
|
||||
expect(screen.getByTestId('period-popover-title')).toHaveTextContent('Sommar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('close behaviour', () => {
|
||||
it('calls onClose on the close button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByLabelText('Stäng'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on overlay click but not on content click', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-popover__content'))
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByTestId('period-popover__overlay'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on the cancel button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-cancel-button'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user