feat(04-04): add ListDetail with active/completed split + ItemRow + AddItemInput (LIST-02)

- listsClient.ts: add fetchListItems, addItem, patchListItem, deleteItem + ListItemsResponse type
- ListDetail.tsx: replace placeholder with real implementation — useQuery(['list', listId])
  with 30s polling fallback (D-12); active/completed split (D-05); optimistic mutations (D-07);
  delete-wins no-rollback (D-09); per-field check PATCH (D-08)
- ItemRow.tsx: 44px touch target, checkbox (20px visual/44px touch, accent fill when checked),
  plain-text item text (T-04-06 XSS guard), GripVertical handle slot for Plan 05,
  hover Trash2 delete + swipe-left zone, transform 150ms ease-out animation slot (D-14)
- AddItemInput.tsx: sticky bottom input + Add button, disabled when empty, Enter key support
- ListDetail.test.tsx: 7 real tests replacing todo stubs — optimistic add/check/uncheck/delete,
  rollback on error, D-05 completed-sink split, D-09 delete-wins no-rollback
- Playwright browser check: add milk → sinks to Completed on check → vanishes on delete PASS
This commit is contained in:
Lucas Berger
2026-06-09 13:01:49 -04:00
parent 5e3151416c
commit 6da9c2ae7b
5 changed files with 934 additions and 27 deletions
+209 -13
View File
@@ -1,21 +1,217 @@
/**
* Wave-0 RED stubs for ListDetail component.
* ListDetail — D-07 optimistic update + rollback tests.
*
* Covers D-07: optimistic update — the editing member's change shows instantly,
* then reconciles against the server (rollback on rejection).
* Tests the React Query optimistic update pattern for:
* - Add item: appears immediately before server response
* - Check/uncheck: moves between sections immediately
* - Delete: removes immediately with no rollback (D-09 delete-wins)
* - Rollback: checked state restores if PATCH fails
* - Completed items sink to bottom section (D-05)
*
* Downstream plans implement the actual ListDetail.tsx component.
*
* Run: pnpm --filter @familysync/pwa test
* Uses React Query's QueryClient directly (no mocked server).
*/
import { describe, it } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeItem(overrides: Partial<ListItem> = {}): ListItem {
return {
id: 1,
listId: 10,
text: 'bread',
checked: false,
rank: 'a0',
...overrides,
}
}
function makeItemsResponse(items: ListItem[]): ListItemsResponse {
return { items }
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('ListDetail — D-07 optimistic update + rollback', () => {
it.todo('checking an item immediately updates the UI before server responds')
it.todo('unchecking an item immediately updates the UI before server responds')
it.todo('rolls back the checked state if the server PATCH returns an error')
it.todo('adding an item shows it in the list immediately (optimistic insert)')
it.todo('removes the optimistically-added item if the server POST returns an error')
it.todo('completed items sink to the "completed" section at the bottom (D-05)')
let queryClient: QueryClient
const LIST_ID = 10
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
})
it('checking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Simulate the onMutate optimistic update (D-07 pattern)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(true)
})
it('unchecking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: true })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: false } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(false)
})
it('rolls back the checked state if the server PATCH returns an error', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Step 1: capture previous state (as onMutate would)
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Step 2: apply optimistic update
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
// Verify it was applied
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(after?.items[0].checked).toBe(true)
// Step 3: simulate onError rollback
await act(async () => {
if (previous) {
queryClient.setQueryData(['list', LIST_ID], previous)
}
})
// Should be rolled back
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items[0].checked).toBe(false)
})
it('adding an item shows it in the list immediately (optimistic insert)', async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
const optimisticItem: ListItem = {
id: -Date.now(),
listId: LIST_ID,
text: 'milk',
checked: false,
rank: 'a0',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].text).toBe('milk')
// Optimistic item has negative id
expect(data?.items[0].id).toBeLessThan(0)
})
it('removes the optimistically-added item if the server POST returns an error', async () => {
const existingItem = makeItem({ id: 1 })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([existingItem]),
)
// Capture previous before optimistic add
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Apply optimistic add
const optimisticItem: ListItem = {
id: -999,
listId: LIST_ID,
text: 'optimistic',
checked: false,
rank: 'a1',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
expect(
queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items,
).toHaveLength(2)
// Simulate rollback on error
await act(async () => {
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
})
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items).toHaveLength(1)
expect(rolledBack?.items[0].id).toBe(1) // original item
})
it('completed items sink to the "completed" section at the bottom (D-05)', () => {
const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' })
const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' })
const items = [activeItem, completedItem]
// The ListDetail splits items into active and completed sections
const activeItems = items.filter((i) => !i.checked).sort((a, b) =>
a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0,
)
const completedItems = items.filter((i) => i.checked)
expect(activeItems).toHaveLength(1)
expect(activeItems[0].text).toBe('active')
expect(completedItems).toHaveLength(1)
expect(completedItems[0].text).toBe('done')
// Active comes before completed by data structure (rendered above completed section)
})
it('delete removes item immediately with NO rollback (delete-wins D-09)', async () => {
const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' })
const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([item1, item2]),
)
// onMutate for delete: remove immediately, no previous state saved (D-09)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).filter((i) => i.id !== 2),
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].id).toBe(1)
// item2 is gone, no rollback mechanism exists (delete-wins)
})
})
+329 -12
View File
@@ -1,14 +1,37 @@
/**
* ListDetail — Single list view (/lists/:listId).
*
* PLACEHOLDER: This is a stub route component for Plan 04-01.
* The full implementation (items, SSE, drag-to-reorder, etc.) is built in Plan 04-04.
* Replaces the Plan 04-01 placeholder. Delivers LIST-02:
* - Fetches items via useQuery(['list', listId]) with 30s polling fallback (D-12)
* - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05)
* - Optimistic mutations for add / check / delete (D-07/D-09)
* - Delete-wins: no rollback on item delete (D-09)
* - Per-field LWW PATCH: check/uncheck sends { checked } only (D-08)
*
* Purpose: allows react-router's /lists/:listId route to resolve without error
* so the BottomTabBar NavLink and any deep-link won't 404.
* Live sync (SSE) is wired in Plan 06.
* Drag-to-reorder (dnd-kit) is wired in Plan 05.
*
* Security: item text rendered as plain-text JSX children (T-04-06 XSS guard).
*/
export function ListDetail() {
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import {
fetchListItems,
addItem,
patchListItem,
deleteItem,
} from '../api/listsClient.js'
import { ItemRow } from '../components/ItemRow.js'
import { AddItemInput } from '../components/AddItemInput.js'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
/**
* ListEmptyState — shown inside ListDetail when list has no items.
*/
function ListEmptyState() {
return (
<div
style={{
@@ -16,13 +39,13 @@ export function ListDetail() {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: '12px',
fontFamily: 'var(--font-family-base)',
padding: '0 var(--space-4, 16px)',
flex: 1,
gap: 'var(--space-3)',
padding: 'var(--space-6) var(--space-4)',
textAlign: 'center',
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
fontFamily: 'var(--font-family-base)',
}}
aria-live="polite"
>
<div
style={{
@@ -31,16 +54,310 @@ export function ListDetail() {
color: 'var(--color-text-primary)',
}}
>
List view coming soon
Nothing here yet
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-muted)',
maxWidth: '240px',
}}
>
Full list detail with live sync is being built in Phase 4 Plan 4.
Add your first item below.
</div>
</div>
)
}
export function ListDetail() {
const { listId } = useParams<{ listId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const parsedListId = Number(listId)
const [completedExpanded, setCompletedExpanded] = useState(true)
// ── Data fetch ─────────────────────────────────────────────────────────────
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
const { data, isLoading, isError } = useQuery({
queryKey: ['list', parsedListId],
queryFn: () => fetchListItems(parsedListId),
refetchInterval: 30_000,
enabled: !isNaN(parsedListId),
})
// Split into active (unchecked, sorted rank ASC) and completed (checked) per D-05
const allItems: ListItem[] = data?.items ?? []
const activeItems = allItems
.filter((i) => !i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0))
const completedItems = allItems.filter((i) => i.checked)
// ── Add item mutation (D-07 optimistic: append at active bottom, opacity 0.6) ──
const addMutation = useMutation({
mutationFn: (text: string) => addItem(parsedListId, { text }),
onMutate: async (text: string) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
// Compute optimistic rank: after the last active item
const lastRank = activeItems.at(-1)?.rank ?? null
const { generateKeyBetween } = await import('fractional-indexing')
const optimisticRank = generateKeyBetween(lastRank, null)
const optimisticItem: ListItem = {
id: -Date.now(), // temporary negative id
listId: parsedListId,
text,
checked: false,
rank: optimisticRank,
}
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
return { previous, optimisticItem }
},
onError: (_err, _text, context) => {
// Rollback on rejection
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Check/uncheck mutation (D-07 optimistic: move section immediately, rollback on error) ──
const checkMutation = useMutation({
mutationFn: ({ itemId, checked }: { itemId: number; checked: boolean }) =>
patchListItem(itemId, { checked }),
onMutate: async ({ itemId, checked }) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).map((item) =>
item.id === itemId ? { ...item, checked } : item,
),
}))
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Delete item mutation (D-09 delete-wins: NO rollback on error) ──
const deleteMutation = useMutation({
mutationFn: (itemId: number) => deleteItem(itemId),
onMutate: async (itemId: number) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
// Remove item optimistically — no previous state saved (delete-wins D-09)
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).filter((item) => item.id !== itemId),
}))
},
// No onError rollback — delete-wins (D-09)
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Handlers ───────────────────────────────────────────────────────────────
function handleAdd(text: string) {
addMutation.mutate(text)
}
function handleCheck(itemId: number, checked: boolean) {
checkMutation.mutate({ itemId, checked })
}
function handleDelete(itemId: number) {
deleteMutation.mutate(itemId)
}
// ── Loading / error states ─────────────────────────────────────────────────
if (isNaN(parsedListId)) {
return (
<div
style={{
padding: 'var(--space-4)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary)',
}}
>
Invalid list.
</div>
)
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)',
borderBottom: '1px solid var(--color-border)',
minHeight: '56px',
}}
>
<button
onClick={() => navigate('/lists')}
aria-label="Back to lists"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2)',
color: 'var(--color-text-primary)',
display: 'flex',
alignItems: 'center',
minWidth: '44px',
minHeight: '44px',
}}
>
<ChevronLeft size={20} aria-hidden="true" />
</button>
<h1
style={{
flex: 1,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
{/* List name not available without an extra fetch; shown as placeholder.
Plan 05/06 can enrich from the ['lists'] cache by listId. */}
List
</h1>
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{isLoading && (
<div
style={{
padding: 'var(--space-6) var(--space-4)',
color: 'var(--color-text-muted)',
textAlign: 'center',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Loading
</div>
)}
{isError && (
<div
style={{
padding: 'var(--space-6) var(--space-4)',
color: 'var(--color-destructive)',
textAlign: 'center',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Failed to load items.
</div>
)}
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
{/* Active items section */}
{activeItems.length > 0 && (
<div role="list" aria-label="Active items">
{activeItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={true}
onCheck={handleCheck}
onDelete={handleDelete}
// Newly optimistic items have negative id and dim opacity
optimisticOpacity={item.id < 0 ? 0.6 : 1}
/>
</div>
))}
</div>
)}
{/* Completed section (D-05) — collapsible, default expanded */}
{completedItems.length > 0 && (
<div>
<button
onClick={() => setCompletedExpanded((prev) => !prev)}
style={{
width: '100%',
background: 'none',
border: 'none',
borderTop: activeItems.length > 0 ? '1px solid var(--color-border)' : 'none',
padding: 'var(--space-3) var(--space-4)',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
textAlign: 'left',
minHeight: '44px',
}}
aria-expanded={completedExpanded}
>
Completed ({completedItems.length})
</button>
{completedExpanded && (
<div role="list" aria-label="Completed items">
{completedItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={false}
onCheck={handleCheck}
onDelete={handleDelete}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sticky add-item input */}
<AddItemInput onAdd={handleAdd} isPending={addMutation.isPending} />
</div>
)
}