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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user