feat(redesign): Trello-style kanban UI (P7 integration)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 29s

This commit is contained in:
Hermes (Agent)
2026-06-24 06:27:31 +00:00
52 changed files with 11274 additions and 455 deletions

9
babel.config.js Normal file
View File

@@ -0,0 +1,9 @@
// Babel config is consumed ONLY by Jest via babel-jest.
// Vite uses esbuild and ignores babel.config.js for builds,
// so this does not interfere with the dev server.
export default {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};

20
jest.config.js Normal file
View File

@@ -0,0 +1,20 @@
export default {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(js|jsx)$': ['babel-jest', { configFile: './babel.config.js' }],
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testMatch: ['<rootDir>/src/**/*.test.{js,jsx}'],
moduleFileExtensions: ['js', 'jsx', 'json'],
testEnvironmentOptions: {
// jsdom 22+ doesn't ship crypto.randomUUID by default in some envs
customExportConditions: [''],
},
// Stub CSS imports as identity objects so jest doesn't try to parse them.
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
transformIgnorePatterns: [
'node_modules/(?!(@dnd-kit)/)',
],
};

31
jest.setup.js Normal file
View File

@@ -0,0 +1,31 @@
import '@testing-library/jest-dom';
import { __resetStore } from './src/store/boardStore'
// jsdom doesn't implement window.matchMedia. AppShell uses it to detect
// mobile breakpoints. Stub a no-op implementation that always reports
// desktop (matches=false) unless a test explicitly overrides it.
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {}, // deprecated
removeListener: () => {}, // deprecated
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
}
// The board store is a module-level singleton. Reset it before every test
// so tests don't leak state into each other. localStorage is also cleared
// because the store seeds from localStorage on first read.
beforeEach(() => {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.clear()
}
__resetStore()
})

6245
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,28 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.5.2",
"babel-jest": "^29.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"vite": "^6.3.5"
}
}

View File

@@ -1,49 +0,0 @@
.app {
max-width: 500px;
margin: 0 auto;
padding: 2rem 1rem;
}
.app h1 {
font-size: 2rem;
text-align: center;
color: #1a1a2e;
margin-bottom: 0.25rem;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.filters {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.3rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn.active {
background: #4a6cf7;
color: white;
border-color: #4a6cf7;
}
.count {
text-align: center;
color: #aaa;
font-size: 0.85rem;
margin-top: 1rem;
}

View File

@@ -1,67 +1,48 @@
import { useState } from 'react'
import { useLocalStorage } from './hooks/useLocalStorage'
import TodoForm from './components/TodoForm'
import TodoList from './components/TodoList'
import './App.css'
import { useBoardStore } from './store/boardStore'
import AppShell from './components/AppShell'
import Board from './components/Board'
import EmptyState from './components/EmptyState'
/**
* App — top-level shell.
*
* Owns the singleton board store (via useBoardStore) and feeds its state +
* actions into the prop-driven <AppShell> + <Board> tree. All persistence
* (localStorage write) is handled inside the store hook.
*
* Render branches:
* - active board present → TopBar + Board (Trello kanban surface)
* - no active board but boardOrder is non-empty → "Select a board" empty state
* - no boards at all → "First-run" empty state (theoretically unreachable
* after P2's migrate() seeds an Inbox board, but kept as a defensive
* fallback in case the store is reset to empty via __resetStore()).
*/
function App() {
const [todos, setTodos] = useLocalStorage('ultra-todo-items', [])
const [filter, setFilter] = useState('all')
const { state, actions } = useBoardStore()
const activeBoardId = state.activeBoardId
const activeBoard = activeBoardId ? state.boards[activeBoardId] : null
const addTodo = (text, dueDate) => {
setTodos((prev) => [
...prev,
{ id: crypto.randomUUID(), text, completed: false, dueDate },
])
}
const toggleTodo = (id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
}
const deleteTodo = (id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id))
}
const isFirstRun = state.boardOrder.length === 0
const emptyState = isFirstRun ? (
<EmptyState variant="first-run" />
) : (
<EmptyState variant="no-board" />
)
return (
<div className="app">
<h1> Ultra Todo</h1>
<p className="subtitle">Your tasks, supercharged.</p>
<TodoForm onAdd={addTodo} />
<div className="filters">
<button
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={`filter-btn ${filter === 'today' ? 'active' : ''}`}
onClick={() => setFilter('today')}
>
Today
</button>
</div>
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
filter={filter}
/>
{todos.length > 0 && (
<p className="count">
{todos.filter((t) => t.completed).length}/{todos.length} done
</p>
)}
</div>
<AppShell
boards={state.boards}
boardOrder={state.boardOrder}
activeBoardId={activeBoardId}
onSelectBoard={(id) => actions.setActiveBoard(id)}
onAddBoard={(name) => actions.addBoard(name)}
onRenameBoard={(id, name) => actions.renameBoard(id, name)}
onDeleteBoard={(id) => actions.deleteBoard(id)}
emptyState={emptyState}
>
{activeBoard && <Board state={state} actions={actions} boardId={activeBoard.id} />}
</AppShell>
)
}
export default App
export default App

150
src/App.test.jsx Normal file
View File

@@ -0,0 +1,150 @@
/**
* App — top-level smoke test.
*
* Verifies that the integrated App shell renders the expected major regions
* (sidebar, top bar, board area, lists) and that the basic create-board flow
* works end-to-end through the real store.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
import { renderWithStore, makeEmptyState } from './test/testUtils.jsx'
const SEEDED_BOARD = 'existing'
function seedWithOneBoard() {
return {
schemaVersion: 2,
activeBoardId: SEEDED_BOARD,
boardOrder: [SEEDED_BOARD],
boards: { [SEEDED_BOARD]: { id: SEEDED_BOARD, name: 'Existing', listIds: [] } },
lists: {},
cards: {},
}
}
function seedWithBoardAndList() {
const boardId = 'b1'
const listId = 'l1'
return {
schemaVersion: 2,
activeBoardId: boardId,
boardOrder: [boardId],
boards: { [boardId]: { id: boardId, name: 'Project', listIds: [listId] } },
lists: { [listId]: { id: listId, boardId, name: 'Todo', cardIds: [] } },
cards: {},
}
}
describe('<App />', () => {
test('renders sidebar + topbar + board + lists when seeded', () => {
renderWithStore(<App />, { initial: seedWithBoardAndList() })
// Sidebar + topbar
expect(screen.getByRole('complementary', { name: /boards sidebar/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Project' })).toBeInTheDocument()
// Board + list
expect(screen.getByTestId('board')).toBeInTheDocument()
expect(screen.getByTestId('list')).toBeInTheDocument()
expect(screen.getByText('Todo')).toBeInTheDocument()
})
test('"+ Create board" flow creates a new board and activates it', async () => {
const user = userEvent.setup()
const result = renderWithStore(<App />, { initial: seedWithOneBoard() })
// Click the create-board button in the sidebar
const createBtn = screen.getByRole('button', { name: /create board/i })
await user.click(createBtn)
// Type a name and submit
const input = screen.getByRole('textbox', { name: /board name/i })
await user.type(input, 'My new board{enter}')
// A new board should now exist in the store and be active.
const boardIds = Object.keys(result.state.boards)
expect(boardIds.length).toBe(2)
const newBoardId = boardIds.find((id) => id !== SEEDED_BOARD)
expect(result.state.boards[newBoardId].name).toBe('My new board')
expect(result.state.activeBoardId).toBe(newBoardId)
// TopBar reflects the new active board
expect(screen.getByRole('heading', { name: 'My new board' })).toBeInTheDocument()
})
test('first-run branch: empty initial state shows first-run empty state + sidebar CTA', () => {
renderWithStore(<App />, { initial: makeEmptyState() })
// No active board → empty state visible in main area
expect(screen.getByText(/welcome to ultra todo/i)).toBeInTheDocument()
// Sidebar first-run CTA visible (since boardOrder.length === 0)
expect(screen.getByRole('button', { name: /create your first board/i })).toBeInTheDocument()
})
test('first-run flow: "+ Create your first board" creates the first board', async () => {
const user = userEvent.setup()
const result = renderWithStore(<App />, { initial: makeEmptyState() })
await user.click(screen.getByRole('button', { name: /create your first board/i }))
const input = screen.getByRole('textbox', { name: /board name/i })
await user.type(input, 'First board{enter}')
expect(Object.keys(result.state.boards).length).toBe(1)
const boardId = Object.keys(result.state.boards)[0]
expect(result.state.boards[boardId].name).toBe('First board')
expect(result.state.activeBoardId).toBe(boardId)
})
test('select-board empty state appears when boards exist but none is active', () => {
const initial = {
schemaVersion: 2,
activeBoardId: null,
boardOrder: [SEEDED_BOARD],
boards: { [SEEDED_BOARD]: { id: SEEDED_BOARD, name: 'Existing', listIds: [] } },
lists: {},
cards: {},
}
renderWithStore(<App />, { initial })
// Sidebar shows the existing board
expect(screen.getByRole('button', { name: 'Existing' })).toBeInTheDocument()
// No TopBar (no active board)
expect(screen.queryByRole('heading', { name: 'Existing' })).not.toBeInTheDocument()
// "Select a board" prompt
expect(screen.getByText(/select a board from the sidebar/i)).toBeInTheDocument()
})
test('mobile breakpoint hides the sidebar by default and shows a hamburger', () => {
// Override matchMedia to report mobile (<=768px) before render.
window.matchMedia = (query) => ({
matches: query === '(max-width: 768px)',
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})
const initial = {
schemaVersion: 2,
activeBoardId: 'b1',
boardOrder: ['b1'],
boards: { b1: { id: 'b1', name: 'Mobile Board', listIds: [] } },
lists: {},
cards: {},
}
renderWithStore(<App />, { initial })
// Sidebar is rendered but not visible (the `--open` modifier isn't applied).
const sidebar = screen.getByRole('complementary', { name: /boards sidebar/i })
expect(sidebar).not.toHaveClass('sidebar--open')
// Hamburger button(s) should be present (both in Sidebar and TopBar).
const hamburgers = screen.getAllByRole('button', { name: /toggle sidebar/i })
expect(hamburgers.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -0,0 +1,22 @@
.app-shell {
display: flex;
width: 100%;
min-height: 100vh;
background: linear-gradient(135deg, #0079bf 0%, #5067c5 100%);
}
.app-shell__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #0079bf 0%, #5067c5 100%);
}
.app-shell__content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react';
import Sidebar from './Sidebar';
import TopBar from './TopBar';
import './AppShell.css';
// mobile breakpoint — matches CSS media query in Sidebar.css / TopBar.css
const MOBILE_QUERY = '(max-width: 768px)';
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(MOBILE_QUERY).matches;
});
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}
/**
* AppShell — Trello-style app chrome with sidebar + top bar + content slot.
*
* Stateless: receives the entire board state + mutation callbacks as props
* so the parent (App.jsx, which will eventually call into the real
* useBoardStore from P2) owns persistence. This keeps the shell
* unit-testable without a store and avoids colliding with P2's store file.
*
* Children render in the main content area (where the active board's
* lists/cards go).
*/
export default function AppShell({
boards,
boardOrder,
activeBoardId,
onSelectBoard,
onAddBoard,
onRenameBoard,
onDeleteBoard,
emptyState, // optional node to render when no board is active
children,
}) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const isMobile = useIsMobile();
const handleMobileToggle = useCallback(() => {
setMobileSidebarOpen((v) => !v);
}, []);
const handleMobileClose = useCallback(() => {
setMobileSidebarOpen(false);
}, []);
const activeBoard = activeBoardId ? boards[activeBoardId] : null;
const boardName = activeBoard?.name ?? null;
// When boards change such that the active one was deleted, surface a hint
// — the parent (App.jsx) is responsible for falling back to the first
// remaining board via onSelectBoard.
return (
<div className="app-shell">
<Sidebar
boards={boards}
boardOrder={boardOrder}
activeBoardId={activeBoardId}
isMobileOpen={mobileSidebarOpen}
showHamburger={isMobile}
onSelectBoard={onSelectBoard}
onAddBoard={onAddBoard}
onRenameBoard={onRenameBoard}
onDeleteBoard={onDeleteBoard}
onMobileToggle={handleMobileToggle}
onMobileClose={handleMobileClose}
/>
<main className="app-shell__main">
<TopBar
boardName={boardName}
showHamburger={isMobile}
onToggleSidebar={handleMobileToggle}
/>
<div className="app-shell__content">
{activeBoardId ? children : emptyState ?? null}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import AppShell from './AppShell';
function makeProps(overrides = {}) {
return {
boards: { b1: { id: 'b1', name: 'Personal' } },
boardOrder: ['b1'],
activeBoardId: 'b1',
onSelectBoard: jest.fn(),
onAddBoard: jest.fn(() => 'new'),
onRenameBoard: jest.fn(),
onDeleteBoard: jest.fn(),
emptyState: <p>Empty</p>,
children: <p>Board content</p>,
...overrides,
};
}
describe('<AppShell />', () => {
test('renders Sidebar + TopBar + children', () => {
render(<AppShell {...makeProps()} />);
expect(screen.getByRole('button', { name: 'Personal' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Personal' })).toBeInTheDocument();
expect(screen.getByText('Board content')).toBeInTheDocument();
});
test('renders emptyState instead of children when no activeBoardId', () => {
render(<AppShell {...makeProps({ activeBoardId: null })} />);
expect(screen.getByText('Empty')).toBeInTheDocument();
expect(screen.queryByText('Board content')).not.toBeInTheDocument();
});
});

121
src/components/Board.css Normal file
View File

@@ -0,0 +1,121 @@
.board {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: calc(100vh - 60px);
background: linear-gradient(135deg, #0079bf 0%, #5067c5 100%);
color: #ffffff;
}
.board__lists {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
overflow-x: auto;
overflow-y: hidden;
flex: 1 1 auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.board__lists::-webkit-scrollbar {
height: 10px;
}
.board__lists::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 5px;
}
.board__lists::-webkit-scrollbar-track {
background: transparent;
}
.board__add-list {
background: rgba(255, 255, 255, 0.18);
border: none;
color: #ffffff;
border-radius: 8px;
padding: 0.5rem 0.75rem;
width: 272px;
flex: 0 0 272px;
min-width: 272px;
max-width: 300px;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.15s;
}
.board__add-list:hover {
background: rgba(255, 255, 255, 0.28);
}
.board__add-list-form {
background: #ebecf0;
border-radius: 8px;
padding: 0.5rem;
width: 272px;
flex: 0 0 272px;
min-width: 272px;
max-width: 300px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.board__add-list-input {
width: 100%;
padding: 0.4rem 0.5rem;
border: 1px solid #0079bf;
border-radius: 3px;
font: inherit;
font-size: 0.95rem;
color: #172b4d;
outline: none;
box-sizing: border-box;
}
.board__add-list-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.board__add-list-submit {
background: #0079bf;
color: #ffffff;
border: none;
border-radius: 3px;
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
}
.board__add-list-submit:hover {
background: #026aa7;
}
.board__add-list-cancel {
background: transparent;
border: none;
color: #5e6c84;
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.board__add-list-cancel:hover {
color: #172b4d;
}
.board__empty {
margin: auto;
padding: 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 0.95rem;
}

247
src/components/Board.jsx Normal file
View File

@@ -0,0 +1,247 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCorners,
DragOverlay,
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
arrayMove,
} from '@dnd-kit/sortable'
import List from './List.jsx'
import ListHeader from './ListHeader.jsx'
import './Board.css'
import {
cardDndId,
listDndId,
isCardDndId,
isListDndId,
parseCardDndId,
parseListDndId,
} from '../lib/boardDnd'
/**
* Board — the central kanban surface for a single board.
*
* Owns the single DnD context that wraps all sortable lists + cards.
* Receives `{ state, actions }` from the host (App.jsx) so it can be
* driven by any store backend.
*
* `boardId` is taken from props if provided, otherwise falls back to
* `state.activeBoardId`.
*/
export default function Board({ state, actions, boardId: boardIdProp }) {
const boardId = boardIdProp ?? state.activeBoardId
const board = boardId ? state.boards[boardId] : null
const lists = useMemo(() => {
if (!board) return []
return board.listIds.map((id) => state.lists[id]).filter(Boolean)
}, [board, state.lists])
// DnD sensors — PointerSensor with 5px activation distance for touch friendliness.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// Track which draggable is currently being dragged for the overlay.
const [activeDragId, setActiveDragId] = useState(null)
function handleDragStart(event) {
setActiveDragId(event.active?.id ?? null)
}
function handleDragEnd(event) {
setActiveDragId(null)
const { active, over } = event
if (!active || !over) return
const activeId = active.id
const overId = over.id
if (activeId === overId) return
// Card drag — could land on another card (reorder within list) or on
// a list container (move to list at end).
if (isCardDndId(activeId)) {
const cardId = parseCardDndId(activeId)
// Figure out the target list + insertion index.
let toListId
let toIndex
if (isCardDndId(overId)) {
const overCard = state.cards[parseCardDndId(overId)]
if (!overCard) return
toListId = overCard.listId
const targetList = state.lists[toListId]
toIndex = targetList ? targetList.cardIds.indexOf(parseCardDndId(overId)) : 0
} else if (isListDndId(overId)) {
toListId = parseListDndId(overId)
const targetList = state.lists[toListId]
toIndex = targetList ? targetList.cardIds.length : 0
} else {
return
}
actions.moveCard(cardId, toListId, toIndex)
return
}
// List drag — reorder within the same board.
if (isListDndId(activeId)) {
const fromListId = parseListDndId(activeId)
const fromIndex = board ? board.listIds.indexOf(fromListId) : -1
let toIndex
if (isListDndId(overId)) {
toIndex = board ? board.listIds.indexOf(parseListDndId(overId)) : -1
} else {
return
}
if (fromIndex < 0 || toIndex < 0) return
actions.moveList(boardId, fromIndex, toIndex)
return
}
}
function handleDragCancel() {
setActiveDragId(null)
}
// "Add another list" inline editor state.
const [adding, setAdding] = useState(false)
const inputRef = useRef(null)
useEffect(() => {
if (adding && inputRef.current) {
inputRef.current.focus()
}
}, [adding])
const startAddList = useCallback(() => {
if (!boardId) return
const newId = actions.addList(boardId, '')
setAdding(newId)
}, [actions, boardId])
const commitNewListName = useCallback(
(value) => {
const next = (value || '').trim()
if (next) {
actions.renameList(adding, next)
} else {
actions.deleteList(adding)
}
setAdding(false)
},
[actions, adding],
)
if (!board) {
return (
<div className="board" data-testid="board">
<p className="board__empty">No board selected.</p>
</div>
)
}
const listDndIds = lists.map((l) => listDndId(l.id))
return (
<div className="board" data-testid="board">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={listDndIds} strategy={horizontalListSortingStrategy}>
<div className="board__lists">
{lists.map((list) => (
<SortableList
key={list.id}
list={list}
state={state}
actions={actions}
/>
))}
{adding ? (
<form
className="board__add-list-form"
onSubmit={(e) => {
e.preventDefault()
if (inputRef.current) commitNewListName(inputRef.current.value)
}}
>
<input
ref={inputRef}
className="board__add-list-input"
type="text"
placeholder="Enter list title…"
aria-label="New list title"
data-testid="board-add-list-input"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitNewListName(e.target.value)
} else if (e.key === 'Escape') {
e.preventDefault()
actions.deleteList(adding)
setAdding(false)
}
}}
onBlur={(e) => commitNewListName(e.target.value)}
/>
<div className="board__add-list-actions">
<button type="submit" className="board__add-list-submit">Add list</button>
<button
type="button"
className="board__add-list-cancel"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
actions.deleteList(adding)
setAdding(false)
}}
>
Cancel
</button>
</div>
</form>
) : (
<button
type="button"
className="board__add-list"
onClick={startAddList}
>
+ Add another list
</button>
)}
</div>
</SortableContext>
</DndContext>
</div>
)
}
/**
* SortableList — a List wrapped in sortable bindings so the entire list
* header can be dragged to reorder. The cards inside are independently
* sortable via the List component's own SortableContext (vertical).
*/
function SortableList({ list, state, actions }) {
// Use the ListHeader's useSortable to get the drag bindings for the whole column.
// For simplicity we make the whole list section draggable by using the header.
// The List component below renders the cards; cards are themselves sortable
// via List's internal SortableContext.
const cardDndIds = list.cardIds.map(cardDndId)
return (
<div className="board__list-wrap" data-list-id={list.id}>
<List listId={list.id} state={state} actions={actions} />
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Board from './Board.jsx'
import { renderWithStore } from '../test/testUtils.jsx'
describe('<Board />', () => {
test('renders every list that belongs to the given board, in order', () => {
// Seed via initial state so we don't need to mutate after render.
const boardId = 'b1'
const list1 = 'l1'
const list2 = 'l2'
const initial = {
schemaVersion: 2,
activeBoardId: boardId,
boardOrder: [boardId],
boards: { [boardId]: { id: boardId, name: 'Project A', listIds: [list1, list2] } },
lists: {
[list1]: { id: list1, boardId, name: 'Backlog', cardIds: [] },
[list2]: { id: list2, boardId, name: 'In Progress', cardIds: [] },
},
cards: {},
}
renderWithStore(<Board boardId={boardId} />, { initial })
const lists = screen.getAllByText(/^(Backlog|In Progress)$/)
expect(lists.length).toBe(2)
expect(lists[0]).toHaveTextContent('Backlog')
expect(lists[1]).toHaveTextContent('In Progress')
})
test('"Add another list" button appends a new list and focuses the inline editor', async () => {
const user = userEvent.setup()
const boardId = 'b1'
const initial = {
schemaVersion: 2,
activeBoardId: boardId,
boardOrder: [boardId],
boards: { [boardId]: { id: boardId, name: 'Project A', listIds: [] } },
lists: {},
cards: {},
}
const result = renderWithStore(<Board boardId={boardId} />, { initial })
const before = Object.keys(result.state.lists).length
await user.click(screen.getByRole('button', { name: /add another list/i }))
expect(Object.keys(result.state.lists).length).toBe(before + 1)
// Inline editor (input) takes focus.
const input = screen.getByRole('textbox')
expect(input).toHaveFocus()
})
test('renders nothing useful when the boardId is unknown', () => {
const initial = {
schemaVersion: 2,
activeBoardId: 'other',
boardOrder: ['other'],
boards: { other: { id: 'other', name: 'Other', listIds: [] } },
lists: {},
cards: {},
}
renderWithStore(<Board boardId="missing" />, { initial })
// No lists for the missing board.
expect(screen.queryByText('Backlog')).toBeNull()
expect(screen.queryByText('In Progress')).toBeNull()
expect(screen.queryByText('Solo')).toBeNull()
})
test('falls back to state.activeBoardId when boardId prop is omitted', () => {
const boardId = 'b1'
const list1 = 'l1'
const initial = {
schemaVersion: 2,
activeBoardId: boardId,
boardOrder: [boardId],
boards: { [boardId]: { id: boardId, name: 'Solo', listIds: [list1] } },
lists: { [list1]: { id: list1, boardId, name: 'Todo', cardIds: [] } },
cards: {},
}
renderWithStore(<Board />, { initial })
expect(screen.getByText('Todo')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,174 @@
// Integration tests for the DnD-wired board.
// Uses userEvent for pointer gestures with target+coords.
import { render, screen, cleanup, act } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import { FixtureBoard } from './__fixtures__/DnDFixtureBoard';
// Stub out text-selection side effects in userEvent v14. The pointer
// pipeline tries to set a text selection on mousedown to mirror browser
// behavior, which throws WrongDocumentError against our stubbed rects
// (because resolveCaretPosition walks into a node whose ownerDocument
// differs). For DnD tests we don't care about text selection.
const selMod = require('@testing-library/user-event/dist/cjs/event/selection/setSelectionPerMouse.js');
selMod.setSelectionPerMouseDown = () => undefined;
const makeState = () => ({
schemaVersion: 2,
activeBoardId: 'b1',
boards: { b1: { id: 'b1', name: 'My Board', listIds: ['l1', 'l2'] } },
boardOrder: ['b1'],
lists: {
l1: { id: 'l1', boardId: 'b1', name: 'Todo', cardIds: ['c1', 'c2'] },
l2: { id: 'l2', boardId: 'b1', name: 'Doing', cardIds: ['c3', 'c4'] },
},
cards: {
c1: { id: 'c1', boardId: 'b1', listId: 'l1', title: 'A' },
c2: { id: 'c2', boardId: 'b1', listId: 'l1', title: 'B' },
c3: { id: 'c3', boardId: 'b1', listId: 'l2', title: 'C' },
c4: { id: 'c4', boardId: 'b1', listId: 'l2', title: 'D' },
},
});
// Each card occupies a known rect; @dnd-kit reads these for collision.
const RECTS = {
'list-l1': { x: 0, y: 0, w: 250, h: 100 },
'list-header-l1': { x: 0, y: 0, w: 250, h: 30 },
'list-body-l1': { x: 0, y: 30, w: 250, h: 70 },
'card-c1': { x: 0, y: 30, w: 250, h: 30 },
'card-c2': { x: 0, y: 60, w: 250, h: 30 },
'list-l2': { x: 260, y: 0, w: 250, h: 100 },
'list-header-l2': { x: 260, y: 0, w: 250, h: 30 },
'list-body-l2': { x: 260, y: 30, w: 250, h: 70 },
'card-c3': { x: 260, y: 30, w: 250, h: 30 },
'card-c4': { x: 260, y: 60, w: 250, h: 30 },
'board': { x: 0, y: 0, w: 510, h: 100 },
};
let origGetRect;
function stubRects() {
origGetRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
const t = this.getAttribute && this.getAttribute('data-testid');
const r = t && RECTS[t];
const x = r ? r.x : 0;
const y = r ? r.y : 0;
const w = r ? r.w : 100;
const h = r ? r.h : 40;
return {
x, y, width: w, height: h,
top: y, left: x, right: x + w, bottom: y + h,
toJSON() { return {}; },
};
};
}
function restoreRects() {
if (origGetRect) Element.prototype.getBoundingClientRect = origGetRect;
}
function center(testId) {
const r = RECTS[testId];
return { clientX: r.x + r.w / 2, clientY: r.y + r.h / 2 };
}
async function drag(srcEl, dstEl, user) {
const srcCenter = center(srcEl.getAttribute('data-testid'));
const dstCenter = center(dstEl.getAttribute('data-testid'));
await user.pointer({ keys: '[MouseLeft>]', target: srcEl });
// Move past the 5px activation distance
await user.pointer({ coords: { clientX: srcCenter.clientX + 20, clientY: srcCenter.clientY + 20 } });
// Move in steps
const steps = 8;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
await user.pointer({
coords: {
clientX: srcCenter.clientX + (dstCenter.clientX - srcCenter.clientX) * t,
clientY: srcCenter.clientY + (dstCenter.clientY - srcCenter.clientY) * t,
},
});
}
await user.pointer({ coords: dstCenter });
await user.pointer({ keys: '[/MouseLeft]', coords: dstCenter });
await act(async () => {});
}
beforeEach(() => stubRects());
afterEach(() => {
cleanup();
restoreRects();
});
describe('FixtureBoard', () => {
test('renders all lists and their cards', () => {
render(<FixtureBoard initialState={makeState()} />);
expect(screen.getByTestId('list-l1')).toBeInTheDocument();
expect(screen.getByTestId('list-l2')).toBeInTheDocument();
expect(screen.getByTestId('card-c1')).toHaveTextContent('A');
expect(screen.getByTestId('card-c2')).toHaveTextContent('B');
expect(screen.getByTestId('card-c3')).toHaveTextContent('C');
expect(screen.getByTestId('card-c4')).toHaveTextContent('D');
});
test('drag card from list 1 to list 2 moves it across lists', async () => {
const stateChanges = [];
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
render(
<FixtureBoard
initialState={makeState()}
onStateChange={(s) => stateChanges.push(JSON.parse(JSON.stringify(s)))}
/>
);
await drag(screen.getByTestId('card-c1'), screen.getByTestId('card-c3'), user);
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBeDefined();
expect(lastState.cards.c1.listId).toBe('l2');
expect(lastState.lists.l1.cardIds).not.toContain('c1');
expect(lastState.lists.l2.cardIds).toContain('c1');
});
test('drag a card within the same list reorders it', async () => {
const stateChanges = [];
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
render(
<FixtureBoard
initialState={makeState()}
onStateChange={(s) => stateChanges.push(JSON.parse(JSON.stringify(s)))}
/>
);
await drag(screen.getByTestId('card-c1'), screen.getByTestId('card-c2'), user);
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBeDefined();
const l1Ids = lastState.lists.l1.cardIds;
expect(l1Ids.indexOf('c2')).toBeLessThan(l1Ids.indexOf('c1'));
});
test('drag a list swaps positions', async () => {
window.__dndDebug = true;
const stateChanges = [];
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
render(
<FixtureBoard
initialState={makeState()}
onStateChange={(s) => stateChanges.push(JSON.parse(JSON.stringify(s)))}
/>
);
await drag(screen.getByTestId('list-body-l1'), screen.getByTestId('list-body-l2'), user);
window.__dndDebug = false;
console.log('TEST 4: stateChanges:', stateChanges.length);
stateChanges.forEach((s, i) => {
console.log(` [${i}] listIds:`, s.boards.b1.listIds);
});
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBeDefined();
expect(lastState.boards.b1.listIds).toEqual(['l2', 'l1']);
});
});

232
src/components/Card.css Normal file
View File

@@ -0,0 +1,232 @@
.card {
position: relative;
background: #ffffff;
box-shadow: 0 1px 0 rgba(9, 30, 66, 0.25);
border-radius: 3px;
padding: 0.5rem 0.75rem;
cursor: pointer;
border: none;
color: #172b4d;
font-size: 0.9rem;
line-height: 1.35;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.card:hover {
background: #f4f5f7;
}
.card:focus {
outline: 2px solid #0079bf;
outline-offset: 1px;
}
.card__title {
font-weight: 500;
/* Truncate at 3 lines */
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.card__description-preview {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
color: #5e6c84;
min-width: 0;
}
.card__description-icon {
flex: 0 0 auto;
}
.card__description-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.card__due {
font-size: 0.78rem;
color: #5e6c84;
display: flex;
align-items: center;
gap: 0.25rem;
}
.card__due--overdue {
color: #b04632;
background: #fce8e6;
border-radius: 3px;
padding: 0.1rem 0.35rem;
align-self: flex-start;
}
.card--overdue .card__title {
/* Subtle visual hint that this card is past due (independent of the due-date pill) */
}
.card__delete {
position: absolute;
top: 0.15rem;
right: 0.15rem;
width: 1.4rem;
height: 1.4rem;
background: rgba(9, 30, 66, 0.85);
color: #ffffff;
border: none;
border-radius: 3px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.card:hover .card__delete,
.card:focus-within .card__delete {
opacity: 1;
}
.card__delete:hover {
background: #b04632;
}
/* ---- Card detail modal ---- */
.card-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.card-modal {
background: #ffffff;
border-radius: 8px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow: auto;
padding: 1.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-modal__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.card-modal__label {
font-size: 0.8rem;
font-weight: 600;
color: #5e6c84;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-modal__title,
.card-modal__desc,
.card-modal__due,
.card-modal__move {
font: inherit;
color: #172b4d;
border: 1px solid #dfe1e6;
border-radius: 3px;
padding: 0.5rem 0.65rem;
background: #ffffff;
outline: none;
}
.card-modal__title:focus,
.card-modal__desc:focus,
.card-modal__due:focus,
.card-modal__move:focus {
border-color: #0079bf;
box-shadow: 0 0 0 1px #0079bf;
}
.card-modal__desc {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.card-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-top: 1px solid #ebecf0;
padding-top: 1rem;
}
.card-modal__footer-right {
display: flex;
gap: 0.5rem;
}
.card-modal__btn {
font: inherit;
font-size: 0.9rem;
font-weight: 500;
border-radius: 3px;
padding: 0.45rem 0.9rem;
cursor: pointer;
border: 1px solid transparent;
}
.card-modal__btn--primary {
background: #0079bf;
color: #ffffff;
}
.card-modal__btn--primary:hover:not(:disabled) {
background: #026aa7;
}
.card-modal__btn--primary:disabled {
background: #a5b6c7;
cursor: not-allowed;
}
.card-modal__btn--secondary {
background: transparent;
color: #5e6c84;
border-color: #dfe1e6;
}
.card-modal__btn--secondary:hover {
background: #f4f5f7;
color: #172b4d;
}
.card-modal__btn--danger {
background: transparent;
color: #b04632;
border-color: transparent;
}
.card-modal__btn--danger:hover {
background: #fce8e6;
}

69
src/components/Card.jsx Normal file
View File

@@ -0,0 +1,69 @@
import './Card.css'
function isOverdue(dueDate) {
if (!dueDate) return false
const today = new Date().toISOString().slice(0, 10)
return dueDate < today
}
function Card({ card, onClick, onDelete }) {
if (!card) return null
const overdue = isOverdue(card.dueDate)
const classes = ['card']
if (overdue) classes.push('card--overdue')
function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.(card.id)
}
}
function handleDeleteClick(e) {
// Prevent the card's click from also firing (would open the modal)
e.stopPropagation()
onDelete?.(card.id)
}
return (
<div
className={classes.join(' ')}
role="button"
tabIndex={0}
aria-label={card.title}
onClick={() => onClick?.(card.id)}
onKeyDown={handleKeyDown}
>
{card.description && (
<div className="card__description-preview" title={card.description}>
<span className="card__description-icon" aria-hidden="true">📝</span>
<span className="card__description-text">{card.description}</span>
</div>
)}
<div className="card__title">{card.title}</div>
{card.dueDate && (
<div
className={`card__due ${overdue ? 'card__due--overdue' : ''}`}
aria-label={`Due ${card.dueDate}${overdue ? ' (overdue)' : ''}`}
>
<span aria-hidden="true">📅</span> {card.dueDate}
</div>
)}
{onDelete && (
<button
type="button"
className="card__delete"
aria-label="delete card"
onClick={handleDeleteClick}
>
×
</button>
)}
</div>
)
}
export default Card

View File

@@ -0,0 +1,142 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@testing-library/react'
import { useBoardStore } from '../store/boardStore.js'
import Card from './Card.jsx'
// P2 store is module-singleton (one useState per test file). We seed it from
// localStorage in beforeEach by writing a fresh v2 state and remounting.
beforeEach(() => {
window.localStorage.clear()
})
function Wrapper({ cardId, onUpdate, onDelete, onMove }) {
// The hook's state seed runs on mount; first mount reads the (cleared) store.
const { state, actions } = useBoardStore()
return (
<Card
card={state.cards[cardId]}
actions={actions}
onUpdate={onUpdate}
onDelete={onDelete}
onMove={onMove}
/>
)
}
function seedCard(overrides = {}) {
const card = {
id: 'c1',
boardId: 'b1',
listId: 'l1',
title: 'Buy milk',
description: '',
dueDate: null,
createdAt: 1,
...overrides,
}
const state = {
schemaVersion: 2,
activeBoardId: 'b1',
boards: { b1: { id: 'b1', name: 'Inbox', listIds: ['l1'] } },
boardOrder: ['b1'],
lists: { l1: { id: 'l1', boardId: 'b1', name: 'Todo', cardIds: ['c1'] } },
cards: { c1: card },
}
window.localStorage.setItem('ultra-todo-v2-state', JSON.stringify(state))
return card
}
describe('<Card />', () => {
test('renders the card title', () => {
seedCard({ title: 'Buy milk' })
render(<Wrapper cardId="c1" />)
expect(screen.getByText('Buy milk')).toBeInTheDocument()
})
test('renders a description preview when description is set', () => {
seedCard({ description: 'Long form notes about the card' })
render(<Wrapper cardId="c1" />)
// Description appears as a truncated 1-line preview
expect(screen.getByText('Long form notes about the card')).toBeInTheDocument()
})
test('does not render description preview when description is empty', () => {
seedCard({ description: '' })
render(<Wrapper cardId="c1" />)
expect(screen.queryByText(/📝/)).toBeNull()
})
test('renders due date footer when dueDate is set', () => {
seedCard({ dueDate: '2099-12-31' })
render(<Wrapper cardId="c1" />)
expect(screen.getByText(/2099-12-31/)).toBeInTheDocument()
})
test('marks the card overdue when dueDate is in the past', () => {
seedCard({ dueDate: '2000-01-01' })
render(<Wrapper cardId="c1" />)
const card = screen.getByRole('button', { name: /Buy milk/ })
expect(card).toHaveClass('card--overdue')
})
test('does NOT mark the card overdue when dueDate is today or future', () => {
const today = new Date().toISOString().slice(0, 10)
seedCard({ dueDate: today })
render(<Wrapper cardId="c1" />)
const card = screen.getByRole('button', { name: /Buy milk/ })
expect(card).not.toHaveClass('card--overdue')
})
test('clicking the card invokes onClick (opens modal in the host)', async () => {
const user = userEvent.setup()
seedCard()
const onClick = jest.fn()
function Host() {
return <Card card={{ id: 'x', boardId: 'b1', listId: 'l1', title: 'X', description: '', dueDate: null, createdAt: 1 }} onClick={onClick} />
}
render(<Host />)
await user.click(screen.getByRole('button', { name: /X/ }))
expect(onClick).toHaveBeenCalledTimes(1)
})
test('Enter key on focused card invokes onClick (a11y)', async () => {
const user = userEvent.setup()
const onClick = jest.fn()
function Host() {
return <Card card={{ id: 'x', boardId: 'b1', listId: 'l1', title: 'X', description: '', dueDate: null, createdAt: 1 }} onClick={onClick} />
}
render(<Host />)
const card = screen.getByRole('button', { name: /X/ })
card.focus()
await user.keyboard('{Enter}')
expect(onClick).toHaveBeenCalledTimes(1)
})
test('Space key on focused card invokes onClick (a11y)', async () => {
const user = userEvent.setup()
const onClick = jest.fn()
function Host() {
return <Card card={{ id: 'x', boardId: 'b1', listId: 'l1', title: 'X', description: '', dueDate: null, createdAt: 1 }} onClick={onClick} />
}
render(<Host />)
const card = screen.getByRole('button', { name: /X/ })
card.focus()
await user.keyboard(' ')
expect(onClick).toHaveBeenCalledTimes(1)
})
test('hover delete button removes the card via onDelete and does NOT trigger onClick', async () => {
const user = userEvent.setup()
seedCard()
const onDelete = jest.fn()
const onClick = jest.fn()
function Host() {
return <Card card={{ id: 'c1', boardId: 'b1', listId: 'l1', title: 'Buy milk', description: '', dueDate: null, createdAt: 1 }} onClick={onClick} onDelete={onDelete} />
}
render(<Host />)
await user.click(screen.getByRole('button', { name: /delete card/i }))
expect(onDelete).toHaveBeenCalledWith('c1')
expect(onClick).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react'
import './Card.css'
const EMPTY_DRAFT = { title: '', description: '', dueDate: null }
function CardDetailModal({ card, lists, onSave, onDelete, onMove, onClose }) {
const [draft, setDraft] = useState(() => ({
title: card.title ?? '',
description: card.description ?? '',
dueDate: card.dueDate ?? null,
}))
const titleRef = useRef(null)
const returnFocusEl = useRef(null)
// Focus the title input on mount; remember what to restore focus to on close.
useEffect(() => {
returnFocusEl.current = document.activeElement
titleRef.current?.focus()
titleRef.current?.select?.()
return () => {
// Best-effort focus restoration. The host may have unmounted the trigger
// by then, in which case the activeElement is the body — that's fine.
if (returnFocusEl.current && typeof returnFocusEl.current.focus === 'function') {
try { returnFocusEl.current.focus() } catch { /* element gone */ }
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Escape closes (no save). Mounted on document so the key fires even when
// focus is in the description textarea or the date input.
useEffect(() => {
function onKeyDown(e) {
if (e.key === 'Escape') {
e.stopPropagation()
onClose?.()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
function setField(name, value) {
setDraft((d) => ({ ...d, [name]: value }))
}
function handleSave() {
const title = (draft.title ?? '').trim()
if (title === '') return
onSave?.(card.id, {
title,
description: draft.description ?? '',
dueDate: draft.dueDate ?? null,
})
onClose?.()
}
function handleDelete() {
// eslint-disable-next-line no-alert
if (window.confirm('Delete this card? This cannot be undone.')) {
onDelete?.(card.id)
onClose?.()
}
}
function handleOverlayClick(e) {
if (e.target === e.currentTarget) onClose?.()
}
function handleMoveChange(e) {
const toListId = e.target.value
if (!toListId || toListId === card.listId) return
// Insert at the end of the destination list.
onMove?.(card.id, toListId, Number.MAX_SAFE_INTEGER)
}
const otherLists = (lists || []).filter((l) => l.id !== card.listId)
return (
<div
className="card-modal-overlay"
data-testid="card-modal-overlay"
onClick={handleOverlayClick}
>
<div
className="card-modal"
role="dialog"
aria-modal="true"
aria-label="Edit card"
>
<div className="card-modal__field">
<label htmlFor="card-modal-title" className="card-modal__label">Title</label>
<input
ref={titleRef}
id="card-modal-title"
className="card-modal__title"
type="text"
value={draft.title}
onChange={(e) => setField('title', e.target.value)}
/>
</div>
<div className="card-modal__field">
<label htmlFor="card-modal-desc" className="card-modal__label">Description</label>
<textarea
id="card-modal-desc"
className="card-modal__desc"
rows={4}
value={draft.description}
onChange={(e) => setField('description', e.target.value)}
/>
</div>
<div className="card-modal__field">
<label htmlFor="card-modal-due" className="card-modal__label">Due date</label>
<input
id="card-modal-due"
className="card-modal__due"
type="date"
value={draft.dueDate ?? ''}
onChange={(e) => setField('dueDate', e.target.value || null)}
/>
</div>
<div className="card-modal__field">
<label htmlFor="card-modal-move" className="card-modal__label">Move to list</label>
<select
id="card-modal-move"
className="card-modal__move"
value=""
onChange={handleMoveChange}
>
<option value="" disabled>
{`Currently in: ${(lists || []).find((l) => l.id === card.listId)?.name ?? '—'}`}
</option>
{otherLists.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div className="card-modal__footer">
<button
type="button"
className="card-modal__btn card-modal__btn--danger"
onClick={handleDelete}
>
Delete
</button>
<div className="card-modal__footer-right">
<button
type="button"
className="card-modal__btn card-modal__btn--secondary"
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className="card-modal__btn card-modal__btn--primary"
onClick={handleSave}
disabled={(draft.title ?? '').trim() === ''}
>
Save
</button>
</div>
</div>
</div>
</div>
)
}
export { EMPTY_DRAFT }
export default CardDetailModal

View File

@@ -0,0 +1,157 @@
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 }))
})
})

View File

@@ -0,0 +1,26 @@
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.empty-state__inner {
text-align: center;
color: rgba(255, 255, 255, 0.92);
max-width: 360px;
}
.empty-state__emoji {
font-size: 3rem;
margin: 0 0 1rem;
line-height: 1;
}
.empty-state__text {
font-size: 1.05rem;
line-height: 1.5;
margin: 0;
color: rgba(255, 255, 255, 0.95);
}

View File

@@ -0,0 +1,26 @@
import './EmptyState.css';
export default function EmptyState({ variant = 'first-run' }) {
if (variant === 'no-board') {
return (
<div className="empty-state">
<div className="empty-state__inner">
<p className="empty-state__emoji" aria-hidden="true">📋</p>
<p className="empty-state__text">
Select a board from the sidebar, or create a new one to get started.
</p>
</div>
</div>
);
}
return (
<div className="empty-state">
<div className="empty-state__inner">
<p className="empty-state__emoji" aria-hidden="true">📋</p>
<p className="empty-state__text">
Welcome to Ultra Todo. Create your first board to get started.
</p>
</div>
</div>
);
}

104
src/components/List.css Normal file
View File

@@ -0,0 +1,104 @@
.list {
background: #ebecf0;
border-radius: 8px;
padding: 0.5rem;
width: 272px;
flex: 0 0 272px;
max-width: 300px;
min-width: 272px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
}
.list__cards {
flex: 1 1 auto;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.1rem 0.15rem 0.5rem;
margin: 0;
list-style: none;
}
.list__card-placeholder {
background: #ffffff;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(9, 30, 66, 0.25);
padding: 0.5rem 0.65rem;
color: #172b4d;
font-size: 0.9rem;
cursor: pointer;
}
.list__add-card {
background: transparent;
border: none;
text-align: left;
padding: 0.4rem 0.5rem;
border-radius: 3px;
color: #5e6c84;
font-size: 0.9rem;
cursor: pointer;
}
.list__add-card:hover {
background: rgba(9, 30, 66, 0.08);
color: #172b4d;
}
.list__add-card-form {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.15rem;
}
.list__add-card-textarea {
width: 100%;
min-height: 56px;
resize: vertical;
padding: 0.5rem;
border: 1px solid #0079bf;
border-radius: 3px;
font: inherit;
font-size: 0.9rem;
color: #172b4d;
outline: none;
box-sizing: border-box;
}
.list__add-card-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.list__add-card-submit {
background: #0079bf;
color: #ffffff;
border: none;
border-radius: 3px;
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
}
.list__add-card-submit:hover {
background: #026aa7;
}
.list__add-card-cancel {
background: transparent;
border: none;
color: #5e6c84;
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.list__add-card-cancel:hover {
color: #172b4d;
}

197
src/components/List.jsx Normal file
View File

@@ -0,0 +1,197 @@
import { useState, useRef, useEffect } from 'react'
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import Card from './Card.jsx'
import CardDetailModal from './CardDetailModal.jsx'
import ListHeader from './ListHeader.jsx'
import { cardDndId } from '../lib/boardDnd'
import './List.css'
/**
* List — a single Trello-style column. Prop-driven: receives state + actions
* from the host (Board.jsx → App.jsx).
*
* Props:
* - listId
* - state: the v2 store state
* - actions: { addCard, updateCard, deleteCard, moveCard, renameList, deleteList, ... }
* - otherLists: optional list of {id,name} shown in the move-select inside CardDetailModal
*
* Renders a ListHeader (inline rename + delete menu) and a vertical
* SortableContext so individual cards can be reordered via DnD.
*/
function List({ listId, state, actions, otherLists }) {
const list = state.lists[listId]
const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState('')
const [openCardId, setOpenCardId] = useState(null)
const inputRef = useRef(null)
useEffect(() => {
if (adding && inputRef.current) {
inputRef.current.focus()
}
}, [adding])
if (!list) return null
const cards = list.cardIds.map((id) => state.cards[id]).filter(Boolean)
const cardIds = cards.map((c) => cardDndId(c.id))
function resetAndCloseForm() {
setDraft('')
setAdding(false)
}
function submitNewCard() {
const title = draft.trim()
if (title === '') {
resetAndCloseForm()
return
}
actions.addCard(listId, { title, description: '', dueDate: null })
resetAndCloseForm()
}
function handleAddKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submitNewCard()
} else if (e.key === 'Escape') {
e.preventDefault()
resetAndCloseForm()
}
}
const openCard = openCardId ? state.cards[openCardId] : null
// Sibling lists for the move-select inside the modal.
let siblingLists = otherLists
if (!siblingLists && openCard) {
siblingLists = (openCard.boardId
? state.boards[openCard.boardId]?.listIds ?? []
: []
)
.filter((id) => id !== openCard.listId)
.map((id) => ({ id, name: state.lists[id]?.name ?? id }))
}
return (
<section className="list" data-testid="list" data-list-id={listId} aria-label={list.name}>
<ListHeader listId={listId} state={state} actions={actions} />
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<ul className="list__cards">
{cards.length === 0 && (
<li className="list__card-placeholder list__card-placeholder--empty" aria-hidden="true">
No cards yet
</li>
)}
{cards.map((c) => (
<SortableCard key={c.id} card={c}>
<Card
card={c}
onClick={(id) => setOpenCardId(id)}
onDelete={(id) => actions.deleteCard(id)}
/>
</SortableCard>
))}
</ul>
</SortableContext>
<div className="list__add-card">
{adding ? (
<div className="list__add-card-form">
<textarea
ref={inputRef}
className="list__add-card-textarea"
aria-label="card title"
placeholder="Enter a title for this card…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleAddKeyDown}
rows={2}
/>
<div className="list__add-card-actions">
<button
type="button"
className="list__add-card-submit"
onClick={submitNewCard}
>
Add card
</button>
<button
type="button"
className="list__add-card-cancel"
onClick={resetAndCloseForm}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="list__add-card"
onClick={() => setAdding(true)}
>
+ Add a card
</button>
)}
</div>
{openCard && (
<CardDetailModal
card={openCard}
lists={siblingLists}
onSave={(id, patch) => actions.updateCard(id, patch)}
onDelete={(id) => actions.deleteCard(id)}
onMove={(id, toListId, toIndex) => actions.moveCard(id, toListId, toIndex)}
onClose={() => setOpenCardId(null)}
/>
)}
</section>
)
}
/**
* SortableCard — wraps a Card in @dnd-kit/sortable bindings so it can be
* dragged vertically within its parent list. The drag handle is the entire
* card surface; click events on child elements (e.g. the × delete button)
* are prevented from triggering drag via stopPropagation in the child.
*/
function SortableCard({ card, children }) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: cardDndId(card.id) })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<li
ref={setNodeRef}
style={style}
className="list__card-item"
data-card-id={card.id}
{...attributes}
{...listeners}
>
{children}
</li>
)
}
export default List

View File

@@ -0,0 +1,169 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { render } from '@testing-library/react'
import { useBoardStore } from '../store/boardStore.js'
import List from './List.jsx'
function Host({ listId, alsoRender, state, actions }) {
// Accept state + actions from a wrapping test host (the integration's
// renderWithStore passes them via cloneElement). Fall back to a direct
// useBoardStore call so this file can also be run standalone.
const store = useBoardStore()
const _state = state ?? store.state
const _actions = actions ?? store.actions
const listsToRender = [listId, ...(alsoRender || [])].filter(Boolean)
return (
<>
{listsToRender.map((id) => (
<List key={id} listId={id} actions={_actions} state={_state} />
))}
</>
)
}
function seedBoard() {
const state = {
schemaVersion: 2,
activeBoardId: 'b1',
boards: {
b1: { id: 'b1', name: 'Inbox', listIds: ['l1', 'l2'] },
},
boardOrder: ['b1'],
lists: {
l1: { id: 'l1', boardId: 'b1', name: 'Todo', cardIds: ['c1', 'c2'] },
l2: { id: 'l2', boardId: 'b1', name: 'Doing', cardIds: [] },
},
cards: {
c1: { id: 'c1', boardId: 'b1', listId: 'l1', title: 'Buy milk', description: '', dueDate: null, createdAt: 1 },
c2: { id: 'c2', boardId: 'b1', listId: 'l1', title: 'Walk dog', description: 'Around the block', dueDate: null, createdAt: 2 },
},
}
window.localStorage.setItem('ultra-todo-v2-state', JSON.stringify(state))
return state
}
beforeEach(() => {
window.localStorage.clear()
})
describe('<List />', () => {
test('renders the list name and each card title in order', () => {
seedBoard()
render(<Host listId="l1" />)
expect(screen.getByText('Todo')).toBeInTheDocument()
expect(screen.getByText('Buy milk')).toBeInTheDocument()
expect(screen.getByText('Walk dog')).toBeInTheDocument()
expect(screen.getByText('Around the block')).toBeInTheDocument()
})
test('renders nothing for an unknown listId', () => {
seedBoard()
render(<Host listId="missing" />)
// No .list section for the missing id, so the cards from the seeded
// lists (l1, l2) are also not in the DOM. (We did NOT pass them in.)
expect(screen.queryByText('Buy milk')).toBeNull()
expect(document.querySelector('.list')).toBeNull()
})
test('"Add a card" button toggles to an inline form', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" />)
expect(screen.queryByRole('textbox', { name: /card title/i })).toBeNull()
await user.click(screen.getByRole('button', { name: /add a card/i }))
expect(screen.getByRole('textbox', { name: /card title/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^add card$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^cancel$/i })).toBeInTheDocument()
})
test('Add-card form: type + Enter appends a new card and closes the form', async () => {
const user = userEvent.setup()
seedBoard()
const { container } = render(<Host listId="l1" />)
const before = screen.getAllByRole('button', { name: /delete card/i }).length
await user.click(screen.getByRole('button', { name: /add a card/i }))
const input = screen.getByRole('textbox', { name: /card title/i })
await user.type(input, 'New task{enter}')
expect(screen.getByText('New task')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /delete card/i }).length).toBe(before + 1)
// Form closed
expect(screen.queryByRole('textbox', { name: /card title/i })).toBeNull()
})
test('Add-card form: type + Escape cancels without adding a card', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" />)
const before = screen.getAllByRole('button', { name: /delete card/i }).length
await user.click(screen.getByRole('button', { name: /add a card/i }))
const input = screen.getByRole('textbox', { name: /card title/i })
await user.type(input, 'Throwaway{escape}')
expect(screen.queryByText('Throwaway')).toBeNull()
expect(screen.getAllByRole('button', { name: /delete card/i }).length).toBe(before)
// Form closed
expect(screen.queryByRole('textbox', { name: /card title/i })).toBeNull()
})
test('Add-card form: submitting an empty/whitespace title is a no-op', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" />)
const before = screen.getAllByRole('button', { name: /delete card/i }).length
await user.click(screen.getByRole('button', { name: /add a card/i }))
const input = screen.getByRole('textbox', { name: /card title/i })
await user.type(input, ' {enter}')
expect(screen.getAllByRole('button', { name: /delete card/i }).length).toBe(before)
})
test('Clicking a card opens the detail modal populated with that card', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" />)
await user.click(screen.getByRole('button', { name: 'Buy milk' }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByDisplayValue('Buy milk')).toBeInTheDocument()
})
test('Saving in the modal updates the card title in the list', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" />)
await user.click(screen.getByRole('button', { name: 'Buy milk' }))
const dialog = screen.getByRole('dialog')
const titleInput = within(dialog).getByDisplayValue('Buy milk')
await user.clear(titleInput)
await user.type(titleInput, 'Buy oat milk')
await user.click(within(dialog).getByRole('button', { name: /^save$/i }))
expect(screen.getByText('Buy oat milk')).toBeInTheDocument()
expect(screen.queryByText('Buy milk')).toBeNull()
})
test('Deleting from the modal removes the card from the list', async () => {
const user = userEvent.setup()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
seedBoard()
render(<Host listId="l1" />)
await user.click(screen.getByRole('button', { name: 'Buy milk' }))
const dialog = screen.getByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: /^delete$/i }))
expect(screen.queryByText('Buy milk')).toBeNull()
expect(screen.getByText('Walk dog')).toBeInTheDocument()
confirmSpy.mockRestore()
})
test('Moving a card via the modal Move-to-list select changes the list it lives in', async () => {
const user = userEvent.setup()
seedBoard()
render(<Host listId="l1" alsoRender={['l2']} />)
await user.click(screen.getByRole('button', { name: 'Buy milk' }))
const dialog = screen.getByRole('dialog')
await user.selectOptions(within(dialog).getByLabelText(/move to list/i), 'l2')
// Scope to the two list elements. The Todo list (l1) and the Doing list (l2)
// are both rendered, so we can verify the move.
const lists = document.querySelectorAll('.list')
const [todoList, doingList] = lists
expect(within(todoList).queryByText('Buy milk')).toBeNull()
expect(within(doingList).getByText('Buy milk')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,105 @@
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.5rem;
position: relative;
}
.list-header__name {
flex: 1 1 auto;
min-width: 0;
background: transparent;
border: none;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.95rem;
color: #172b4d;
text-align: left;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-header__name:hover {
background: rgba(9, 30, 66, 0.08);
}
.list-header__menu-wrap {
position: relative;
flex: 0 0 auto;
}
.list-header__menu-btn {
background: transparent;
border: none;
font-size: 1.1rem;
line-height: 1;
color: #5e6c84;
padding: 0.25rem 0.4rem;
border-radius: 3px;
cursor: pointer;
}
.list-header__menu-btn:hover {
background: rgba(9, 30, 66, 0.08);
color: #172b4d;
}
.list-header__menu {
position: absolute;
top: 100%;
right: 0;
margin: 0.15rem 0 0;
padding: 0.35rem 0;
list-style: none;
background: #ffffff;
border-radius: 3px;
box-shadow: 0 8px 16px rgba(9, 30, 66, 0.2);
z-index: 10;
min-width: 160px;
}
.list-header__menu-item {
display: block;
width: 100%;
background: transparent;
border: none;
padding: 0.4rem 0.75rem;
text-align: left;
font-size: 0.9rem;
color: #172b4d;
cursor: pointer;
}
.list-header__menu-item:hover {
background: rgba(9, 30, 66, 0.08);
}
.list-header__menu-item--danger {
color: #b04632;
}
.list-header__menu-item--danger:hover {
background: rgba(176, 70, 50, 0.1);
}
.list-header__rename-input {
flex: 1 1 auto;
width: 100%;
background: #ffffff;
border: 1px solid #0079bf;
border-radius: 3px;
padding: 0.3rem 0.5rem;
font-size: 0.95rem;
font-weight: 600;
color: #172b4d;
outline: none;
}
.list-header--renaming {
padding: 0.25rem 0.5rem 0.5rem;
}

View File

@@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from 'react'
import './ListHeader.css'
/**
* ListHeader — prop-driven list column header with inline rename + delete menu.
*
* Props:
* - listId
* - state: the v2 store state
* - actions: { renameList, deleteList, ... }
*/
export default function ListHeader({ listId, state, actions }) {
const list = listId ? state.lists[listId] : null
const [isRenaming, setRenaming] = useState(false)
const [renameValue, setRenameValue] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const inputRef = useRef(null)
const menuRef = useRef(null)
useEffect(() => {
if (isRenaming && inputRef.current && list) {
inputRef.current.focus()
inputRef.current.select()
setRenameValue(list.name)
}
}, [isRenaming, list])
useEffect(() => {
if (!menuOpen) return
function onDoc(e) {
if (menuRef.current && !menuRef.current.contains(e.target)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [menuOpen])
if (!list) return null
function startRename() {
setMenuOpen(false)
setRenameValue(list.name)
setRenaming(true)
}
function commitRename(value) {
const next = (value ?? '').trim()
if (next && next !== list.name) {
actions.renameList(listId, next)
}
setRenaming(false)
}
function onDelete() {
setMenuOpen(false)
// eslint-disable-next-line no-alert
const ok = window.confirm(`Delete list "${list.name}" and its ${list.cardIds.length} card(s)?`)
if (ok) {
actions.deleteList(listId)
}
}
if (isRenaming) {
return (
<div className="list-header list-header--renaming" data-testid="list-header">
<input
ref={inputRef}
className="list-header__rename-input"
type="text"
value={renameValue}
aria-label="Rename list"
data-testid="list-rename-input"
onChange={(e) => setRenameValue(e.target.value)}
onBlur={(e) => commitRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitRename(e.target.value)
} else if (e.key === 'Escape') {
e.preventDefault()
setRenaming(false)
}
}}
/>
</div>
)
}
return (
<div className="list-header" data-testid="list-header">
<button
type="button"
className="list-header__name"
onClick={() => setRenaming(true)}
aria-label={list.name}
>
{list.name}
</button>
<div className="list-header__menu-wrap" ref={menuRef}>
<button
type="button"
className="list-header__menu-btn"
aria-label="List actions"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
</button>
{menuOpen && (
<ul className="list-header__menu" role="menu">
<li role="none">
<button
type="button"
role="menuitem"
className="list-header__menu-item"
onClick={startRename}
>
Rename list
</button>
</li>
<li role="none">
<button
type="button"
role="menuitem"
className="list-header__menu-item list-header__menu-item--danger"
onClick={onDelete}
>
Delete list
</button>
</li>
</ul>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ListHeader from './ListHeader.jsx'
import { renderWithStore } from '../test/testUtils.jsx'
function makeInitialWithCards() {
const boardId = 'b1'
const listId = 'l1'
const c1 = 'c1'
const c2 = 'c2'
return {
initial: {
schemaVersion: 2,
activeBoardId: boardId,
boardOrder: [boardId],
boards: { [boardId]: { id: boardId, name: 'B', listIds: [listId] } },
lists: {
[listId]: { id: listId, boardId, name: 'Backlog', cardIds: [c1, c2] },
},
cards: {
[c1]: { id: c1, boardId, listId, title: 'c1' },
[c2]: { id: c2, boardId, listId, title: 'c2' },
},
},
listId,
cardIds: [c1, c2],
}
}
describe('<ListHeader />', () => {
test('clicking the name reveals a rename input prefilled with current name', async () => {
const user = userEvent.setup()
const { initial, listId } = makeInitialWithCards()
renderWithStore(<ListHeader listId={listId} />, { initial })
await user.click(screen.getByRole('button', { name: 'Backlog' }))
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Backlog')
expect(input).toHaveFocus()
})
test('typing + blur renames the list in the store', async () => {
const user = userEvent.setup()
const { initial, listId } = makeInitialWithCards()
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
await user.click(screen.getByRole('button', { name: 'Backlog' }))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Renamed')
// Tab away to trigger blur.
await user.tab()
expect(result.state.lists[listId].name).toBe('Renamed')
})
test('Enter saves, Escape cancels the rename', async () => {
const user = userEvent.setup()
const { initial, listId } = makeInitialWithCards()
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
// Enter saves
await user.click(screen.getByRole('button', { name: 'Backlog' }))
let input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Enter-name{enter}')
expect(result.state.lists[listId].name).toBe('Enter-name')
// Escape cancels
await user.click(screen.getByRole('button', { name: 'Enter-name' }))
input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'should-not-stick{escape}')
expect(result.state.lists[listId].name).toBe('Enter-name')
})
test('menu: Delete confirms via window.confirm and removes the list + cascades cards', async () => {
const user = userEvent.setup()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
const { initial, listId, cardIds } = makeInitialWithCards()
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
await user.click(screen.getByRole('button', { name: /list actions/i }))
await user.click(screen.getByRole('menuitem', { name: /delete list/i }))
expect(confirmSpy).toHaveBeenCalled()
expect(result.state.lists[listId]).toBeUndefined()
cardIds.forEach((cid) => expect(result.state.cards[cid]).toBeUndefined())
confirmSpy.mockRestore()
})
test('menu: Delete with cancel does not remove the list', async () => {
const user = userEvent.setup()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false)
const { initial, listId } = makeInitialWithCards()
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
await user.click(screen.getByRole('button', { name: /list actions/i }))
await user.click(screen.getByRole('menuitem', { name: /delete list/i }))
expect(result.state.lists[listId]).toBeDefined()
confirmSpy.mockRestore()
})
test('menu: Rename switches to the rename input', async () => {
const user = userEvent.setup()
const { initial, listId } = makeInitialWithCards()
renderWithStore(<ListHeader listId={listId} />, { initial })
await user.click(screen.getByRole('button', { name: /list actions/i }))
await user.click(screen.getByRole('menuitem', { name: /rename list/i }))
expect(screen.getByRole('textbox')).toHaveValue('Backlog')
})
})

283
src/components/Sidebar.css Normal file
View File

@@ -0,0 +1,283 @@
.sidebar {
width: 240px;
flex-shrink: 0;
background: #1d2125;
color: #b6c2cf;
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
padding: 0.75rem 0.5rem;
}
.sidebar__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem 0.75rem;
}
.sidebar__hamburger {
background: transparent;
border: none;
color: #b6c2cf;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.sidebar__hamburger:hover {
background: rgba(255, 255, 255, 0.08);
}
.sidebar__title {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
margin: 0;
color: #b6c2cf;
flex: 1;
}
.sidebar__create-existing,
.sidebar__first-run {
padding: 0.25rem 0.25rem 0.75rem;
}
.sidebar__create-button,
.sidebar__first-run-button {
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #b6c2cf;
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.sidebar__create-button:hover,
.sidebar__first-run-button:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.sidebar__first-run-button {
background: #0079bf;
color: #fff;
font-weight: 600;
text-align: center;
}
.sidebar__first-run-button:hover {
background: #026aa7;
color: #fff;
}
.sidebar__create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar__create-input {
background: #2c333a;
border: 1px solid #4a5562;
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
outline: none;
}
.sidebar__create-input:focus {
border-color: #0079bf;
box-shadow: 0 0 0 2px rgba(0, 121, 191, 0.4);
}
.sidebar__create-actions {
display: flex;
gap: 0.5rem;
}
.sidebar__create-submit {
background: #0079bf;
color: #fff;
border: none;
padding: 0.4rem 0.9rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.sidebar__create-submit:hover {
background: #026aa7;
}
.sidebar__create-cancel {
background: transparent;
color: #b6c2cf;
border: none;
padding: 0.4rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.sidebar__create-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.sidebar__list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.board-row {
position: relative;
display: flex;
align-items: center;
border-radius: 4px;
}
.board-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.board-row__button {
flex: 1;
text-align: left;
background: transparent;
border: none;
color: #b6c2cf;
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
min-width: 0;
}
.board-row__button:hover {
color: #fff;
}
.board-row__button.is-active {
background: #1e3a5f;
color: #fff;
font-weight: 600;
}
.board-row__name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-row__rename-input {
flex: 1;
background: #2c333a;
border: 1px solid #4a5562;
color: #fff;
padding: 0.4rem 0.6rem;
border-radius: 4px;
font-size: 0.9rem;
outline: none;
margin: 0.25rem 0;
}
.board-row__rename-input:focus {
border-color: #0079bf;
box-shadow: 0 0 0 2px rgba(0, 121, 191, 0.4);
}
.board-row__menu-button {
background: transparent;
border: none;
color: #b6c2cf;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
opacity: 0;
margin-right: 0.25rem;
}
.board-row:hover .board-row__menu-button,
.board-row__menu-button:focus {
opacity: 1;
}
.board-row__menu-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.board-row__menu {
position: absolute;
top: calc(100% - 0.25rem);
right: 0.5rem;
background: #2c333a;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 100;
min-width: 140px;
padding: 0.25rem 0;
}
.board-row__menu-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #b6c2cf;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-size: 0.85rem;
}
.board-row__menu-item:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.board-row__menu-item--danger {
color: #e87479;
}
.board-row__menu-item--danger:hover {
background: rgba(232, 116, 121, 0.1);
color: #ff7575;
}
.sidebar__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
/* Mobile (≤ 768px): sidebar collapses into an overlay */
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
transform: translateX(-100%);
transition: transform 0.18s ease-out;
z-index: 250;
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.4);
}
.sidebar--open {
transform: translateX(0);
}
}

306
src/components/Sidebar.jsx Normal file
View File

@@ -0,0 +1,306 @@
import { useState, useEffect, useRef } from 'react';
import './Sidebar.css';
function BoardListItem({ board, isActive, onSelect, onRename, onDelete, onMobileClose }) {
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(board.name);
const renameInputRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (renaming && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renaming]);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [menuOpen]);
const handleRenameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = draftName.trim();
if (trimmed && trimmed !== board.name) {
onRename(board.id, trimmed);
}
setRenaming(false);
setMenuOpen(false);
} else if (e.key === 'Escape') {
e.preventDefault();
setDraftName(board.name);
setRenaming(false);
setMenuOpen(false);
}
};
const handleRenameBlur = () => {
const trimmed = draftName.trim();
if (trimmed && trimmed !== board.name) {
onRename(board.id, trimmed);
}
setRenaming(false);
};
const handleDelete = () => {
setMenuOpen(false);
if (window.confirm(`Delete board "${board.name}"? This will remove all its lists and cards.`)) {
onDelete(board.id);
}
};
const handleSelect = () => {
onSelect(board.id);
if (onMobileClose) onMobileClose();
};
return (
<div className="board-row" data-testid="board-row">
{renaming ? (
<input
ref={renameInputRef}
type="text"
className="board-row__rename-input"
aria-label="Board name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameBlur}
onClick={(e) => e.stopPropagation()}
/>
) : (
<button
type="button"
className={`board-row__button ${isActive ? 'is-active' : ''}`}
aria-current={isActive ? 'true' : 'false'}
onClick={handleSelect}
>
<span className="board-row__name">{board.name}</span>
</button>
)}
<button
type="button"
className="board-row__menu-button"
aria-label={`Board menu for ${board.name}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
>
</button>
{menuOpen && (
<div ref={menuRef} className="board-row__menu" role="menu">
<button
type="button"
role="menuitem"
className="board-row__menu-item"
onClick={() => {
setRenaming(true);
setMenuOpen(false);
}}
>
Rename
</button>
<button
type="button"
role="menuitem"
className="board-row__menu-item board-row__menu-item--danger"
onClick={handleDelete}
>
Delete
</button>
</div>
)}
</div>
);
}
export default function Sidebar({
boards,
boardOrder,
activeBoardId,
isMobileOpen = false,
showHamburger = false,
onSelectBoard,
onAddBoard,
onRenameBoard,
onDeleteBoard,
onMobileToggle,
onMobileClose,
}) {
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newInputRef = useRef(null);
const isFirstRun = boardOrder.length === 0;
useEffect(() => {
if (creating && newInputRef.current) {
newInputRef.current.focus();
}
}, [creating]);
const handleSubmitCreate = (e) => {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) return;
const newId = onAddBoard(trimmed);
if (newId) {
onSelectBoard(newId);
}
setNewName('');
setCreating(false);
};
const handleCancelCreate = () => {
setNewName('');
setCreating(false);
};
const asideClasses = [
'sidebar',
isMobileOpen ? 'sidebar--open' : '',
isFirstRun ? 'sidebar--first-run' : '',
]
.filter(Boolean)
.join(' ');
return (
<>
{isMobileOpen && (
<div
className="sidebar__backdrop"
onClick={onMobileClose}
aria-hidden="true"
/>
)}
<aside className={asideClasses} aria-label="Boards sidebar">
<div className="sidebar__header">
{showHamburger && (
<button
type="button"
className="sidebar__hamburger"
aria-label="Toggle sidebar"
onClick={onMobileToggle}
>
</button>
)}
<h2 className="sidebar__title">
<span aria-hidden="true"></span> Boards
</h2>
</div>
{!isFirstRun && (
<div className="sidebar__create-existing">
{creating ? (
<form className="sidebar__create-form" onSubmit={handleSubmitCreate}>
<input
ref={newInputRef}
type="text"
className="sidebar__create-input"
aria-label="Board name"
placeholder="Board name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') handleCancelCreate();
}}
/>
<div className="sidebar__create-actions">
<button type="submit" className="sidebar__create-submit">
Create
</button>
<button
type="button"
className="sidebar__create-cancel"
onClick={handleCancelCreate}
>
Cancel
</button>
</div>
</form>
) : (
<button
type="button"
className="sidebar__create-button"
onClick={() => setCreating(true)}
>
+ Create board
</button>
)}
</div>
)}
<nav className="sidebar__list" aria-label="Boards">
{isFirstRun ? (
<div className="sidebar__first-run">
<button
type="button"
className="sidebar__first-run-button"
onClick={() => setCreating(true)}
>
+ Create your first board
</button>
{creating && (
<form className="sidebar__create-form" onSubmit={handleSubmitCreate}>
<input
ref={newInputRef}
type="text"
className="sidebar__create-input"
aria-label="Board name"
placeholder="Board name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') handleCancelCreate();
}}
/>
<div className="sidebar__create-actions">
<button type="submit" className="sidebar__create-submit">
Create
</button>
<button
type="button"
className="sidebar__create-cancel"
onClick={handleCancelCreate}
>
Cancel
</button>
</div>
</form>
)}
</div>
) : (
boardOrder.map((id) => {
const board = boards[id];
if (!board) return null;
return (
<BoardListItem
key={id}
board={board}
isActive={board.id === activeBoardId}
onSelect={onSelectBoard}
onRename={onRenameBoard}
onDelete={onDeleteBoard}
onMobileClose={onMobileClose}
/>
);
})
)}
</nav>
</aside>
</>
);
}

View File

@@ -0,0 +1,187 @@
import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Sidebar from './Sidebar';
function makeProps(overrides = {}) {
const boards = {
b1: { id: 'b1', name: 'Personal' },
b2: { id: 'b2', name: 'Work' },
};
const boardOrder = ['b1', 'b2'];
const props = {
boards,
boardOrder,
activeBoardId: 'b1',
isMobileOpen: false,
onSelectBoard: jest.fn(),
onAddBoard: jest.fn(() => 'b-new'),
onRenameBoard: jest.fn(),
onDeleteBoard: jest.fn(),
onMobileToggle: jest.fn(),
onMobileClose: jest.fn(),
...overrides,
};
return props;
}
describe('<Sidebar />', () => {
test('renders all boards from store as buttons', () => {
render(<Sidebar {...makeProps()} />);
expect(screen.getByRole('button', { name: 'Personal' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Work' })).toBeInTheDocument();
});
test('marks active board with aria-current="page"', () => {
render(<Sidebar {...makeProps()} />);
expect(screen.getByRole('button', { name: 'Personal' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('button', { name: 'Work' })).toHaveAttribute('aria-current', 'false');
});
test('clicking a board calls onSelectBoard with the board id', async () => {
const user = userEvent.setup();
const props = makeProps();
render(<Sidebar {...props} />);
await user.click(screen.getByRole('button', { name: 'Work' }));
expect(props.onSelectBoard).toHaveBeenCalledWith('b2');
});
test('renders "+ Create board" button in header', () => {
render(<Sidebar {...makeProps()} />);
expect(screen.getByRole('button', { name: /create board/i })).toBeInTheDocument();
});
test('clicking "+ Create board" opens an inline form with input and Create/Cancel buttons', async () => {
const user = userEvent.setup();
render(<Sidebar {...makeProps()} />);
await user.click(screen.getByRole('button', { name: /create board/i }));
expect(screen.getByRole('textbox', { name: /board name/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
test('submitting the create form calls onAddBoard with the entered name and onSelectBoard with the new id', async () => {
const user = userEvent.setup();
const props = makeProps();
render(<Sidebar {...props} />);
await user.click(screen.getByRole('button', { name: /create board/i }));
await user.type(screen.getByRole('textbox', { name: /board name/i }), 'New Board');
await user.click(screen.getByRole('button', { name: /^create$/i }));
expect(props.onAddBoard).toHaveBeenCalledWith('New Board');
expect(props.onSelectBoard).toHaveBeenCalledWith('b-new');
});
test('create form can be cancelled without calling onAddBoard', async () => {
const user = userEvent.setup();
const props = makeProps();
render(<Sidebar {...props} />);
await user.click(screen.getByRole('button', { name: /create board/i }));
await user.type(screen.getByRole('textbox', { name: /board name/i }), 'Will Cancel');
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(props.onAddBoard).not.toHaveBeenCalled();
expect(screen.queryByRole('textbox', { name: /board name/i })).not.toBeInTheDocument();
});
test('hovering a board row reveals a menu button; clicking opens Rename/Delete menu', async () => {
const user = userEvent.setup();
render(<Sidebar {...makeProps()} />);
const row = screen.getByRole('button', { name: 'Personal' }).closest('[data-testid="board-row"]');
const menuButton = within(row).getByRole('button', { name: /board menu/i });
await user.click(menuButton);
expect(within(row).getByRole('menuitem', { name: /rename/i })).toBeInTheDocument();
expect(within(row).getByRole('menuitem', { name: /delete/i })).toBeInTheDocument();
});
test('rename: clicking menu Rename swaps the name with an inline input; Enter saves via onRenameBoard', async () => {
const user = userEvent.setup();
const props = makeProps();
render(<Sidebar {...props} />);
const row = screen.getByRole('button', { name: 'Personal' }).closest('[data-testid="board-row"]');
await user.click(within(row).getByRole('button', { name: /board menu/i }));
await user.click(within(row).getByRole('menuitem', { name: /rename/i }));
const input = within(row).getByRole('textbox', { name: /board name/i });
await user.clear(input);
await user.type(input, 'Personal Updated{enter}');
expect(props.onRenameBoard).toHaveBeenCalledWith('b1', 'Personal Updated');
});
test('rename: Escape cancels without saving', async () => {
const user = userEvent.setup();
const props = makeProps();
render(<Sidebar {...props} />);
const row = screen.getByRole('button', { name: 'Personal' }).closest('[data-testid="board-row"]');
await user.click(within(row).getByRole('button', { name: /board menu/i }));
await user.click(within(row).getByRole('menuitem', { name: /rename/i }));
const input = within(row).getByRole('textbox', { name: /board name/i });
await user.clear(input);
await user.type(input, 'Should Not Save');
fireEvent.keyDown(input, { key: 'Escape' });
expect(props.onRenameBoard).not.toHaveBeenCalled();
});
test('delete: clicking menu Delete then confirming calls onDeleteBoard with the board id', async () => {
const user = userEvent.setup();
const props = makeProps();
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
render(<Sidebar {...props} />);
const row = screen.getByRole('button', { name: 'Work' }).closest('[data-testid="board-row"]');
await user.click(within(row).getByRole('button', { name: /board menu/i }));
await user.click(within(row).getByRole('menuitem', { name: /delete/i }));
expect(confirmSpy).toHaveBeenCalled();
expect(props.onDeleteBoard).toHaveBeenCalledWith('b2');
});
test('delete: confirming with Cancel does NOT call onDeleteBoard', async () => {
const user = userEvent.setup();
const props = makeProps();
jest.spyOn(window, 'confirm').mockReturnValue(false);
render(<Sidebar {...props} />);
const row = screen.getByRole('button', { name: 'Work' }).closest('[data-testid="board-row"]');
await user.click(within(row).getByRole('button', { name: /board menu/i }));
await user.click(within(row).getByRole('menuitem', { name: /delete/i }));
expect(props.onDeleteBoard).not.toHaveBeenCalled();
});
test('when no boards exist, shows "+ Create your first board" button (full-width)', () => {
render(
<Sidebar
{...makeProps({
boards: {},
boardOrder: [],
activeBoardId: null,
})}
/>
);
expect(screen.getByRole('button', { name: /create your first board/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Personal' })).not.toBeInTheDocument();
});
test('mobile: hamburger button is visible when sidebar is collapsed (props.isMobileOpen=false)', () => {
// The hamburger lives in TopBar in this design — but we expose it via a small viewport helper.
// Sidebar accepts a `showHamburger` boolean prop for the test surface.
render(<Sidebar {...makeProps({ showHamburger: true, isMobileOpen: false })} />);
expect(screen.getByRole('button', { name: /toggle sidebar/i })).toBeInTheDocument();
});
test('mobile: clicking the hamburger calls onMobileToggle', async () => {
const user = userEvent.setup();
const props = makeProps({ showHamburger: true });
render(<Sidebar {...props} />);
await user.click(screen.getByRole('button', { name: /toggle sidebar/i }));
expect(props.onMobileToggle).toHaveBeenCalledTimes(1);
});
test('mobile: when isMobileOpen=true, sidebar gets the "open" class', () => {
const { container } = render(<Sidebar {...makeProps({ isMobileOpen: true })} />);
const aside = container.querySelector('aside');
expect(aside.className).toMatch(/open/);
});
test('mobile: clicking a board calls onMobileClose so the overlay closes', async () => {
const user = userEvent.setup();
const props = makeProps({ isMobileOpen: true });
render(<Sidebar {...props} />);
await user.click(screen.getByRole('button', { name: 'Work' }));
expect(props.onSelectBoard).toHaveBeenCalledWith('b2');
expect(props.onMobileClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,49 +0,0 @@
.todo-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.todo-input {
flex: 1;
padding: 0.6rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.15s;
}
.todo-input:focus {
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.15);
}
.date-input {
padding: 0.6rem 0.5rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
color: #555;
transition: border-color 0.15s;
}
.date-input:focus {
border-color: #4a6cf7;
}
.add-btn {
padding: 0.6rem 1.25rem;
background: #4a6cf7;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.15s;
}
.add-btn:hover {
background: #3a5ce5;
}

View File

@@ -1,37 +0,0 @@
import { useState } from 'react'
import './TodoForm.css'
function TodoForm({ onAdd }) {
const [text, setText] = useState('')
const [dueDate, setDueDate] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (!trimmed) return
onAdd(trimmed, dueDate || null)
setText('')
setDueDate('')
}
return (
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
className="todo-input"
placeholder="What needs to be done?"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<input
type="date"
className="date-input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
<button type="submit" className="add-btn">Add</button>
</form>
)
}
export default TodoForm

View File

@@ -1,61 +0,0 @@
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e0e0e0;
transition: background 0.15s;
}
.todo-item:hover {
background: #fafafa;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-item.overdue {
background: #fff5f5;
}
.due-date {
font-size: 0.8rem;
color: #999;
}
.due-date.overdue {
color: #e74c3c;
font-weight: 500;
}
.todo-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
flex: 1;
}
.todo-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
accent-color: #4a6cf7;
}
.delete-btn {
background: none;
border: none;
font-size: 1.4rem;
color: #ccc;
cursor: pointer;
padding: 0 0.25rem;
line-height: 1;
transition: color 0.15s;
}
.delete-btn:hover {
color: #e74c3c;
}

View File

@@ -1,30 +0,0 @@
import './TodoItem.css'
function TodoItem({ todo, onToggle, onDelete }) {
const isOverdue = todo.dueDate && !todo.completed && todo.dueDate < new Date().toISOString().slice(0, 10)
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''} ${isOverdue ? 'overdue' : ''}`}>
<label className="todo-label">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className="todo-text">
{todo.text}
{todo.dueDate && (
<span className={`due-date ${isOverdue ? 'overdue' : ''}`}>
&nbsp;📅 {todo.dueDate}
</span>
)}
</span>
</label>
<button className="delete-btn" onClick={() => onDelete(todo.id)} title="Delete">
×
</button>
</li>
)
}
export default TodoItem

View File

@@ -1,16 +0,0 @@
.todo-list {
list-style: none;
padding: 0;
margin: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.empty-state {
text-align: center;
color: #999;
padding: 2rem;
font-style: italic;
}

View File

@@ -1,36 +0,0 @@
import { useMemo } from 'react'
import TodoItem from './TodoItem'
import './TodoList.css'
function TodoList({ todos, onToggle, onDelete, filter }) {
const filtered = useMemo(() => {
if (filter === 'today') {
const today = new Date().toISOString().slice(0, 10)
return todos.filter((t) => t.dueDate === today)
}
return todos
}, [todos, filter])
if (filtered.length === 0) {
const msg =
filter === 'today'
? 'Nothing due today! 🎉'
: 'No todos yet! Add one above.'
return <p className="empty-state">{msg}</p>
}
return (
<ul className="todo-list">
{filtered.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
)
}
export default TodoList

43
src/components/TopBar.css Normal file
View File

@@ -0,0 +1,43 @@
.topbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
min-height: 48px;
flex-shrink: 0;
}
.topbar__hamburger {
background: transparent;
border: none;
color: #fff;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.6rem;
border-radius: 4px;
display: none;
}
.topbar__hamburger:hover {
background: rgba(255, 255, 255, 0.12);
}
.topbar__title {
font-size: 1.05rem;
font-weight: 600;
color: #fff;
margin: 0;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.topbar__hamburger {
display: inline-block;
}
}

24
src/components/TopBar.jsx Normal file
View File

@@ -0,0 +1,24 @@
import './TopBar.css';
export default function TopBar({
boardName,
showHamburger = false,
onToggleSidebar,
}) {
const displayName = boardName || 'Untitled board';
return (
<header className="topbar">
{showHamburger && (
<button
type="button"
className="topbar__hamburger"
aria-label="Toggle sidebar"
onClick={onToggleSidebar}
>
</button>
)}
<h1 className="topbar__title">{displayName}</h1>
</header>
);
}

View File

@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TopBar from './TopBar';
function makeProps(overrides = {}) {
return {
boardName: 'Personal',
showHamburger: false,
onToggleSidebar: jest.fn(),
...overrides,
};
}
describe('<TopBar />', () => {
test('renders the active board name', () => {
render(<TopBar {...makeProps()} />);
expect(screen.getByRole('heading', { name: 'Personal' })).toBeInTheDocument();
});
test('renders a different board name when boardName prop changes', () => {
const { rerender } = render(<TopBar {...makeProps({ boardName: 'Personal' })} />);
rerender(<TopBar {...makeProps({ boardName: 'Work' })} />);
expect(screen.getByRole('heading', { name: 'Work' })).toBeInTheDocument();
});
test('hamburger is hidden when showHamburger is false', () => {
render(<TopBar {...makeProps({ showHamburger: false })} />);
expect(screen.queryByRole('button', { name: /toggle sidebar/i })).not.toBeInTheDocument();
});
test('hamburger is visible when showHamburger is true', () => {
render(<TopBar {...makeProps({ showHamburger: true })} />);
expect(screen.getByRole('button', { name: /toggle sidebar/i })).toBeInTheDocument();
});
test('clicking hamburger calls onToggleSidebar', async () => {
const user = userEvent.setup();
const props = makeProps({ showHamburger: true });
render(<TopBar {...props} />);
await user.click(screen.getByRole('button', { name: /toggle sidebar/i }));
expect(props.onToggleSidebar).toHaveBeenCalledTimes(1);
});
test('falls back to "Untitled board" when boardName is null/undefined', () => {
render(<TopBar {...makeProps({ boardName: null })} />);
expect(screen.getByRole('heading', { name: /untitled/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,262 @@
// Test fixture — a complete DnD-wired board used by P5's component tests.
// Mirrors what P3's Board + List and P4's Card will produce at integration time.
//
// CSS uses positioning so test runs without getBoundingClientRect stubs.
import { useState, useMemo, useCallback, useRef } from 'react';
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
applyMoveCard,
applyMoveList,
cardDndId,
listDndId,
isCardDndId,
isListDndId,
parseCardDndId,
parseListDndId,
} from '../../lib/boardDnd';
// Inline stylesheet: positions items in a deterministic grid so tests
// can target known coordinates without stubbing getBoundingClientRect.
const fixtureStyles = `
.fixture-board { position: relative; padding: 8px; }
.fixture-board__row { display: flex; gap: 12px; align-items: flex-start; }
.fixture-list { background: #ebecf0; border-radius: 8px; padding: 8px; width: 240px; flex-shrink: 0; }
.fixture-list__header { padding: 6px 8px; font-weight: bold; }
.fixture-list__body { min-height: 60px; padding: 4px; }
.fixture-card { background: #fff; box-shadow: 0 1px 0 rgba(9,30,66,.25); border-radius: 3px; padding: 8px 12px; margin-bottom: 6px; cursor: pointer; }
`;
// Inject styles once
if (typeof document !== 'undefined' && !document.getElementById('fixture-board-styles')) {
const tag = document.createElement('style');
tag.id = 'fixture-board-styles';
tag.textContent = fixtureStyles;
document.head.appendChild(tag);
}
function FixtureCard({ cardId, title }) {
const {
setNodeRef,
listeners,
attributes,
transform,
transition,
isDragging,
} = useSortable({ id: cardDndId(cardId) });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
data-testid={`card-${cardId}`}
data-card-id={cardId}
className="fixture-card"
{...attributes}
{...listeners}
>
{title}
</div>
);
}
function FixtureList({ listId, name, cards }) {
const cardIds = useMemo(() => cards.map((c) => c.id), [cards]);
const {
setNodeRef,
listeners,
attributes,
transform,
transition,
isDragging,
} = useSortable({ id: listDndId(listId) });
const listStyle = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div
data-testid={`list-${listId}`}
data-list-id={listId}
className="fixture-list"
style={listStyle}
>
<div data-testid={`list-header-${listId}`} className="fixture-list__header">{name}</div>
<div
ref={setNodeRef}
data-testid={`list-body-${listId}`}
className="fixture-list__body"
{...attributes}
{...listeners}
>
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
{cards.map((c) => (
<FixtureCard key={c.id} cardId={c.id} title={c.title} />
))}
</SortableContext>
</div>
</div>
);
}
export function FixtureBoard({ initialState, onStateChange }) {
const [state, setState] = useState(initialState);
const lastOverRef = useRef(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const update = useCallback(
(next) => {
setState(next);
onStateChange?.(next);
},
[onStateChange]
);
function handleDragOver(event) {
const { active, over } = event;
if (typeof window !== 'undefined' && window.__dndDebug) {
console.log('onDragOver: active.id=', active.id, 'over.id=', over?.id, 'state.cards.c1.listId=', state.cards.c1?.listId);
}
if (!over) return;
const activeCardId = isCardDndId(active.id) ? parseCardDndId(active.id) : null;
if (!activeCardId) return;
let overListId = null;
if (isListDndId(over.id)) {
overListId = parseListDndId(over.id);
} else if (isCardDndId(over.id)) {
const overCardId = parseCardDndId(over.id);
overListId = state.cards[overCardId]?.listId ?? null;
}
if (!overListId) return;
const fromListId = state.cards[activeCardId]?.listId;
if (!fromListId || fromListId === overListId) return;
const key = `${activeCardId}->${overListId}`;
if (lastOverRef.current === key) return;
lastOverRef.current = key;
const targetLen = state.lists[overListId].cardIds.length;
const next = applyMoveCard(state, {
cardId: activeCardId,
toListId: overListId,
toIndex: targetLen,
});
update(next);
}
function handleDragEnd(event) {
const { active, over } = event;
lastOverRef.current = null;
if (!over) return;
// LIST reorder — over can be either another list-id or a card-id (whose
// parent list is the drop target).
if (isListDndId(active.id)) {
const activeListId = parseListDndId(active.id);
const boardId = Object.keys(state.boards)[0];
if (!boardId) return;
const board = state.boards[boardId];
let overListId = null;
if (isListDndId(over.id)) {
overListId = parseListDndId(over.id);
} else if (isCardDndId(over.id)) {
// Card belongs to some list — that's the target list.
const overCardId = parseCardDndId(over.id);
overListId = state.cards[overCardId]?.listId ?? null;
}
if (!overListId || activeListId === overListId) return;
const fromIndex = board.listIds.indexOf(activeListId);
const toIndex = board.listIds.indexOf(overListId);
const next = applyMoveList(state, { boardId, fromIndex, toIndex });
update(next);
return;
}
if (isCardDndId(active.id)) {
const activeCardId = parseCardDndId(active.id);
let toListId = null;
let toIndex = 0;
if (isCardDndId(over.id)) {
const overCardId = parseCardDndId(over.id);
toListId = state.cards[overCardId]?.listId;
if (!toListId) return;
toIndex = state.lists[toListId].cardIds.indexOf(overCardId);
} else if (isListDndId(over.id)) {
toListId = parseListDndId(over.id);
toIndex = state.lists[toListId]?.cardIds.length ?? 0;
} else {
return;
}
const next = applyMoveCard(state, {
cardId: activeCardId,
toListId,
toIndex,
});
update(next);
}
}
const boardId = state.activeBoardId ?? Object.keys(state.boards)[0];
const board = state.boards[boardId];
const lists = board.listIds.map((id) => state.lists[id]);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={board.listIds.map(listDndId)}
strategy={horizontalListSortingStrategy}
>
<div data-testid="board" className="fixture-board">
<div className="fixture-board__row">
{lists.map((list) => (
<FixtureList
key={list.id}
listId={list.id}
name={list.name}
cards={list.cardIds.map((cid) => state.cards[cid])}
/>
))}
</div>
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -1,22 +0,0 @@
import { useState, useCallback } from 'react'
export function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})
const setValue = useCallback((value) => {
setStoredValue((prev) => {
const nextValue = typeof value === 'function' ? value(prev) : value
window.localStorage.setItem(key, JSON.stringify(nextValue))
return nextValue
})
}, [key])
return [storedValue, setValue]
}

View File

@@ -1,16 +1,48 @@
/* Global resets and base styles for the redesign.
* Component-specific styles live in each component's .css file.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
}
#root {
min-height: 100vh;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
color: #172b4d;
background: #0079bf;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid #0079bf;
outline-offset: 2px;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}

116
src/lib/boardDnd.js Normal file
View File

@@ -0,0 +1,116 @@
// Pure reducer for DnD ops on the normalized board state.
// Lives in src/lib/ so it's testable without React.
import { arrayMove } from '@dnd-kit/sortable';
/**
* Move a card to a different list and/or position within a list.
* Returns a NEW state object (immutable).
*/
export function applyMoveCard(state, { cardId, toListId, toIndex }) {
if (!state || !state.cards || !state.cards[cardId] || !state.lists[toListId]) {
return state;
}
const card = state.cards[cardId];
const fromListId = card.listId;
const toList = state.lists[toListId];
if (fromListId === toListId) {
const oldIndex = toList.cardIds.indexOf(cardId);
if (oldIndex === -1) return state;
if (oldIndex === toIndex) return state;
return {
...state,
lists: {
...state.lists,
[toListId]: { ...toList, cardIds: arrayMove(toList.cardIds, oldIndex, toIndex) },
},
};
}
const fromList = state.lists[fromListId];
const newFromCardIds = fromList.cardIds.filter((id) => id !== cardId);
const clampedIndex = Math.max(0, Math.min(toIndex, toList.cardIds.length));
const newToCardIds = [
...toList.cardIds.slice(0, clampedIndex),
cardId,
...toList.cardIds.slice(clampedIndex),
];
return {
...state,
cards: {
...state.cards,
[cardId]: { ...card, listId: toListId },
},
lists: {
...state.lists,
[fromListId]: { ...fromList, cardIds: newFromCardIds },
[toListId]: { ...toList, cardIds: newToCardIds },
},
};
}
/**
* Reorder lists within a board.
*/
export function applyMoveList(state, { boardId, fromIndex, toIndex }) {
if (!state || !state.boards || !state.boards[boardId]) return state;
const board = state.boards[boardId];
if (fromIndex < 0 || fromIndex >= board.listIds.length) return state;
if (toIndex < 0 || toIndex >= board.listIds.length) return state;
if (fromIndex === toIndex) return state;
return {
...state,
boards: {
...state.boards,
[boardId]: {
...board,
listIds: arrayMove(board.listIds, fromIndex, toIndex),
},
},
};
}
/**
* Return the listId a card currently belongs to, or null.
*/
export function getCardListId(state, cardId) {
return state?.cards?.[cardId]?.listId ?? null;
}
/**
* Build a Map<cardId, listId> for fast lookup during onDragOver.
*/
export function buildCardListMap(state) {
const m = new Map();
if (!state || !state.cards) return m;
for (const cardId in state.cards) {
m.set(cardId, state.cards[cardId].listId);
}
return m;
}
// ID helpers — DnD items use prefixed ids to disambiguate card vs list.
export function cardDndId(cardId) {
return `card-${cardId}`;
}
export function listDndId(listId) {
return `list-${listId}`;
}
export function isCardDndId(id) {
return typeof id === 'string' && id.startsWith('card-');
}
export function isListDndId(id) {
return typeof id === 'string' && id.startsWith('list-');
}
export function parseCardDndId(id) {
return isCardDndId(id) ? id.slice('card-'.length) : null;
}
export function parseListDndId(id) {
return isListDndId(id) ? id.slice('list-'.length) : null;
}

169
src/lib/boardDnd.test.js Normal file
View File

@@ -0,0 +1,169 @@
// Unit tests for the pure DnD reducer.
// Per TDD: written before the source. Tests cover:
// - same-list card reorder via arrayMove
// - cross-list card move (atomic remove from source + insert at target index)
// - cross-list card move with clamped index (out-of-range toIndex is clamped)
// - list reorder within a board
// - malformed input returns state unchanged
// - ID helpers (cardDndId/listDndId/parse*/is*)
// - getCardListId / buildCardListMap
import {
applyMoveCard,
applyMoveList,
getCardListId,
buildCardListMap,
cardDndId,
listDndId,
isCardDndId,
isListDndId,
parseCardDndId,
parseListDndId,
} from './boardDnd';
// Fixture: 1 board, 2 lists, 4 cards. Cards c1,c2 in l1; c3,c4 in l2.
const makeState = () => ({
schemaVersion: 2,
activeBoardId: 'b1',
boards: {
b1: { id: 'b1', name: 'My Board', listIds: ['l1', 'l2'] },
},
boardOrder: ['b1'],
lists: {
l1: { id: 'l1', boardId: 'b1', name: 'Todo', cardIds: ['c1', 'c2'] },
l2: { id: 'l2', boardId: 'b1', name: 'Doing', cardIds: ['c3', 'c4'] },
},
cards: {
c1: { id: 'c1', boardId: 'b1', listId: 'l1', title: 'A' },
c2: { id: 'c2', boardId: 'b1', listId: 'l1', title: 'B' },
c3: { id: 'c3', boardId: 'b1', listId: 'l2', title: 'C' },
c4: { id: 'c4', boardId: 'b1', listId: 'l2', title: 'D' },
},
});
describe('applyMoveCard — same list', () => {
test('reorders cards within a list', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l1', toIndex: 1 });
expect(next.lists.l1.cardIds).toEqual(['c2', 'c1']);
// original state unchanged
expect(s.lists.l1.cardIds).toEqual(['c1', 'c2']);
});
test('reorder with same source/target index is a no-op (returns new ref but same order)', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l1', toIndex: 0 });
expect(next.lists.l1.cardIds).toEqual(['c1', 'c2']);
});
});
describe('applyMoveCard — cross list', () => {
test('moves a card to another list and inserts at given index', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l2', toIndex: 1 });
expect(next.lists.l1.cardIds).toEqual(['c2']);
expect(next.lists.l2.cardIds).toEqual(['c3', 'c1', 'c4']);
expect(next.cards.c1.listId).toBe('l2');
// original state unchanged
expect(s.cards.c1.listId).toBe('l1');
expect(s.lists.l2.cardIds).toEqual(['c3', 'c4']);
});
test('moves a card to end of another list when index equals length', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l2', toIndex: 2 });
expect(next.lists.l2.cardIds).toEqual(['c3', 'c4', 'c1']);
expect(next.lists.l1.cardIds).toEqual(['c2']);
});
test('clamps out-of-range toIndex to the target list length', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l2', toIndex: 99 });
expect(next.lists.l2.cardIds).toEqual(['c3', 'c4', 'c1']);
});
test('clamps negative toIndex to 0', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'l2', toIndex: -5 });
expect(next.lists.l2.cardIds).toEqual(['c1', 'c3', 'c4']);
});
});
describe('applyMoveCard — error / no-op cases', () => {
test('unknown cardId returns state unchanged', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'nope', toListId: 'l2', toIndex: 0 });
expect(next).toBe(s);
});
test('unknown toListId returns state unchanged', () => {
const s = makeState();
const next = applyMoveCard(s, { cardId: 'c1', toListId: 'nope', toIndex: 0 });
expect(next).toBe(s);
});
test('null state returns null', () => {
expect(applyMoveCard(null, { cardId: 'c1', toListId: 'l1', toIndex: 0 })).toBeNull();
});
});
describe('applyMoveList', () => {
test('reorders lists within a board', () => {
const s = makeState();
const next = applyMoveList(s, { boardId: 'b1', fromIndex: 0, toIndex: 1 });
expect(next.boards.b1.listIds).toEqual(['l2', 'l1']);
// original unchanged
expect(s.boards.b1.listIds).toEqual(['l1', 'l2']);
});
test('same index returns state (different ref but unchanged order)', () => {
const s = makeState();
const next = applyMoveList(s, { boardId: 'b1', fromIndex: 1, toIndex: 1 });
expect(next.boards.b1.listIds).toEqual(['l1', 'l2']);
});
test('unknown boardId returns state unchanged', () => {
const s = makeState();
const next = applyMoveList(s, { boardId: 'nope', fromIndex: 0, toIndex: 1 });
expect(next).toBe(s);
});
test('out-of-range index returns state unchanged', () => {
const s = makeState();
const next = applyMoveList(s, { boardId: 'b1', fromIndex: 0, toIndex: 99 });
expect(next).toBe(s);
});
});
describe('helpers', () => {
test('getCardListId returns card listId', () => {
expect(getCardListId(makeState(), 'c1')).toBe('l1');
expect(getCardListId(makeState(), 'c3')).toBe('l2');
expect(getCardListId(makeState(), 'nope')).toBeNull();
});
test('buildCardListMap returns cardId -> listId map', () => {
const m = buildCardListMap(makeState());
expect(m.size).toBe(4);
expect(m.get('c1')).toBe('l1');
expect(m.get('c4')).toBe('l2');
});
test('ID helpers round-trip', () => {
expect(cardDndId('c1')).toBe('card-c1');
expect(listDndId('l1')).toBe('list-l1');
expect(parseCardDndId('card-c1')).toBe('c1');
expect(parseListDndId('list-l1')).toBe('l1');
expect(parseCardDndId('list-l1')).toBeNull();
expect(parseListDndId('card-c1')).toBeNull();
});
test('ID type predicates', () => {
expect(isCardDndId('card-c1')).toBe(true);
expect(isCardDndId('list-l1')).toBe(false);
expect(isListDndId('list-l1')).toBe(true);
expect(isListDndId('card-c1')).toBe(false);
expect(isCardDndId(null)).toBe(false);
expect(isCardDndId(undefined)).toBe(false);
});
});

106
src/lib/migrate.js Normal file
View File

@@ -0,0 +1,106 @@
// One-shot migration from v1 (flat array of todos under "ultra-todo-items")
// to v2 (normalized boards/lists/cards under "ultra-todo-v2-state").
//
// v1 shape: Array<{ id, text, completed, dueDate }>
// v2 shape:
// {
// schemaVersion: 2,
// activeBoardId: string,
// boards: { [id]: { id, name, listIds: [listId,...] } },
// boardOrder: [boardId,...],
// lists: { [id]: { id, boardId, name, cardIds: [cardId,...] } },
// cards: { [id]: { id, boardId, listId, title, description, dueDate, createdAt } },
// }
//
// Migration runs synchronously and is idempotent: if the v2 key already holds
// a valid v2 state, that state is returned unchanged. The v1 key is never
// deleted so the user can roll back.
export const V1_STORAGE_KEY = 'ultra-todo-items';
export const V2_STORAGE_KEY = 'ultra-todo-v2-state';
const BOARD_NAME = 'Inbox';
const LIST_NAME = 'Todo';
function makeId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function readJSON(key) {
const raw = window.localStorage.getItem(key);
if (raw == null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function writeJSON(key, value) {
window.localStorage.setItem(key, JSON.stringify(value));
}
function emptyState() {
const boardId = makeId();
const listId = makeId();
return {
schemaVersion: 2,
activeBoardId: boardId,
boards: {
[boardId]: { id: boardId, name: BOARD_NAME, listIds: [listId] },
},
boardOrder: [boardId],
lists: {
[listId]: { id: listId, boardId, name: LIST_NAME, cardIds: [] },
},
cards: {},
};
}
function migrateFromV1(v1Array) {
const state = emptyState();
const boardId = state.boardOrder[0];
const listId = state.boards[boardId].listIds[0];
const now = Date.now();
for (const item of v1Array) {
if (!item || typeof item.id !== 'string') continue;
const card = {
id: item.id,
boardId,
listId,
title: typeof item.text === 'string' ? item.text : '',
description: '',
dueDate: item.dueDate ?? null,
createdAt: now,
};
state.cards[item.id] = card;
state.lists[listId].cardIds.push(item.id);
}
return state;
}
export function migrate() {
const existing = readJSON(V2_STORAGE_KEY);
if (existing && existing.schemaVersion === 2) {
return existing;
}
const v1Raw = window.localStorage.getItem(V1_STORAGE_KEY);
const v1 = v1Raw == null ? null : readJSON(V1_STORAGE_KEY);
let state;
if (Array.isArray(v1) && v1.length > 0) {
state = migrateFromV1(v1);
} else {
state = emptyState();
}
writeJSON(V2_STORAGE_KEY, state);
// v1 key is intentionally left intact for rollback safety.
return state;
}

118
src/lib/migrate.test.js Normal file
View File

@@ -0,0 +1,118 @@
import { migrate, V2_STORAGE_KEY, V1_STORAGE_KEY } from './migrate';
// jsdom provides window.localStorage, but jest isolates it per test by default.
// We clear it explicitly between tests so migration behavior is deterministic.
beforeEach(() => {
window.localStorage.clear();
});
describe('migrate()', () => {
test('returns the v2 state untouched when schemaVersion is already 2', () => {
const existing = {
schemaVersion: 2,
activeBoardId: 'b1',
boards: { b1: { id: 'b1', name: 'Personal', listIds: [] } },
boardOrder: ['b1'],
lists: {},
cards: {},
};
window.localStorage.setItem(V2_STORAGE_KEY, JSON.stringify(existing));
const result = migrate();
expect(result).toEqual(existing);
// v2 key is preserved
expect(JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY))).toEqual(existing);
});
test('creates a fresh empty Inbox board when neither v1 nor v2 exists', () => {
const result = migrate();
expect(result.schemaVersion).toBe(2);
expect(result.activeBoardId).toBeTruthy();
expect(result.boardOrder).toHaveLength(1);
const boardId = result.boardOrder[0];
expect(result.boards[boardId].name).toBe('Inbox');
// One default "Todo" list inside the inbox board
expect(result.boards[boardId].listIds).toHaveLength(1);
const listId = result.boards[boardId].listIds[0];
expect(result.lists[listId].name).toBe('Todo');
expect(result.lists[listId].boardId).toBe(boardId);
expect(result.lists[listId].cardIds).toEqual([]);
expect(result.cards).toEqual({});
});
test('persists the fresh v2 state to localStorage under the v2 key', () => {
migrate();
const stored = JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY));
expect(stored.schemaVersion).toBe(2);
expect(stored.boardOrder).toHaveLength(1);
});
test('migrates v1 array of todos into a single Inbox board with one Todo list', () => {
const v1 = [
{ id: 'a', text: 'Buy milk', completed: false, dueDate: '2026-06-30' },
{ id: 'b', text: 'Walk dog', completed: true, dueDate: null },
{ id: 'c', text: 'Read book', completed: false, dueDate: '2026-07-01' },
];
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
const result = migrate();
expect(result.schemaVersion).toBe(2);
// single board, single list
expect(result.boardOrder).toHaveLength(1);
const boardId = result.boardOrder[0];
expect(result.boards[boardId].name).toBe('Inbox');
expect(result.boards[boardId].listIds).toHaveLength(1);
const listId = result.boards[boardId].listIds[0];
expect(result.lists[listId].name).toBe('Todo');
// three cards preserved in order
expect(result.lists[listId].cardIds).toEqual(['a', 'b', 'c']);
expect(Object.keys(result.cards)).toHaveLength(3);
// text -> title, completed dropped, dueDate preserved
expect(result.cards.a).toMatchObject({
id: 'a',
boardId,
listId,
title: 'Buy milk',
dueDate: '2026-06-30',
});
expect(result.cards.b.title).toBe('Walk dog');
expect(result.cards.b.dueDate).toBeNull();
expect(result.cards.c.title).toBe('Read book');
// createdAt is set on every migrated card
for (const card of Object.values(result.cards)) {
expect(typeof card.createdAt).toBe('number');
expect(card.createdAt).toBeGreaterThan(0);
}
});
test('v1 key is left intact after migration (rollback safety)', () => {
const v1 = [{ id: 'x', text: 'Hello', completed: false, dueDate: null }];
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
migrate();
expect(JSON.parse(window.localStorage.getItem(V1_STORAGE_KEY))).toEqual(v1);
});
test('does not re-migrate when called twice (idempotent)', () => {
const v1 = [{ id: 'a', text: 'one', completed: false, dueDate: null }];
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
const first = migrate();
const second = migrate();
expect(first).toEqual(second);
// v1 still there, v2 unchanged
expect(JSON.parse(window.localStorage.getItem(V1_STORAGE_KEY))).toEqual(v1);
expect(JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY))).toEqual(second);
});
test('handles corrupted v1 JSON gracefully and starts fresh', () => {
window.localStorage.setItem(V1_STORAGE_KEY, 'not-json{');
const result = migrate();
expect(result.schemaVersion).toBe(2);
expect(result.boardOrder).toHaveLength(1);
});
});

3
src/setup.test.js Normal file
View File

@@ -0,0 +1,3 @@
test('jest setup works', () => {
expect(1 + 1).toBe(2);
});

326
src/store/boardStore.js Normal file
View File

@@ -0,0 +1,326 @@
import { useSyncExternalStore, useCallback, useRef } from 'react';
import { migrate, V2_STORAGE_KEY } from '../lib/migrate';
// Module-level singleton state + listeners. All calls to useBoardStore()
// share the same state — actions on one component propagate to all
// subscribers. This is the standard useSyncExternalStore pattern.
let _state = null;
let _persistOnChange = true;
const _listeners = new Set();
function getState() {
if (_state === null) {
_state = migrate();
persist(_state);
}
return _state;
}
function subscribe(listener) {
_listeners.add(listener);
return () => _listeners.delete(listener);
}
function setState(next) {
_state = typeof next === 'function' ? next(_state) : next;
if (_persistOnChange) persist(_state);
_listeners.forEach((l) => l());
}
// Pure action helpers — each returns a new state object. No mutation, no async.
// Keeping them outside the component avoids stale-closure traps with useState's
// functional updater and makes every transition trivially testable in isolation.
function makeId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function persist(state) {
try {
window.localStorage.setItem(V2_STORAGE_KEY, JSON.stringify(state));
} catch {
// localStorage may be unavailable (private mode, quota). Swallow — in-memory
// state remains usable for the session.
}
}
function addBoard(state, name) {
const id = makeId();
const board = { id, name, listIds: [] };
return {
...state,
boards: { ...state.boards, [id]: board },
boardOrder: [...state.boardOrder, id],
};
}
function renameBoard(state, id, name) {
if (!state.boards[id]) return state;
return {
...state,
boards: { ...state.boards, [id]: { ...state.boards[id], name } },
};
}
function deleteBoard(state, id) {
if (!state.boards[id]) return state;
const board = state.boards[id];
const lists = { ...state.lists };
for (const listId of board.listIds) {
delete lists[listId];
}
const cards = { ...state.cards };
for (const card of Object.values(state.cards)) {
if (card.boardId === id) delete cards[card.id];
}
const boards = { ...state.boards };
delete boards[id];
return {
...state,
boards,
lists,
cards,
boardOrder: state.boardOrder.filter((b) => b !== id),
activeBoardId: state.activeBoardId === id ? (state.boardOrder.find((b) => b !== id) ?? null) : state.activeBoardId,
};
}
function setActiveBoard(state, id) {
if (!state.boards[id]) return state;
return { ...state, activeBoardId: id };
}
function addList(state, boardId, name) {
if (!state.boards[boardId]) return state;
const id = makeId();
const list = { id, boardId, name, cardIds: [] };
return {
...state,
lists: { ...state.lists, [id]: list },
boards: {
...state.boards,
[boardId]: {
...state.boards[boardId],
listIds: [...state.boards[boardId].listIds, id],
},
},
};
}
function renameList(state, listId, name) {
if (!state.lists[listId]) return state;
return {
...state,
lists: { ...state.lists, [listId]: { ...state.lists[listId], name } },
};
}
function deleteList(state, listId) {
const list = state.lists[listId];
if (!list) return state;
const board = state.boards[list.boardId];
if (!board) return state;
const cards = { ...state.cards };
for (const cardId of list.cardIds) {
delete cards[cardId];
}
const lists = { ...state.lists };
delete lists[listId];
return {
...state,
lists,
cards,
boards: {
...state.boards,
[board.id]: {
...board,
listIds: board.listIds.filter((l) => l !== listId),
},
},
};
}
function moveList(state, boardId, fromIdx, toIdx) {
const board = state.boards[boardId];
if (!board) return state;
if (fromIdx === toIdx) return state;
if (fromIdx < 0 || fromIdx >= board.listIds.length) return state;
if (toIdx < 0 || toIdx >= board.listIds.length) return state;
const next = board.listIds.slice();
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
return {
...state,
boards: { ...state.boards, [boardId]: { ...board, listIds: next } },
};
}
function addCard(state, listId, { title, description, dueDate } = {}) {
const list = state.lists[listId];
if (!list) return state;
const id = makeId();
const card = {
id,
boardId: list.boardId,
listId,
title: title ?? '',
description: description ?? '',
dueDate: dueDate ?? null,
createdAt: Date.now(),
};
return {
...state,
cards: { ...state.cards, [id]: card },
lists: {
...state.lists,
[listId]: { ...list, cardIds: [...list.cardIds, id] },
},
};
}
function updateCard(state, cardId, patch) {
const card = state.cards[cardId];
if (!card) return state;
return {
...state,
cards: { ...state.cards, [cardId]: { ...card, ...patch } },
};
}
function deleteCard(state, cardId) {
const card = state.cards[cardId];
if (!card) return state;
const list = state.lists[card.listId];
const cards = { ...state.cards };
delete cards[cardId];
if (!list) return { ...state, cards };
return {
...state,
cards,
lists: {
...state.lists,
[list.id]: { ...list, cardIds: list.cardIds.filter((c) => c !== cardId) },
},
};
}
function moveCard(state, cardId, toListId, toIndex) {
const card = state.cards[cardId];
if (!card) return state;
const fromList = state.lists[card.listId];
const toList = state.lists[toListId];
if (!toList) return state;
const nextCards = { ...state.cards };
let nextLists = { ...state.lists };
// Remove from source list (if any)
if (fromList) {
nextLists = {
...nextLists,
[fromList.id]: {
...fromList,
cardIds: fromList.cardIds.filter((c) => c !== cardId),
},
};
}
// Insert into target list at the requested index (clamped)
const targetIds = (nextLists[toListId]?.cardIds ?? toList.cardIds).slice();
const clampedIndex = Math.max(0, Math.min(toIndex, targetIds.length));
targetIds.splice(clampedIndex, 0, cardId);
nextLists = {
...nextLists,
[toListId]: { ...toList, cardIds: targetIds },
};
// Card knows its new home (denormalized for easy cleanup)
nextCards[cardId] = { ...card, listId: toListId, boardId: toList.boardId };
return { ...state, cards: nextCards, lists: nextLists };
}
function reducer(state, action) {
switch (action.type) {
case 'addBoard': return addBoard(state, action.name);
case 'renameBoard': return renameBoard(state, action.id, action.name);
case 'deleteBoard': return deleteBoard(state, action.id);
case 'setActiveBoard': return setActiveBoard(state, action.id);
case 'addList': return addList(state, action.boardId, action.name);
case 'renameList': return renameList(state, action.listId, action.name);
case 'deleteList': return deleteList(state, action.listId);
case 'moveList': return moveList(state, action.boardId, action.fromIdx, action.toIdx);
case 'addCard': return addCard(state, action.listId, action.data);
case 'updateCard': return updateCard(state, action.cardId, action.patch);
case 'deleteCard': return deleteCard(state, action.cardId);
case 'moveCard': return moveCard(state, action.cardId, action.toListId, action.toIndex);
default: return state;
}
}
/**
* Hook — subscribes to the module-level singleton store and returns
* `{ state, actions }`. All callers share the same state (true singleton).
*/
export function useBoardStore() {
const state = useSyncExternalStore(subscribe, getState, getState);
// Lazy-init the actions object so it doesn't churn on every render.
const actionsRef = useRef(null);
if (actionsRef.current === null) {
actionsRef.current = makeActions();
}
return { state, actions: actionsRef.current };
}
function makeActions() {
function dispatch(action) {
setState((prev) => reducer(prev, action));
}
function dispatchAndCaptureId(action) {
let capturedId = null;
setState((prev) => {
const next = reducer(prev, action);
if (action.type === 'addBoard') {
capturedId = next.boardOrder[next.boardOrder.length - 1] ?? null;
} else if (action.type === 'addList') {
const board = next.boards[action.boardId];
capturedId = board ? board.listIds[board.listIds.length - 1] ?? null : null;
} else if (action.type === 'addCard') {
const list = next.lists[action.listId];
capturedId = list ? list.cardIds[list.cardIds.length - 1] ?? null : null;
}
return next;
});
return capturedId;
}
return {
addBoard: (name) => dispatchAndCaptureId({ type: 'addBoard', name }),
renameBoard: (id, name) => dispatch({ type: 'renameBoard', id, name }),
deleteBoard: (id) => dispatch({ type: 'deleteBoard', id }),
setActiveBoard: (id) => dispatch({ type: 'setActiveBoard', id }),
addList: (boardId, name) => dispatchAndCaptureId({ type: 'addList', boardId, name }),
renameList: (listId, name) => dispatch({ type: 'renameList', listId, name }),
deleteList: (listId) => dispatch({ type: 'deleteList', listId }),
moveList: (boardId, fromIdx, toIdx) => dispatch({ type: 'moveList', boardId, fromIdx, toIdx }),
addCard: (listId, data) => dispatchAndCaptureId({ type: 'addCard', listId, data }),
updateCard: (cardId, patch) => dispatch({ type: 'updateCard', cardId, patch }),
deleteCard: (cardId) => dispatch({ type: 'deleteCard', cardId }),
moveCard: (cardId, toListId, toIndex) => dispatch({ type: 'moveCard', cardId, toListId, toIndex }),
};
}
// Internal — exposed for advanced wiring (tests, alternate persistence).
export function __resetStore() {
_state = null;
_listeners.clear();
}
export function __setState(s) {
_state = s;
_listeners.forEach((l) => l());
}
export function __setPersistOnChange(v) {
_persistOnChange = v;
}

View File

@@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react';
import { useBoardStore } from './boardStore';
// End-to-end-ish: write a v1 array to localStorage, mount the store, verify
// the migrated v2 state matches the spec, and verify the v1 key is untouched.
describe('useBoardStore() — migration integration', () => {
test('loading the store migrates v1 data into a single Inbox/Todo board', () => {
const v1 = [
{ id: 'todo-1', text: 'Ship P2', completed: false, dueDate: '2026-07-04' },
{ id: 'todo-2', text: 'Buy milk', completed: true, dueDate: null },
];
window.localStorage.setItem('ultra-todo-items', JSON.stringify(v1));
const { result } = renderHook(() => useBoardStore());
const { state } = result.current;
expect(state.schemaVersion).toBe(2);
expect(state.boardOrder).toHaveLength(1);
const boardId = state.boardOrder[0];
expect(state.boards[boardId].name).toBe('Inbox');
expect(state.boards[boardId].listIds).toHaveLength(1);
const listId = state.boards[boardId].listIds[0];
expect(state.lists[listId].name).toBe('Todo');
expect(state.lists[listId].cardIds).toEqual(['todo-1', 'todo-2']);
expect(state.cards['todo-1']).toMatchObject({
id: 'todo-1',
boardId,
listId,
title: 'Ship P2',
description: '',
dueDate: '2026-07-04',
});
expect(state.cards['todo-2'].title).toBe('Buy milk');
expect(state.cards['todo-2'].dueDate).toBeNull();
// v1 key still intact
expect(JSON.parse(window.localStorage.getItem('ultra-todo-items'))).toEqual(v1);
});
});

View File

@@ -0,0 +1,226 @@
import { renderHook, act } from '@testing-library/react';
import { useBoardStore } from './boardStore';
function getFreshStore() {
const { result } = renderHook(() => useBoardStore());
return result;
}
beforeEach(() => {
window.localStorage.clear();
});
describe('useBoardStore() — initial state', () => {
test('seeds a single Inbox board with a Todo list when storage is empty', () => {
const result = getFreshStore();
const { state } = result.current;
expect(state.schemaVersion).toBe(2);
expect(state.boardOrder).toHaveLength(1);
const boardId = state.boardOrder[0];
expect(state.boards[boardId].name).toBe('Inbox');
expect(state.boards[boardId].listIds).toHaveLength(1);
const listId = state.boards[boardId].listIds[0];
expect(state.lists[listId].name).toBe('Todo');
expect(state.activeBoardId).toBe(boardId);
});
test('persists the seeded state to localStorage on mount', () => {
renderHook(() => useBoardStore());
const stored = JSON.parse(window.localStorage.getItem('ultra-todo-v2-state'));
expect(stored).toBeTruthy();
expect(stored.schemaVersion).toBe(2);
expect(stored.boardOrder).toHaveLength(1);
});
});
describe('board actions', () => {
test('addBoard appends to boardOrder and inserts into boards map', () => {
const result = getFreshStore();
act(() => result.current.actions.addBoard('Work'));
const { state } = result.current;
expect(state.boardOrder).toHaveLength(2);
const newId = state.boardOrder[1];
expect(state.boards[newId].name).toBe('Work');
expect(state.boards[newId].listIds).toEqual([]);
});
test('renameBoard updates the board name in place', () => {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
act(() => result.current.actions.renameBoard(boardId, 'Personal'));
expect(result.current.state.boards[boardId].name).toBe('Personal');
});
test('deleteBoard removes the board, its lists, and its cards', () => {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
act(() => result.current.actions.addBoard('ToDelete'));
const toDelete = result.current.state.boardOrder[1];
act(() => result.current.actions.addList(toDelete, 'Trash List'));
const trashListId = result.current.state.boards[toDelete].listIds[0];
act(() => result.current.actions.addCard(trashListId, { title: 'soon gone' }));
const cardId = result.current.state.lists[trashListId].cardIds[0];
act(() => result.current.actions.deleteBoard(toDelete));
const { state } = result.current;
expect(state.boardOrder).not.toContain(toDelete);
expect(state.boards[toDelete]).toBeUndefined();
expect(state.lists[trashListId]).toBeUndefined();
expect(state.cards[cardId]).toBeUndefined();
// default Inbox board still present
expect(state.boardOrder).toContain(boardId);
});
test('setActiveBoard updates activeBoardId', () => {
const result = getFreshStore();
act(() => result.current.actions.addBoard('Other'));
const otherId = result.current.state.boardOrder[1];
act(() => result.current.actions.setActiveBoard(otherId));
expect(result.current.state.activeBoardId).toBe(otherId);
});
});
describe('list actions', () => {
test('addList appends a list id to the board in order', () => {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
act(() => result.current.actions.addList(boardId, 'Doing'));
act(() => result.current.actions.addList(boardId, 'Done'));
const { state } = result.current;
expect(state.boards[boardId].listIds).toHaveLength(3);
const [todoId, doingId, doneId] = state.boards[boardId].listIds;
expect(state.lists[doingId].name).toBe('Doing');
expect(state.lists[doneId].name).toBe('Done');
expect(state.lists[doingId].boardId).toBe(boardId);
expect(state.lists[todoId].name).toBe('Todo');
});
test('renameList updates the list name in place', () => {
const result = getFreshStore();
const listId = result.current.state.boards[result.current.state.boardOrder[0]].listIds[0];
act(() => result.current.actions.renameList(listId, 'Backlog'));
expect(result.current.state.lists[listId].name).toBe('Backlog');
});
test('deleteList removes the list and its cards, removes id from parent board', () => {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
act(() => result.current.actions.addList(boardId, 'Temp'));
const listId = result.current.state.boards[boardId].listIds[1];
act(() => result.current.actions.addCard(listId, { title: 'bye' }));
const cardId = result.current.state.lists[listId].cardIds[0];
act(() => result.current.actions.deleteList(listId));
const { state } = result.current;
expect(state.lists[listId]).toBeUndefined();
expect(state.cards[cardId]).toBeUndefined();
expect(state.boards[boardId].listIds).not.toContain(listId);
});
test('moveList reorders the listIds array on the board', () => {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
act(() => result.current.actions.addList(boardId, 'A'));
act(() => result.current.actions.addList(boardId, 'B'));
const [todo, a, b] = result.current.state.boards[boardId].listIds;
act(() => result.current.actions.moveList(boardId, 2, 0));
const order = result.current.state.boards[boardId].listIds;
expect(order).toEqual([b, todo, a]);
});
});
describe('card actions', () => {
function setup() {
const result = getFreshStore();
const boardId = result.current.state.boardOrder[0];
const listId = result.current.state.boards[boardId].listIds[0];
return { result, boardId, listId };
}
test('addCard appends a card to the list and records title/createdAt', () => {
const { result, listId } = setup();
act(() => result.current.actions.addCard(listId, { title: 'Task one' }));
const { state } = result.current;
expect(state.lists[listId].cardIds).toHaveLength(1);
const cardId = state.lists[listId].cardIds[0];
expect(state.cards[cardId].title).toBe('Task one');
expect(state.cards[cardId].listId).toBe(listId);
expect(state.cards[cardId].boardId).toBe(state.boardOrder[0]);
expect(typeof state.cards[cardId].createdAt).toBe('number');
});
test('addCard preserves optional description and dueDate', () => {
const { result, listId } = setup();
act(() => result.current.actions.addCard(listId, {
title: 'Plan',
description: 'a longer note',
dueDate: '2026-07-15',
}));
const cardId = result.current.state.lists[listId].cardIds[0];
expect(result.current.state.cards[cardId].description).toBe('a longer note');
expect(result.current.state.cards[cardId].dueDate).toBe('2026-07-15');
});
test('updateCard patches the given fields in place', () => {
const { result, listId } = setup();
act(() => result.current.actions.addCard(listId, { title: 'old' }));
const cardId = result.current.state.lists[listId].cardIds[0];
act(() => result.current.actions.updateCard(cardId, { title: 'new', dueDate: '2026-08-01' }));
expect(result.current.state.cards[cardId].title).toBe('new');
expect(result.current.state.cards[cardId].dueDate).toBe('2026-08-01');
});
test('deleteCard removes the card from its list and from the cards map', () => {
const { result, listId } = setup();
act(() => result.current.actions.addCard(listId, { title: 'gone' }));
const cardId = result.current.state.lists[listId].cardIds[0];
act(() => result.current.actions.deleteCard(cardId));
expect(result.current.state.cards[cardId]).toBeUndefined();
expect(result.current.state.lists[listId].cardIds).not.toContain(cardId);
});
test('moveCard updates the card\'s listId, boardId, and target index', () => {
const { result, boardId, listId } = setup();
// build a second list with a few existing cards
act(() => result.current.actions.addList(boardId, 'Other'));
const otherListId = result.current.state.boards[boardId].listIds[1];
act(() => result.current.actions.addCard(otherListId, { title: 'preexisting 1' }));
act(() => result.current.actions.addCard(otherListId, { title: 'preexisting 2' }));
const [pre1, pre2] = result.current.state.lists[otherListId].cardIds;
// add a card to the first list, then move it to index 1 of the second list
act(() => result.current.actions.addCard(listId, { title: 'moving' }));
const movingId = result.current.state.lists[listId].cardIds[0];
act(() => result.current.actions.moveCard(movingId, otherListId, 1));
const { state } = result.current;
expect(state.lists[listId].cardIds).toEqual([]);
expect(state.lists[otherListId].cardIds).toEqual([pre1, movingId, pre2]);
expect(state.cards[movingId].listId).toBe(otherListId);
expect(state.cards[movingId].boardId).toBe(boardId);
});
test('moveCard moving within the same list reorders the card', () => {
const { result, listId } = setup();
act(() => result.current.actions.addCard(listId, { title: 'a' }));
act(() => result.current.actions.addCard(listId, { title: 'b' }));
act(() => result.current.actions.addCard(listId, { title: 'c' }));
const [a, b, c] = result.current.state.lists[listId].cardIds;
act(() => result.current.actions.moveCard(c, listId, 0));
expect(result.current.state.lists[listId].cardIds).toEqual([c, a, b]);
});
});
describe('persistence', () => {
test('writes the v2 state to localStorage on every state change', () => {
const result = getFreshStore();
act(() => result.current.actions.addBoard('Work'));
const stored = JSON.parse(window.localStorage.getItem('ultra-todo-v2-state'));
expect(stored.boardOrder).toHaveLength(2);
expect(stored.boards[stored.boardOrder[1]].name).toBe('Work');
});
});

127
src/test/testUtils.jsx Normal file
View File

@@ -0,0 +1,127 @@
/**
* Test utilities for the redesign components.
*
* P2's boardStore is a singleton-style hook: useBoardStore() reads from
* localStorage in its useState initializer and returns `{ state, actions }`.
* Each call to the hook in a render tree creates its own state (useState
* is per-hook-call), so tests are naturally isolated as long as localStorage
* is patched BEFORE the hook runs.
*
* `renderWithStore` does two things:
* 1. Pre-seeds localStorage with `initial` (so useBoardStore's
* initializer picks it up via migrate()).
* 2. Wraps the UI in a Host component that calls useBoardStore() and
* clones the rendered child element to inject `{ state, actions }`
* as props. This matches how App.jsx wires the store in production.
*/
import { render } from '@testing-library/react'
import { cloneElement } from 'react'
import { useBoardStore } from '../store/boardStore'
function makeMemoryStorage() {
const data = {}
return {
data,
getItem(k) { return k in data ? data[k] : null },
setItem(k, v) { data[k] = String(v) },
removeItem(k) { delete data[k] },
}
}
/**
* Empty initial v2 state.
*/
export function makeEmptyState() {
return {
schemaVersion: 2,
activeBoardId: null,
boardOrder: [],
boards: {},
lists: {},
cards: {},
}
}
/**
* Host that calls useBoardStore and passes { state, actions } as a render prop.
* `children` may be a function `(api) => ReactElement` OR a ReactElement —
* in the latter case, the host uses React.cloneElement to inject state/actions.
*/
function Host({ children }) {
const { state, actions } = useBoardStore()
if (typeof children === 'function') {
return children({ state, actions })
}
// Treat anything with a .type property as a React element. We avoid
// importing isValidElement from React 19 (it's a named export there but
// the resolution path differs across jest module setups).
if (children && typeof children === 'object' && 'type' in children) {
return cloneElement(children, { state, actions })
}
return children
}
/**
* Render a component tree with the singleton board store seeded from `initial`.
*
* The UI can either be:
* - a React element that accepts `{ state, actions }` props directly
* (e.g. `<List listId="l1" />`) — the host will inject them via cloneElement.
* - a function `(api) => ReactElement` — the host calls it with the
* store API (used when the test wants more control, e.g. host components
* that need access to state inside the render prop).
*
* Returns the @testing-library/react render result, plus a `state` and
* `actions` getter pair so tests can read state and invoke actions from
* outside the component tree:
*
* const result = renderWithStore(<List listId="l1" />, { initial })
* expect(result.state.lists['l1'].name).toBe('Foo')
* act(() => { result.actions.renameList('l1', 'Bar') })
* expect(screen.getByText('Bar')).toBeInTheDocument()
*
* @param {React.ReactElement|Function} ui
* @param {object} [opts]
* @param {object} [opts.initial] - v2 state shape to pre-populate
* @returns the @testing-library/react render result + state/actions getters
*/
export function renderWithStore(ui, { initial } = {}) {
const memStorage = makeMemoryStorage()
if (initial) {
memStorage.setItem('ultra-todo-v2-state', JSON.stringify(initial))
}
const original = window.localStorage
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: memStorage,
})
// Use a ref-like object to capture the live store API from inside Host.
const capture = { current: null }
function CapturingHost() {
const api = useBoardStore()
capture.current = api
return <Host>{ui}</Host>
}
const result = render(<CapturingHost />)
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: original,
})
// Expose getters that always reflect the latest store state (which is
// updated by useState inside the CapturingHost instance).
return {
...result,
get state() {
return capture.current ? capture.current.state : null
},
get actions() {
return capture.current ? capture.current.actions : null
},
}
}