Milestone v1.0: FamilySync MVP #1
@@ -4,8 +4,9 @@
|
||||
* credentials: 'include' is required so the OIDC session cookie is sent with
|
||||
* every request (same pattern as client.ts).
|
||||
*
|
||||
* Exports: fetchLists, createList, patchList, deleteList
|
||||
* Types: List, ListItem, ListsResponse
|
||||
* Exports: fetchLists, createList, patchList, deleteList,
|
||||
* fetchListItems, addItem, patchListItem, deleteItem
|
||||
* Types: List, ListItem, ListsResponse, ListItemsResponse
|
||||
*/
|
||||
|
||||
const BASE = '/api'
|
||||
@@ -38,12 +39,18 @@ export interface ListItem {
|
||||
text: string
|
||||
checked: boolean
|
||||
rank: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface ListsResponse {
|
||||
lists: List[]
|
||||
}
|
||||
|
||||
export interface ListItemsResponse {
|
||||
items: ListItem[]
|
||||
}
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLists(): Promise<ListsResponse> {
|
||||
@@ -77,3 +84,55 @@ export async function deleteList(id: number): Promise<{ id: number }> {
|
||||
method: 'DELETE',
|
||||
}).then((r) => r.json() as Promise<{ id: number }>)
|
||||
}
|
||||
|
||||
// ── Item functions (LIST-02) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all items for a list, ordered by rank ASC.
|
||||
* Returns active and completed items; the UI splits them into sections.
|
||||
*/
|
||||
export async function fetchListItems(listId: number): Promise<ListItemsResponse> {
|
||||
return apiFetch(`/lists/${listId}/items`).then(
|
||||
(r) => r.json() as Promise<ListItemsResponse>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to a list. Server assigns the fractional rank (D-13).
|
||||
*/
|
||||
export async function addItem(
|
||||
listId: number,
|
||||
payload: { text: string },
|
||||
): Promise<ListItem> {
|
||||
return apiFetch(`/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((r) => r.json() as Promise<ListItem>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-field PATCH for a list item (D-08).
|
||||
* Exactly one of: checked, text, or position.
|
||||
* Enforced server-side by zod refine; callers must pass exactly one field.
|
||||
*/
|
||||
export async function patchListItem(
|
||||
itemId: number,
|
||||
patch: { checked: boolean } | { text: string } | { position: string },
|
||||
): Promise<ListItem> {
|
||||
return apiFetch(`/list-items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
}).then((r) => r.json() as Promise<ListItem>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an item instantly — no confirmation (D-06).
|
||||
* Delete-wins semantics (D-09): no rollback in the client after success.
|
||||
*/
|
||||
export async function deleteItem(itemId: number): Promise<{ id: number }> {
|
||||
return apiFetch(`/list-items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
}).then((r) => r.json() as Promise<{ id: number }>)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* AddItemInput — sticky add-item text input at the bottom of ListDetail.
|
||||
*
|
||||
* Design contract (UI-SPEC §AddItemInput):
|
||||
* - Sticky at the bottom, above keyboard on mobile
|
||||
* - Horizontal flex: text input (flex:1) + "Add" button
|
||||
* - Input: --text-body-*, 44px min-height, placeholder "Add an item…"
|
||||
* - Button: "Add" label, --color-member-0 bg, white text, disabled when empty
|
||||
* - Submit: Enter key OR tap "Add" button
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
interface AddItemInputProps {
|
||||
onAdd: (text: string) => void
|
||||
/** Whether the add mutation is pending (parent controls this for optimistic state) */
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
onAdd(trimmed)
|
||||
setText('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
background: 'var(--color-surface)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
// Push above keyboard on iOS and bottom tab bar
|
||||
paddingBottom: 'calc(var(--space-3) + env(safe-area-inset-bottom, 0px))',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add an item…"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
minHeight: '44px',
|
||||
color: 'var(--color-text-primary)',
|
||||
outline: 'none',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-focus-ring, #4A90D9)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim() || isPending}
|
||||
aria-label="Add item"
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: '0 var(--space-4)',
|
||||
minHeight: '44px',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
cursor: !text.trim() || isPending ? 'not-allowed' : 'pointer',
|
||||
opacity: !text.trim() || isPending ? 0.5 : 1,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* ItemRow — a single list item row with checkbox, text, drag handle, and delete.
|
||||
*
|
||||
* Design contract (UI-SPEC §ItemRow):
|
||||
* - 44px min-height touch target
|
||||
* - Checkbox: 20px visual / 44px touch target, --color-member-0 fill when checked
|
||||
* - Item text: plain-text JSX (T-04-06 XSS guard); line-through + muted when completed
|
||||
* - GripVertical handle slot on active items (non-functional here; Plan 05 wires dnd-kit)
|
||||
* - Delete affordance: hover Trash2 on desktop / swipe-left zone on phone
|
||||
* - No confirmation on delete (D-06)
|
||||
* - CSS transition 'transform 150ms ease-out' for Plan 05 remote reorder animation slot (D-14)
|
||||
*
|
||||
* Optimistic behavior (caller responsibility):
|
||||
* - Checking: caller's mutation moves item to completed section immediately
|
||||
* - Delete: caller removes item from cache; no rollback (D-09)
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, Trash2 } from 'lucide-react'
|
||||
import type { ListItem } from '../api/listsClient.js'
|
||||
|
||||
interface ItemRowProps {
|
||||
item: ListItem
|
||||
/** Whether this is an active (unchecked) item — shows drag handle */
|
||||
isActive: boolean
|
||||
onCheck: (itemId: number, checked: boolean) => void
|
||||
onDelete: (itemId: number) => void
|
||||
/** Opacity for optimistic pending state (e.g. 0.6 while add is confirming) */
|
||||
optimisticOpacity?: number
|
||||
}
|
||||
|
||||
export function ItemRow({
|
||||
item,
|
||||
isActive,
|
||||
onCheck,
|
||||
onDelete,
|
||||
optimisticOpacity = 1,
|
||||
}: ItemRowProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [swipeRevealed, setSwipeRevealed] = useState(false)
|
||||
const [touchStartX, setTouchStartX] = useState<number | null>(null)
|
||||
|
||||
function handleCheckboxClick() {
|
||||
onCheck(item.id, !item.checked)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
setSwipeRevealed(false)
|
||||
onDelete(item.id)
|
||||
}
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
setTouchStartX(e.touches[0].clientX)
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: React.TouchEvent) {
|
||||
if (touchStartX === null) return
|
||||
const deltaX = touchStartX - e.changedTouches[0].clientX
|
||||
if (deltaX > 60) {
|
||||
// Swipe-left: reveal delete zone
|
||||
setSwipeRevealed(true)
|
||||
} else if (deltaX < -20) {
|
||||
// Swipe-right: hide delete zone
|
||||
setSwipeRevealed(false)
|
||||
}
|
||||
setTouchStartX(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
// D-14: transition slot for Plan 05 remote reorder animation
|
||||
transition: 'transform 150ms ease-out',
|
||||
opacity: optimisticOpacity,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Main row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
background: 'var(--color-surface)',
|
||||
transform: swipeRevealed ? 'translateX(-80px)' : 'translateX(0)',
|
||||
transition: 'transform 200ms ease',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle slot — GripVertical exists but non-functional until Plan 05 */}
|
||||
{isActive && (
|
||||
<button
|
||||
aria-label="Drag to reorder (available in next update)"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'grab',
|
||||
padding: 'var(--space-1)',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: '20px',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Checkbox (44px touch area, 20px visual) */}
|
||||
<button
|
||||
role="checkbox"
|
||||
aria-checked={item.checked}
|
||||
aria-label={item.text}
|
||||
onClick={handleCheckboxClick}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
minWidth: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '4px',
|
||||
border: item.checked ? 'none' : '2px solid var(--color-border)',
|
||||
background: item.checked ? 'var(--color-member-0)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{item.checked && (
|
||||
<svg
|
||||
width="12"
|
||||
height="9"
|
||||
viewBox="0 0 12 9"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M1 4L4.5 7.5L11 1"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Item text — plain text only (T-04-06 XSS guard: no dangerouslySetInnerHTML) */}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: item.checked ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
|
||||
{/* Desktop delete button — visible on hover */}
|
||||
{hovered && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete ${item.text}`}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--space-2)',
|
||||
color: 'var(--color-destructive)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swipe-left delete zone (phone) */}
|
||||
{swipeRevealed && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete ${item.text}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '80px',
|
||||
background: 'var(--color-destructive)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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