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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import type { LeaveWish } from '@/types'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
import './period-popover.css'
|
||||
|
||||
export interface PeriodPopoverSavePayload {
|
||||
name: string
|
||||
// ISO-8601 dates for the period's first (or only) occurrence. Month/day
|
||||
// become the wish's recurring anchor; the start date's year becomes
|
||||
// `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a
|
||||
// wrapping window (end sorts before start) is inferred from month/day
|
||||
// alone, not from the actual year gap between these two dates.
|
||||
startDate: string
|
||||
endDate: string
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
// Meaningful only when `recurrence === 'yearly'`. `null` = "until the
|
||||
// child turns 4" (no explicit end year).
|
||||
lastYear: number | null
|
||||
}
|
||||
|
||||
export interface PeriodPopoverProps {
|
||||
isOpen: boolean
|
||||
/** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band
|
||||
* (planned days with no `wishId`) — those can only be deleted, not edited,
|
||||
* since there is no wish to persist name/parent/repeat changes against. */
|
||||
wish: LeaveWish | null
|
||||
/** The clicked band's actual placed dates — always shown, and the only
|
||||
* source of truth for an ad-hoc band. */
|
||||
startDate: string
|
||||
endDate: string
|
||||
parents: Array<{ id: string; name: string }>
|
||||
isSaving?: boolean | undefined
|
||||
error?: string | null | undefined
|
||||
onSave: (payload: PeriodPopoverSavePayload) => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
lastYear: number | null
|
||||
}
|
||||
|
||||
/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
|
||||
* whether (end_month, end_day) sorts before (start_month, start_day). */
|
||||
function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean {
|
||||
return endMonth < startMonth || (endMonth === startMonth && endDay < startDay)
|
||||
}
|
||||
|
||||
function formatISO(year: number, month: number, day: number): string {
|
||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseISO(date: string): { year: number; month: number; day: number } {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
|
||||
}
|
||||
|
||||
function formStateFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): FormState {
|
||||
const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay)
|
||||
const endYear = wraps ? wish.firstYear + 1 : wish.firstYear
|
||||
return {
|
||||
name: resolveWishName(wish, t),
|
||||
startDate: formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay),
|
||||
endDate: formatISO(endYear, wish.windowEndMonth, wish.windowEndDay),
|
||||
parentMode: wish.parentMode,
|
||||
parentPersonalDataUuid: wish.parentPersonalDataUuid,
|
||||
recurrence: wish.recurrence,
|
||||
lastYear: wish.lastYear,
|
||||
}
|
||||
}
|
||||
|
||||
export function PeriodPopover({
|
||||
isOpen,
|
||||
wish,
|
||||
startDate,
|
||||
endDate,
|
||||
parents,
|
||||
isSaving = false,
|
||||
error = null,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: PeriodPopoverProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: '',
|
||||
startDate,
|
||||
endDate,
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
})
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (wish) {
|
||||
setForm(formStateFromWish(wish, t))
|
||||
} else {
|
||||
setForm({
|
||||
name: '',
|
||||
startDate,
|
||||
endDate,
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
})
|
||||
}
|
||||
setLocalError(null)
|
||||
}, [isOpen, wish, startDate, endDate, t])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!wish) return
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onSave({
|
||||
name: form.name,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate,
|
||||
parentMode: form.parentMode,
|
||||
parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
|
||||
recurrence: form.recurrence,
|
||||
lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
|
||||
})
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm(t('calendar.periodDeleteConfirm'))) {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onDelete()
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="period-popover__overlay"
|
||||
onClick={onClose}
|
||||
data-testid="period-popover__overlay"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="period-popover__content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="period-popover__content"
|
||||
>
|
||||
<div className="period-popover__header">
|
||||
<h2 data-testid="period-popover-title">{title}</h2>
|
||||
<button
|
||||
className="period-popover__close"
|
||||
onClick={onClose}
|
||||
aria-label={t('calendar.periodCloseAria')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__body">
|
||||
{(error || localError) && (
|
||||
<div className="period-popover__error" role="alert">
|
||||
{error || localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!wish && (
|
||||
<>
|
||||
<p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice">
|
||||
{t('calendar.periodAdHocNotice')}
|
||||
</p>
|
||||
<p className="period-popover__date-range">
|
||||
{startDate} – {endDate}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{wish && (
|
||||
<>
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-name" className="period-popover__label">
|
||||
{t('calendar.periodNameLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-name-input"
|
||||
aria-label={t('calendar.periodNameAria')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-start" className="period-popover__label">
|
||||
{t('calendar.periodStartLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-start"
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-start-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-end" className="period-popover__label">
|
||||
{t('calendar.periodEndLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-end"
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-end-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-parent" className="period-popover__label">
|
||||
{t('wishEditor.parentLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="period-parent"
|
||||
value={parentSelectValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === 'either' || value === 'both') {
|
||||
setForm((prev) => ({ ...prev, parentMode: value, parentPersonalDataUuid: null }))
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, parentMode: 'specific', parentPersonalDataUuid: value }))
|
||||
}
|
||||
}}
|
||||
className="period-popover__select"
|
||||
disabled={isSaving}
|
||||
data-testid="period-parent-select"
|
||||
>
|
||||
<option value="either">{t('wishEditor.eitherParent')}</option>
|
||||
<option value="both">{t('wishEditor.bothParents')}</option>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset className="period-popover__fieldset" data-testid="period-repeat-fieldset">
|
||||
<legend className="period-popover__label">{t('calendar.periodRepeatLabel')}</legend>
|
||||
<label className="period-popover__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="period-repeat"
|
||||
checked={form.recurrence === 'once'}
|
||||
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'once' }))}
|
||||
disabled={isSaving}
|
||||
data-testid="period-repeat-never"
|
||||
/>
|
||||
{' '}{t('calendar.periodRepeatNever')}
|
||||
</label>
|
||||
<label className="period-popover__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="period-repeat"
|
||||
checked={form.recurrence === 'yearly'}
|
||||
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'yearly' }))}
|
||||
disabled={isSaving}
|
||||
data-testid="period-repeat-yearly"
|
||||
/>
|
||||
{' '}{t('calendar.periodRepeatYearly')}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{form.recurrence === 'yearly' && (
|
||||
<div className="period-popover__field" data-testid="period-yearly-options">
|
||||
<label className="period-popover__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.lastYear === null}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
lastYear: e.target.checked ? null : parseISO(prev.startDate).year,
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
data-testid="period-until-age4"
|
||||
/>
|
||||
{' '}{t('wishEditor.untilAge4')}
|
||||
</label>
|
||||
|
||||
{form.lastYear !== null && (
|
||||
<label className="period-popover__field">
|
||||
{t('calendar.periodLastYearLabel')}
|
||||
<input
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2200}
|
||||
value={form.lastYear}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, lastYear: Number(e.target.value) }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
data-testid="period-last-year-input"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="period-popover__footer">
|
||||
<button
|
||||
className="period-popover__button period-popover__button--danger"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isSaving}
|
||||
data-testid="period-delete-button"
|
||||
>
|
||||
{isSaving
|
||||
? t('calendar.periodDeletingButton')
|
||||
: wish
|
||||
? t('calendar.periodDeleteButton')
|
||||
: t('calendar.periodDeleteAdHocButton')}
|
||||
</button>
|
||||
<button
|
||||
className="period-popover__button period-popover__button--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
data-testid="period-cancel-button"
|
||||
>
|
||||
{t('calendar.periodCancelButton')}
|
||||
</button>
|
||||
{wish && (
|
||||
<button
|
||||
className="period-popover__button period-popover__button--primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
data-testid="period-save-button"
|
||||
>
|
||||
{isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { PresetRail, SEASON_PRESETS } from './PresetRail'
|
||||
|
||||
describe('PresetRail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders one button per season preset', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the heading and hint', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelectPreset with the clicked preset', () => {
|
||||
const onSelectPreset = vi.fn()
|
||||
render(<PresetRail onSelectPreset={onSelectPreset} />)
|
||||
fireEvent.click(screen.getByTestId('preset-rail-season-sommar'))
|
||||
expect(onSelectPreset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'season-sommar', defaultWeeks: 6 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('every preset is flexible (window search, not a fixed placement)', () => {
|
||||
// Season presets seed a "när det passar bäst" wish — the allocator
|
||||
// resolves the placement. A fixed period is what dragging produces.
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(preset.startMonth).toBeGreaterThanOrEqual(1)
|
||||
expect(preset.endMonth).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
expect(SEASON_PRESETS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('disables every button when isBusy is true', () => {
|
||||
render(<PresetRail isBusy onSelectPreset={vi.fn()} />)
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not call onSelectPreset for a disabled button click', () => {
|
||||
const onSelectPreset = vi.fn()
|
||||
render(<PresetRail isBusy onSelectPreset={onSelectPreset} />)
|
||||
fireEvent.click(screen.getByTestId('preset-rail-season-var'))
|
||||
expect(onSelectPreset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the week count and the flexible badge for each preset', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
const summerButton = screen.getByTestId('preset-rail-season-sommar')
|
||||
expect(summerButton).toHaveTextContent('6')
|
||||
expect(summerButton).toHaveTextContent('när det passar bäst')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import './preset-rail.css'
|
||||
|
||||
export interface SeasonPreset {
|
||||
key: string
|
||||
labelKey: TranslationKey
|
||||
startMonth: number
|
||||
startDay: number
|
||||
endMonth: number
|
||||
endDay: number
|
||||
defaultWeeks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The four season quick-picks, copied from `SEASON_PRESETS` in
|
||||
* `WishEditor.tsx` (PLANNING_ENGINE_SPEC.md §10.7) — deliberately NOT
|
||||
* imported from there since that component is owned by another task.
|
||||
* Every one of these is a `flexible` wish: the dates below are the bounding
|
||||
* range the allocator searches within for the best-costing placement, not
|
||||
* the final dates. A fixed (exact-dates) period is what dragging on the
|
||||
* calendar produces instead — see Calendar.tsx's drag-select handler.
|
||||
*/
|
||||
export const SEASON_PRESETS: SeasonPreset[] = [
|
||||
{ key: 'season-var', labelKey: 'wishEditor.seasonVar', startMonth: 3, startDay: 1, endMonth: 5, endDay: 31, defaultWeeks: 1 },
|
||||
{ key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6 },
|
||||
{ key: 'season-host', labelKey: 'wishEditor.seasonHost', startMonth: 9, startDay: 1, endMonth: 10, endDay: 31, defaultWeeks: 1 },
|
||||
{ key: 'season-jul', labelKey: 'wishEditor.seasonJul', startMonth: 12, startDay: 1, endMonth: 1, endDay: 31, defaultWeeks: 4 },
|
||||
]
|
||||
|
||||
export interface PresetRailProps {
|
||||
/** Disables every button while a preset's create+sync is in flight. */
|
||||
isBusy?: boolean | undefined
|
||||
onSelectPreset: (preset: SeasonPreset) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* A side rail of one-click season presets. Clicking one creates a flexible
|
||||
* `LeaveWish` and syncs the plan — the allocator picks the best placement,
|
||||
* and the calendar scrolls to it and flashes the resulting band (see
|
||||
* Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
|
||||
* and reports which one was picked; it owns no request/scroll logic itself.
|
||||
*/
|
||||
export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<aside className="preset-rail" data-testid="preset-rail" aria-label={t('calendar.railHeading')}>
|
||||
<h3 className="preset-rail__heading">{t('calendar.railHeading')}</h3>
|
||||
<p className="preset-rail__hint">{t('calendar.railHint')}</p>
|
||||
<div className="preset-rail__buttons">
|
||||
{SEASON_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="preset-rail__button"
|
||||
onClick={() => void onSelectPreset(preset)}
|
||||
disabled={isBusy}
|
||||
data-testid={`preset-rail-${preset.key}`}
|
||||
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
|
||||
>
|
||||
<span className="preset-rail__button-name">{t(preset.labelKey)}</span>
|
||||
<span className="preset-rail__button-detail">
|
||||
{preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
|
||||
{' · '}
|
||||
{t('wishEditor.flexBadge')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
.period-popover__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.period-popover__content {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.period-popover__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.period-popover__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.period-popover__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.period-popover__close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.period-popover__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.period-popover__error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.period-popover__adhoc-notice {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.period-popover__date-range {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.period-popover__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__input,
|
||||
.period-popover__select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.period-popover__input:disabled,
|
||||
.period-popover__select:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-popover__fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.period-popover__radio-label,
|
||||
.period-popover__checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.period-popover__radio-label input,
|
||||
.period-popover__checkbox-label input {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-popover__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.period-popover__button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.period-popover__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-popover__button--primary {
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.period-popover__button--primary:hover:not(:disabled) {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.period-popover__button--secondary {
|
||||
background-color: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__button--secondary:hover:not(:disabled) {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.period-popover__button--danger {
|
||||
background-color: #d32f2f;
|
||||
color: #fff;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.period-popover__button--danger:hover:not(:disabled) {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.preset-rail {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.preset-rail__heading {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preset-rail__hint {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.preset-rail__buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-rail__button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-rail__button:hover:not(:disabled) {
|
||||
background-color: #f0f8ff;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.preset-rail__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preset-rail__button-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preset-rail__button-detail {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
@@ -456,6 +456,26 @@ describe('CalendarWeek', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 flashWishId', () => {
|
||||
it('adds the flash class to the band matching flashWishId', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-1" />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).toHaveClass('calendar-band--flash')
|
||||
})
|
||||
|
||||
it('does not flash a band for a different wishId', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-2" />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
|
||||
})
|
||||
|
||||
it('never flashes an ad-hoc (wishId null) band even if flashWishId is set oddly', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId={null} />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lanes: overlapping bands for two parents do not collide', () => {
|
||||
it('assigns overlapping bands to different lanes (both render, neither is dropped)', () => {
|
||||
const plannedDays = [
|
||||
|
||||
@@ -29,8 +29,12 @@ export interface CalendarWeekProps {
|
||||
onPointerEnter: (date: string) => void
|
||||
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
|
||||
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
|
||||
/** Optional for now — a later task wires this to a period editor. */
|
||||
/** Opens the period editor for the clicked band's underlying period. */
|
||||
onBandClick?: ((info: BandClickInfo) => void) | undefined
|
||||
/** §Task7 — the wishId of a just-created/just-edited period to briefly
|
||||
* highlight (see Calendar.tsx's preset rail: "scrolls to the resolved
|
||||
* placement and flashes the band"). `null`/omitted highlights nothing. */
|
||||
flashWishId?: string | null | undefined
|
||||
}
|
||||
|
||||
/** One day's slot inside a band segment: either a real planned_leave_day
|
||||
@@ -241,6 +245,7 @@ export function CalendarWeek({
|
||||
onPointerEnter,
|
||||
calendarDaysByDate,
|
||||
onBandClick,
|
||||
flashWishId,
|
||||
}: CalendarWeekProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const bandSegments = useMemo(
|
||||
@@ -323,6 +328,7 @@ export function CalendarWeek({
|
||||
'calendar-band',
|
||||
segment.isBandStart ? 'calendar-band--start' : '',
|
||||
segment.isBandEnd ? 'calendar-band--end' : '',
|
||||
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PlannedDayModal } from './PlannedDayModal'
|
||||
import { makePlannedLeaveDay } from '@/../tests/fixtures'
|
||||
import { makePlannedLeaveDay, makeCalendarDayEntry } from '@/../tests/fixtures'
|
||||
|
||||
describe('PlannedDayModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
@@ -827,4 +827,136 @@ describe('PlannedDayModal', () => {
|
||||
expect(screen.getByTestId('delete-button')).toHaveTextContent('Tar bort…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 pinned notice', () => {
|
||||
it('shows a pinned notice when the existing day is pinned', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a pinned notice for an unpinned day', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: false })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a pinned notice for a brand-new day', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 day status switch (arbetsdag/ledig)', () => {
|
||||
const onToggleOverride = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleOverride.mockReset()
|
||||
})
|
||||
|
||||
it('is hidden when dayInfo is not provided', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} onToggleOverride={onToggleOverride} />)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden when onToggleOverride is not provided', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} dayInfo={makeCalendarDayEntry()} />)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden in range mode even with dayInfo and onToggleOverride provided', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isRangeMode={true}
|
||||
dayCount={3}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects "Ledig" when the day is currently free', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: true })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-off')).toBeChecked()
|
||||
expect(screen.getByTestId('day-status-workday')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('selects "Arbetsdag" when the day is currently a working day', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-workday')).toBeChecked()
|
||||
expect(screen.getByTestId('day-status-off')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('calls onToggleOverride when picking the option that differs from the current status', async () => {
|
||||
onToggleOverride.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByTestId('day-status-off'))
|
||||
expect(onToggleOverride).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onToggleOverride when clicking the already-selected option', async () => {
|
||||
onToggleOverride.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByTestId('day-status-workday'))
|
||||
expect(onToggleOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the override error when provided', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
overrideError="Det gick inte att uppdatera undantaget"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-error')).toHaveTextContent('Det gick inte att uppdatera undantaget')
|
||||
})
|
||||
|
||||
it('disables the switch while saving', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isSaving={true}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-workday')).toBeDisabled()
|
||||
expect(screen.getByTestId('day-status-off')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import type { CalendarDayEntry, PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import './planned-day-modal.css'
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface PlannedDayModalProps {
|
||||
isRangeMode?: boolean | undefined
|
||||
dayCount?: number | undefined
|
||||
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
|
||||
// §Task7 day popover — this date's calendar metadata (röda dagar/weekend/
|
||||
// overrides), driving the "arbetsdag/ledig" switch below. `undefined`
|
||||
// while the calendar window hasn't loaded metadata for this date yet;
|
||||
// `null`/absent `onToggleOverride` hides the switch entirely (e.g. in
|
||||
// range mode, where there is no single day to toggle).
|
||||
dayInfo?: CalendarDayEntry | null | undefined
|
||||
onToggleOverride?: (() => Promise<void>) | undefined
|
||||
overrideError?: string | null | undefined
|
||||
}
|
||||
|
||||
export function PlannedDayModal({
|
||||
@@ -37,12 +45,16 @@ export function PlannedDayModal({
|
||||
isRangeMode = false,
|
||||
dayCount = 1,
|
||||
defaultDayType,
|
||||
dayInfo,
|
||||
onToggleOverride,
|
||||
overrideError = null,
|
||||
}: PlannedDayModalProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
|
||||
const [parentId, setParentId] = useState(selectedParentId)
|
||||
const [isDubbeldag, setIsDubbeldag] = useState(false)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const [isTogglingOverride, setIsTogglingOverride] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (existingDay) {
|
||||
@@ -88,6 +100,22 @@ export function PlannedDayModal({
|
||||
}
|
||||
}
|
||||
|
||||
// §Task7 — the "Den här dagen är: arbetsdag / ledig" switch. There are
|
||||
// only two effective states, so flipping to whichever option ISN'T
|
||||
// currently selected is exactly the toggle `onToggleOverride` performs —
|
||||
// no separate "set to X" call is needed. A no-op click on the
|
||||
// already-selected option is ignored.
|
||||
const handleSelectDayStatus = async (wantFree: boolean) => {
|
||||
if (!onToggleOverride || !dayInfo) return
|
||||
if (dayInfo.isFree === wantFree) return
|
||||
try {
|
||||
setIsTogglingOverride(true)
|
||||
await onToggleOverride()
|
||||
} finally {
|
||||
setIsTogglingOverride(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
|
||||
@@ -179,6 +207,52 @@ export function PlannedDayModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
📌 {t('plannedDayModal.pinnedNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isRangeMode && dayInfo && onToggleOverride && (
|
||||
<div className="planned-day-modal__field">
|
||||
<span className="planned-day-modal__label">{t('plannedDayModal.dayStatusLabel')}</span>
|
||||
<div
|
||||
className="planned-day-modal__override-switch"
|
||||
role="radiogroup"
|
||||
aria-label={t('plannedDayModal.dayStatusLabel')}
|
||||
data-testid="day-status-switch"
|
||||
>
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="day-status"
|
||||
checked={!dayInfo.isFree}
|
||||
onChange={() => void handleSelectDayStatus(false)}
|
||||
disabled={isSaving || isTogglingOverride}
|
||||
data-testid="day-status-workday"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.workdayOption')}
|
||||
</label>
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="day-status"
|
||||
checked={dayInfo.isFree}
|
||||
onChange={() => void handleSelectDayStatus(true)}
|
||||
disabled={isSaving || isTogglingOverride}
|
||||
data-testid="day-status-off"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.dayOffOption')}
|
||||
</label>
|
||||
</div>
|
||||
{overrideError && (
|
||||
<p className="planned-day-modal__error" role="alert" data-testid="day-status-error">
|
||||
{overrideError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="planned-day-modal__footer">
|
||||
|
||||
@@ -121,6 +121,22 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.planned-day-modal__pinned-notice {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: -8px 0 16px 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user