- src/components/Board.jsx: prop-driven, owns single DnD context (PointerSensor 5px activation distance, KeyboardSensor, closestCorners). Wires all sortable lists + cards. Drops P3's store-coupled Board in favour of P4's prop-driven List pattern + P2's canonical store. - src/components/List.jsx: prop-driven, wraps cards in SortableContext via SortableCard helper for vertical DnD. - src/components/ListHeader.jsx: rewritten as prop-driven. - src/components/Card.jsx, CardDetailModal.jsx: P4 verbatim. - src/lib/boardDnd.js: P5 reducer + ID helpers. - src/store/boardStore.js: extended addBoard/addList/addCard to return new ids (computed from post-action state) so callers (Sidebar) can select the new entity immediately. - src/components/AppShell.jsx, Sidebar.jsx, TopBar.jsx, EmptyState.jsx: P6 verbatim (stateless, prop-driven). - src/App.jsx: new shell per spec — Sidebar + board area with FirstRunEmptyState / SelectBoardEmptyState / TopBar+Board. - src/test/testUtils.jsx: renderWithStore helper that pre-seeds localStorage so useBoardStore's initializer picks up test state. - src/index.css: fresh global resets. - Deleted: TodoForm/TodoList/TodoItem (legacy flat-todo), useLocalStorage (replaced by useBoardStore), old App.css. - Added: __fixtures__/DnDFixtureBoard.jsx (P5 DnD fixture for tests).
158 lines
5.4 KiB
JavaScript
158 lines
5.4 KiB
JavaScript
import { screen, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { render } from '@testing-library/react'
|
|
import CardDetailModal from './CardDetailModal.jsx'
|
|
|
|
function makeCard(overrides = {}) {
|
|
return {
|
|
id: 'c1',
|
|
boardId: 'b1',
|
|
listId: 'l1',
|
|
title: 'Buy milk',
|
|
description: 'From the corner store',
|
|
dueDate: '2099-12-31',
|
|
createdAt: 1,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const LISTS = [
|
|
{ id: 'l1', boardId: 'b1', name: 'Todo' },
|
|
{ id: 'l2', boardId: 'b1', name: 'Doing' },
|
|
{ id: 'l3', boardId: 'b1', name: 'Done' },
|
|
]
|
|
|
|
function setup(props = {}, cardOverrides = {}) {
|
|
const onSave = jest.fn()
|
|
const onDelete = jest.fn()
|
|
const onMove = jest.fn()
|
|
const onClose = jest.fn()
|
|
const card = makeCard(cardOverrides)
|
|
const utils = render(
|
|
<CardDetailModal
|
|
card={card}
|
|
lists={LISTS}
|
|
onSave={onSave}
|
|
onDelete={onDelete}
|
|
onMove={onMove}
|
|
onClose={onClose}
|
|
{...props}
|
|
/>
|
|
)
|
|
return { ...utils, onSave, onDelete, onMove, onClose, card }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// jsdom may have lingering confirm mocks between tests.
|
|
jest.restoreAllMocks()
|
|
})
|
|
|
|
describe('<CardDetailModal />', () => {
|
|
test('renders the card title, description, and due date in editable fields', () => {
|
|
setup()
|
|
expect(screen.getByDisplayValue('Buy milk')).toBeInTheDocument()
|
|
expect(screen.getByDisplayValue('From the corner store')).toBeInTheDocument()
|
|
expect(screen.getByDisplayValue('2099-12-31')).toBeInTheDocument()
|
|
})
|
|
|
|
test('focuses the title input on open', () => {
|
|
setup()
|
|
const titleInput = screen.getByDisplayValue('Buy milk')
|
|
expect(titleInput).toHaveFocus()
|
|
})
|
|
|
|
test('Save button commits edits via onSave with all updated fields', async () => {
|
|
const user = userEvent.setup()
|
|
const { onSave, onClose } = setup()
|
|
const titleInput = screen.getByDisplayValue('Buy milk')
|
|
await user.clear(titleInput)
|
|
await user.type(titleInput, 'Buy oat milk')
|
|
const descInput = screen.getByDisplayValue('From the corner store')
|
|
await user.clear(descInput)
|
|
await user.type(descInput, 'From the corner store, organic')
|
|
const dueInput = screen.getByDisplayValue('2099-12-31')
|
|
await user.clear(dueInput)
|
|
await user.type(dueInput, '2030-01-15')
|
|
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
|
expect(onSave).toHaveBeenCalledWith('c1', {
|
|
title: 'Buy oat milk',
|
|
description: 'From the corner store, organic',
|
|
dueDate: '2030-01-15',
|
|
})
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
test('Save with whitespace-only title is a no-op (no onSave call)', async () => {
|
|
const user = userEvent.setup()
|
|
const { onSave, onClose } = setup()
|
|
const titleInput = screen.getByDisplayValue('Buy milk')
|
|
await user.clear(titleInput)
|
|
await user.type(titleInput, ' ')
|
|
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
|
expect(onSave).not.toHaveBeenCalled()
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('Delete button confirms via window.confirm, then calls onDelete, then closes', async () => {
|
|
const user = userEvent.setup()
|
|
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
|
|
const { onDelete, onClose } = setup()
|
|
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
|
expect(confirmSpy).toHaveBeenCalled()
|
|
expect(onDelete).toHaveBeenCalledWith('c1')
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
test('Delete with confirm=false does NOT call onDelete', async () => {
|
|
const user = userEvent.setup()
|
|
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false)
|
|
const { onDelete, onClose } = setup()
|
|
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
|
expect(confirmSpy).toHaveBeenCalled()
|
|
expect(onDelete).not.toHaveBeenCalled()
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('Changing the "Move to list" select calls onMove with the new list id', async () => {
|
|
const user = userEvent.setup()
|
|
const { onMove } = setup()
|
|
const select = screen.getByLabelText(/move to list/i)
|
|
await user.selectOptions(select, 'l2')
|
|
expect(onMove).toHaveBeenCalledWith('c1', 'l2', expect.any(Number))
|
|
})
|
|
|
|
test('Escape closes the modal without saving', async () => {
|
|
const user = userEvent.setup()
|
|
const { onClose, onSave } = setup()
|
|
await user.keyboard('{Escape}')
|
|
expect(onClose).toHaveBeenCalled()
|
|
expect(onSave).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('Clicking the overlay closes the modal', async () => {
|
|
const user = userEvent.setup()
|
|
const { onClose } = setup()
|
|
// The overlay is the parent of the modal box
|
|
const overlay = screen.getByTestId('card-modal-overlay')
|
|
await user.click(overlay)
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
test('Clicking inside the modal box does NOT close', async () => {
|
|
const user = userEvent.setup()
|
|
const { onClose } = setup()
|
|
const titleInput = screen.getByDisplayValue('Buy milk')
|
|
await user.click(titleInput)
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('Clearing the due date field sends null to onSave', async () => {
|
|
const user = userEvent.setup()
|
|
const { onSave } = setup()
|
|
const dueInput = screen.getByDisplayValue('2099-12-31')
|
|
await user.clear(dueInput)
|
|
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
|
expect(onSave).toHaveBeenCalledWith('c1', expect.objectContaining({ dueDate: null }))
|
|
})
|
|
})
|