feat(redesign): Trello-style kanban UI (P7 integration)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 29s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 29s
This commit is contained in:
9
babel.config.js
Normal file
9
babel.config.js
Normal 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
20
jest.config.js
Normal 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
31
jest.setup.js
Normal 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
6245
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
49
src/App.css
49
src/App.css
@@ -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;
|
||||
}
|
||||
99
src/App.jsx
99
src/App.jsx
@@ -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
150
src/App.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
22
src/components/AppShell.css
Normal file
22
src/components/AppShell.css
Normal 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;
|
||||
}
|
||||
89
src/components/AppShell.jsx
Normal file
89
src/components/AppShell.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/AppShell.test.jsx
Normal file
32
src/components/AppShell.test.jsx
Normal 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
121
src/components/Board.css
Normal 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
247
src/components/Board.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/components/Board.test.jsx
Normal file
83
src/components/Board.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
174
src/components/BoardDnD.test.jsx
Normal file
174
src/components/BoardDnD.test.jsx
Normal 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
232
src/components/Card.css
Normal 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
69
src/components/Card.jsx
Normal 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
|
||||
142
src/components/Card.test.jsx
Normal file
142
src/components/Card.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
175
src/components/CardDetailModal.jsx
Normal file
175
src/components/CardDetailModal.jsx
Normal 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
|
||||
157
src/components/CardDetailModal.test.jsx
Normal file
157
src/components/CardDetailModal.test.jsx
Normal 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 }))
|
||||
})
|
||||
})
|
||||
26
src/components/EmptyState.css
Normal file
26
src/components/EmptyState.css
Normal 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);
|
||||
}
|
||||
26
src/components/EmptyState.jsx
Normal file
26
src/components/EmptyState.jsx
Normal 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
104
src/components/List.css
Normal 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
197
src/components/List.jsx
Normal 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
|
||||
169
src/components/List.test.jsx
Normal file
169
src/components/List.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
105
src/components/ListHeader.css
Normal file
105
src/components/ListHeader.css
Normal 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;
|
||||
}
|
||||
139
src/components/ListHeader.jsx
Normal file
139
src/components/ListHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/components/ListHeader.test.jsx
Normal file
115
src/components/ListHeader.test.jsx
Normal 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
283
src/components/Sidebar.css
Normal 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
306
src/components/Sidebar.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
187
src/components/Sidebar.test.jsx
Normal file
187
src/components/Sidebar.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' : ''}`}>
|
||||
📅 {todo.dueDate}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<button className="delete-btn" onClick={() => onDelete(todo.id)} title="Delete">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default TodoItem
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
43
src/components/TopBar.css
Normal 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
24
src/components/TopBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/TopBar.test.jsx
Normal file
48
src/components/TopBar.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
262
src/components/__fixtures__/DnDFixtureBoard.jsx
Normal file
262
src/components/__fixtures__/DnDFixtureBoard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
116
src/lib/boardDnd.js
Normal 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
169
src/lib/boardDnd.test.js
Normal 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
106
src/lib/migrate.js
Normal 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
118
src/lib/migrate.test.js
Normal 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
3
src/setup.test.js
Normal file
@@ -0,0 +1,3 @@
|
||||
test('jest setup works', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
326
src/store/boardStore.js
Normal file
326
src/store/boardStore.js
Normal 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;
|
||||
}
|
||||
41
src/store/boardStore.migration.test.js
Normal file
41
src/store/boardStore.migration.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
226
src/store/boardStore.test.js
Normal file
226
src/store/boardStore.test.js
Normal 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
127
src/test/testUtils.jsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user