From 95dbc663c1d3c398e4d8ecd6070d524847d38951 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 12:45:19 -0400 Subject: [PATCH] feat(04-03): wire ListsIndex + ListCard + CreateListSheet + ListDeleteDialog (LIST-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listsClient.ts: add createList/patchList/deleteList + List/ListItem types with activeCount/doneCount - ListsEmptyState.tsx: extracted standalone component (ClipboardList icon, UI-SPEC copy) - ListCard.tsx: name/count badge/Shared pill/ChevronRight; hover-reveal delete button; navigates /lists/:id - CreateListSheet.tsx: bottom-sheet/modal; Shared default (D-01); optimistic useMutation; auto-focus; Escape to close - ListDeleteDialog.tsx: mirrors DeleteConfirmationDialog pattern; props-driven (no calendarStore); XSS guard on name - ListsIndex.tsx: replaced placeholder with real data via useQuery+useMutation; mounts CreateListSheet+ListDeleteDialog - DeleteConfirmationDialog.tsx NOT modified (stable, D-06 pattern preserved) - PWA typecheck passes; DeleteConfirmationDialog.test.tsx 10 passed - Playwright E2E: create Groceries+Gift Ideas (Shared pills); delete dialog → confirm → card disappears --- apps/pwa/src/api/listsClient.ts | 36 +- apps/pwa/src/components/CreateListSheet.tsx | 336 ++++++++++++++++ apps/pwa/src/components/ListCard.tsx | 176 ++++++++ apps/pwa/src/components/ListDeleteDialog.tsx | 196 +++++++++ apps/pwa/src/components/ListsEmptyState.tsx | 62 +++ apps/pwa/src/routes/ListsIndex.tsx | 401 +++++++++---------- 6 files changed, 998 insertions(+), 209 deletions(-) create mode 100644 apps/pwa/src/components/CreateListSheet.tsx create mode 100644 apps/pwa/src/components/ListCard.tsx create mode 100644 apps/pwa/src/components/ListDeleteDialog.tsx create mode 100644 apps/pwa/src/components/ListsEmptyState.tsx diff --git a/apps/pwa/src/api/listsClient.ts b/apps/pwa/src/api/listsClient.ts index fbd2b57..834762c 100644 --- a/apps/pwa/src/api/listsClient.ts +++ b/apps/pwa/src/api/listsClient.ts @@ -4,8 +4,8 @@ * credentials: 'include' is required so the OIDC session cookie is sent with * every request (same pattern as client.ts). * - * This file provides only the functions needed in Plan 04-01 (ListsIndex empty - * state). Plans 04-02 and 04-03 expand it with createList, deleteList, item CRUD. + * Exports: fetchLists, createList, patchList, deleteList + * Types: List, ListItem, ListsResponse */ const BASE = '/api' @@ -26,6 +26,10 @@ export interface List { name: string isShared: boolean ownerId: number + activeCount: number + doneCount: number + createdAt?: string + updatedAt?: string } export interface ListItem { @@ -45,3 +49,31 @@ export interface ListsResponse { export async function fetchLists(): Promise { return apiFetch('/lists').then((r) => r.json() as Promise) } + +export async function createList(payload: { + name: string + isShared: boolean +}): Promise { + return apiFetch('/lists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then((r) => r.json() as Promise) +} + +export async function patchList( + id: number, + patch: { name?: string; isShared?: boolean }, +): Promise { + return apiFetch(`/lists/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }).then((r) => r.json() as Promise) +} + +export async function deleteList(id: number): Promise<{ id: number }> { + return apiFetch(`/lists/${id}`, { + method: 'DELETE', + }).then((r) => r.json() as Promise<{ id: number }>) +} diff --git a/apps/pwa/src/components/CreateListSheet.tsx b/apps/pwa/src/components/CreateListSheet.tsx new file mode 100644 index 0000000..8594e84 --- /dev/null +++ b/apps/pwa/src/components/CreateListSheet.tsx @@ -0,0 +1,336 @@ +/** + * CreateListSheet — new-list creation form (LIST-01, D-01). + * + * UI-SPEC §CreateListSheet: + * - Mobile: bottom sheet (slides up from bottom, 50vh height, backdrop overlay) + * - Desktop: centered modal (max-width 360px) + * - Heading: "New list" + * - Name input: auto-focused, placeholder "e.g. Groceries" + * - Shared/Private toggle: "Shared" default (D-01), clearly labeled + * - "Create" button: accent var(--color-member-0), disabled while name empty, + * destructive border on blank-submit attempt + * - "Cancel": ghost text button + * - Escape to close, backdrop click to close + * + * Behavior: + * - Open/close driven by listsStore.createListSheetOpen + * - useMutation(createList) with optimistic insert + onError rollback + onSettled invalidate + * + * T-04-06 XSS guard: all text is static or plain-text JSX children (no dangerouslySetInnerHTML). + */ + +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createList } from '../api/listsClient.js' +import type { List, ListsResponse } from '../api/listsClient.js' +import { useListsStore } from '../store/listsStore.js' + +export function CreateListSheet() { + const isOpen = useListsStore((s) => s.createListSheetOpen) + const setOpen = useListsStore((s) => s.setCreateListSheetOpen) + const queryClient = useQueryClient() + + const [name, setName] = useState('') + const [isShared, setIsShared] = useState(true) // D-01: default shared + const [attemptedEmpty, setAttemptedEmpty] = useState(false) + const inputRef = useRef(null) + + // Auto-focus name input when sheet opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + // Reset form state on open + setName('') + setIsShared(true) + setAttemptedEmpty(false) + } + }, [isOpen]) + + // Escape key listener + useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleClose = () => { + setOpen(false) + setName('') + setIsShared(true) + setAttemptedEmpty(false) + } + + const mutation = useMutation({ + mutationFn: (payload: { name: string; isShared: boolean }) => createList(payload), + onMutate: async (payload) => { + // Optimistic insert: add a temporary entry to the lists cache + await queryClient.cancelQueries({ queryKey: ['lists'] }) + const previous = queryClient.getQueryData(['lists']) + const tempId = -Date.now() // negative temp ID so it won't collide with real IDs + queryClient.setQueryData(['lists'], (old) => ({ + lists: [ + ...(old?.lists ?? []), + { + id: tempId, + name: payload.name, + isShared: payload.isShared, + ownerId: 0, // unknown until response + activeCount: 0, + doneCount: 0, + } as List, + ], + })) + return { previous } + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous) { + queryClient.setQueryData(['lists'], context.previous) + } + }, + onSettled: () => { + // Always invalidate to get canonical server state + queryClient.invalidateQueries({ queryKey: ['lists'] }) + }, + onSuccess: () => { + handleClose() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + setAttemptedEmpty(true) + return + } + mutation.mutate({ name: name.trim(), isShared }) + } + + if (!isOpen) return null + + const isNameEmpty = !name.trim() + const inputBorderColor = attemptedEmpty && isNameEmpty + ? 'var(--color-destructive)' + : 'var(--color-border)' + + return ( + <> + {/* Backdrop */} + + + {/* Delete confirmation dialog (D-06) */} + + + {/* Create list sheet (D-01) */} + + ) }