Files
TREK/client/src/components/Todo/TodoRow.test.tsx
T
Maurice 1f79776070 feat(todo): To-Do in the look of the packing list, detail pane always in reach
Rows get the packing list's filled checkbox, priority, due date and list
as quiet badges, and the assignee as an avatar with the name as its
tooltip. The tasks sit in a card whose head names the filter and holds
the sort, Priority or Due date, which moved out of the sidebar; a second
click on the active one goes back to the manual order. The progress card
matches the packing list's.

The detail pane is a card with a box in its head that ticks the task
off, and Delete and Save changes stay in sight: the To-Do view now fills
the space under the Lists bar instead of a fixed height that pushed the
pane's foot off screen. The detail pane and the new-task form share one
set of fields, and a new list is named in a dialog, from the sidebar
and from the form; the dialog moved to shared/NameDialog for both lists.

The To-Do help pictures are re-rendered, the texts that described the
old sidebar and form follow in all 27 languages, and the wiki page is
updated. The phone screens are unchanged.
2026-09-25 13:29:06 +02:00

233 lines
8.2 KiB
TypeScript

// FE-W4TDR-001 to FE-W4TDR-022
import type { ComponentProps } from 'react'
import { describe, it, expect, vi } from 'vitest'
import type { TodoItem } from '../../types'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import TodoRow from './TodoRow'
import type { Member } from './todoListModel'
const MEMBERS: Member[] = [
{ id: 1, username: 'ada', avatar: 'ada.png' },
{ id: 2, username: 'bob', avatar: null, is_guest: true },
]
function todo(overrides: Partial<TodoItem> = {}): TodoItem {
return {
id: 10, trip_id: 1, name: 'Book ferry', checked: 0, priority: 0,
description: null, due_date: null, category: null, assigned_user_id: null,
...overrides,
} as unknown as TodoItem
}
function setup(item: TodoItem, overrides: Partial<ComponentProps<typeof TodoRow>> = {}) {
const onSelect = vi.fn()
const onToggle = vi.fn()
const utils = render(
<TodoRow
item={item}
members={MEMBERS}
categories={['Docs', 'Gear']}
today="2026-06-15"
isSelected={false}
canEdit
formatDate={(d: string) => `on ${d}`}
onSelect={onSelect}
onToggle={onToggle}
{...overrides}
/>,
)
return { onSelect, onToggle, ...utils }
}
const dragHandlers = () => ({
isDragging: false,
isOver: false,
onStart: vi.fn(),
onOver: vi.fn(),
onEnd: vi.fn(),
onDrop: vi.fn(),
})
describe('TodoRow', () => {
it('FE-W4TDR-001: renders the task name without any badges', () => {
const { container } = setup(todo())
expect(screen.getByText('Book ferry')).toBeInTheDocument()
expect(container.querySelectorAll('span')).toHaveLength(0)
})
it('FE-W4TDR-002: selecting an unselected row reports its id', () => {
const { onSelect, container } = setup(todo())
fireEvent.click(container.firstElementChild!)
expect(onSelect).toHaveBeenCalledWith(10)
})
it('FE-W4TDR-003: clicking the selected row deselects it', () => {
const { onSelect, container } = setup(todo(), { isSelected: true })
fireEvent.click(container.firstElementChild!)
expect(onSelect).toHaveBeenCalledWith(null)
})
it('FE-W4TDR-004: the checkbox toggles the task without selecting the row', () => {
const { onToggle, onSelect, container } = setup(todo())
fireEvent.click(container.querySelector('button')!)
expect(onToggle).toHaveBeenCalledWith(10, true)
expect(onSelect).not.toHaveBeenCalled()
})
it('FE-W4TDR-005: a done task strikes through and unchecks on click', () => {
const { onToggle, container } = setup(todo({ checked: 1 } as Partial<TodoItem>))
expect(screen.getByText('Book ferry')).toHaveStyle({ textDecoration: 'line-through' })
fireEvent.click(container.querySelector('button')!)
expect(onToggle).toHaveBeenCalledWith(10, false)
})
it('FE-W4TDR-006: a read-only row swallows the toggle', () => {
const { onToggle, container } = setup(todo(), { canEdit: false })
fireEvent.click(container.querySelector('button')!)
expect(onToggle).not.toHaveBeenCalled()
})
it('FE-W4TDR-007: renders the description preview', () => {
setup(todo({ description: 'Ferry to Vestmannaeyjar' } as Partial<TodoItem>))
expect(screen.getByText('Ferry to Vestmannaeyjar')).toBeInTheDocument()
})
it('FE-W4TDR-008: renders the priority badge', () => {
setup(todo({ priority: 2 } as Partial<TodoItem>))
expect(screen.getByText('P2')).toBeInTheDocument()
})
it('FE-W4TDR-009: ignores a priority outside the configured range', () => {
setup(todo({ priority: 9 } as Partial<TodoItem>))
expect(screen.queryByText(/^P\d$/)).toBeNull()
})
it('FE-W4TDR-010: formats the due date through the caller formatter', () => {
setup(todo({ due_date: '2026-06-20' } as Partial<TodoItem>))
expect(screen.getByText('on 2026-06-20')).toBeInTheDocument()
})
it('FE-W4TDR-011: highlights an overdue date in red', () => {
setup(todo({ due_date: '2026-06-01' } as Partial<TodoItem>))
expect(screen.getByText('on 2026-06-01')).toHaveStyle({ color: 'rgb(239, 68, 68)' })
})
it('FE-W4TDR-012: does not treat a past date on a done task as overdue', () => {
setup(todo({ due_date: '2026-06-01', checked: 1 } as Partial<TodoItem>))
expect(screen.getByText('on 2026-06-01')).not.toHaveStyle({ color: 'rgb(239, 68, 68)' })
})
it('FE-W4TDR-013: renders the category chip with its palette dot', () => {
const { container } = setup(todo({ category: 'Gear' } as Partial<TodoItem>))
expect(screen.getByText('Gear')).toBeInTheDocument()
expect(container.innerHTML).toContain('rgb(168, 85, 247)')
})
it('FE-W4TDR-014: renders the assignee avatar for a member with a picture', () => {
const { container } = setup(todo({ assigned_user_id: 1 } as Partial<TodoItem>))
// The assignee is an avatar; the name is its tooltip and label.
expect(screen.getByTitle('ada')).toHaveAttribute('aria-label', 'ada')
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
})
it('FE-W4TDR-015: falls back to an initial and flags a guest assignee', () => {
const { container } = setup(todo({ assigned_user_id: 2 } as Partial<TodoItem>))
expect(screen.getByText('B')).toBeInTheDocument()
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('.lucide-user-round')).not.toBeNull()
})
it('FE-W4TDR-016: the drag handle starts and ends a reorder', () => {
const drag = dragHandlers()
const { container } = setup(todo(), { drag })
const handle = container.querySelector('[draggable="true"]') as HTMLElement
fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '' } })
expect(drag.onStart).toHaveBeenCalledWith(10)
fireEvent.dragEnd(handle)
expect(drag.onEnd).toHaveBeenCalled()
})
it('FE-W4TDR-017: dragging over the row reports it and leaving clears the target', () => {
const drag = dragHandlers()
const { container } = setup(todo(), { drag })
const row = container.firstElementChild as HTMLElement
fireEvent.dragOver(row, { dataTransfer: { dropEffect: '' } })
expect(drag.onOver).toHaveBeenCalledWith(10)
fireEvent.dragLeave(row, { relatedTarget: document.body })
expect(drag.onOver).toHaveBeenLastCalledWith(-1)
})
it('FE-W4TDR-018: dropping on the row reports the target id', () => {
const drag = dragHandlers()
const { container } = setup(todo(), { drag })
fireEvent.drop(container.firstElementChild!)
expect(drag.onDrop).toHaveBeenCalledWith(10)
})
it('FE-W4TDR-019: hides the drag handle when the user cannot edit', () => {
const { container } = setup(todo(), { drag: dragHandlers(), canEdit: false })
expect(container.querySelector('[draggable="true"]')).toBeNull()
})
it('FE-W4TDR-020: dims the dragged row and marks the drop target', () => {
const { container, rerender } = setup(todo(), { drag: { ...dragHandlers(), isDragging: true } })
expect((container.firstElementChild as HTMLElement).style.opacity).toBe('0.4')
rerender(
<TodoRow
item={todo()} members={MEMBERS} categories={[]} today="2026-06-15" isSelected={false} canEdit
formatDate={(d: string) => d} onSelect={() => {}} onToggle={() => {}}
drag={{ ...dragHandlers(), isOver: true }}
/>,
)
expect((container.firstElementChild as HTMLElement).style.boxShadow).toBe('inset 3px 0 0 0 var(--accent)')
})
it('FE-W4TDR-021: the row opts out of the global press-scale (#2158)', () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active
// scale on the row-wide role="button" shifted the checkbox out from under
// the pointer, so the click retargeted onto the row and opened the detail
// pane instead of toggling. The data-no-press attribute is the pin.
const { container } = setup(todo())
expect(container.firstElementChild).toHaveAttribute('data-no-press')
})
it('FE-W4TDR-022: the checkbox toggles without selecting or starting a drag', () => {
const drag = dragHandlers()
const { onToggle, onSelect, container } = setup(todo(), { drag })
fireEvent.click(container.querySelector('button')!)
expect(onToggle).toHaveBeenCalledWith(10, true)
expect(onSelect).not.toHaveBeenCalled()
expect(drag.onStart).not.toHaveBeenCalled()
})
})