feat(04-03): wire ListsIndex + ListCard + CreateListSheet + ListDeleteDialog (LIST-01)
- 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
This commit is contained in:
+194
-207
@@ -3,118 +3,42 @@
|
||||
*
|
||||
* UI-SPEC §ListsIndex:
|
||||
* - Full-height scrollable column with 16px horizontal padding
|
||||
* - "Lists" heading
|
||||
* - FAB ("+ New List") — fixed bottom-right on phone, top-right inline on desktop
|
||||
* - Empty state: ListsEmptyState when no lists
|
||||
* - "Lists" heading + FAB ("+ New List") in top-right corner
|
||||
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
|
||||
* - Empty state: ListsEmptyState when no lists
|
||||
* - ListCard for each list — navigates to /lists/:id on tap
|
||||
* - ListDeleteDialog for delete confirmation (D-06)
|
||||
* - CreateListSheet for new-list creation (D-01)
|
||||
*
|
||||
* Data flow:
|
||||
* TanStack Query ['lists'] → fetchLists → render list or empty state
|
||||
* TanStack Query ['lists'] → fetchLists → render list cards or empty state
|
||||
* useMutation(deleteList) → optimistic remove → onError rollback → onSettled invalidate
|
||||
*
|
||||
* Note: Create + delete logic landed in Plans 04-02/04-03.
|
||||
* The FAB is a non-functional placeholder here (Plan 04-03 wires CreateListSheet).
|
||||
* T-04-06 XSS guard: all user-supplied strings rendered via ListCard/ListDeleteDialog
|
||||
* as plain-text JSX children — no dangerouslySetInnerHTML anywhere.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { fetchLists, type List } from '../api/listsClient.js'
|
||||
|
||||
// ── Empty State ────────────────────────────────────────────────────────────
|
||||
|
||||
function ListsEmptyState() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
padding: '48px var(--space-4)',
|
||||
gap: 'var(--space-4)',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
}}
|
||||
>
|
||||
No lists yet
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
Tap + to create your first shared list…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List Card (placeholder for Plan 04-03 full implementation) ─────────────
|
||||
|
||||
function ListCard({ list }: { list: List }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-4) var(--space-6, 24px)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
minHeight: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
}}
|
||||
>
|
||||
{list.name}
|
||||
</div>
|
||||
{list.isShared && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
display: 'inline-block',
|
||||
background: 'var(--color-surface-dim)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { fetchLists, deleteList } from '../api/listsClient.js'
|
||||
import type { List, ListsResponse } from '../api/listsClient.js'
|
||||
import { useListsStore } from '../store/listsStore.js'
|
||||
import { ListCard } from '../components/ListCard.js'
|
||||
import { ListDeleteDialog } from '../components/ListDeleteDialog.js'
|
||||
import { ListsEmptyState } from '../components/ListsEmptyState.js'
|
||||
import { CreateListSheet } from '../components/CreateListSheet.js'
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function ListsIndex() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen)
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<List | null>(null)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['lists'],
|
||||
queryFn: fetchLists,
|
||||
@@ -124,144 +48,207 @@ export function ListsIndex() {
|
||||
|
||||
const lists = data?.lists ?? []
|
||||
|
||||
// Delete mutation with optimistic removal
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (listId: number) => deleteList(listId),
|
||||
onMutate: async (listId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['lists'] })
|
||||
const previous = queryClient.getQueryData<ListsResponse>(['lists'])
|
||||
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
|
||||
lists: (old?.lists ?? []).filter((l) => l.id !== listId),
|
||||
}))
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['lists'], context.previous)
|
||||
}
|
||||
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onSuccess: () => {
|
||||
navigate('/lists')
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDeleteRequest = (list: List) => {
|
||||
setDeleteTarget(list)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) return
|
||||
deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
// Bottom padding to clear the 56px fixed tab bar on phone
|
||||
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-6, 24px) var(--space-4, 16px) var(--space-4, 16px)',
|
||||
flexShrink: 0,
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
// Bottom padding to clear the 56px fixed tab bar on phone
|
||||
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-display-size, 24px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 'var(--text-display-line-height, 1.2)',
|
||||
}}
|
||||
>
|
||||
Lists
|
||||
</h1>
|
||||
|
||||
{/* FAB / inline button — non-functional placeholder until Plan 04-03 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="New list"
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-6, 24px) var(--space-4, 16px) var(--space-4, 16px)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div
|
||||
<h1
|
||||
style={{
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-display-size, 24px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 'var(--text-display-line-height, 1.2)',
|
||||
}}
|
||||
>
|
||||
Loading lists…
|
||||
</div>
|
||||
)}
|
||||
{/* Plain text — XSS guard */}
|
||||
Lists
|
||||
</h1>
|
||||
|
||||
{/* Error state */}
|
||||
{isError && !isLoading && (
|
||||
<div
|
||||
{/* FAB / inline button — opens CreateListSheet */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="New list"
|
||||
onClick={() => setCreateListSheetOpen(true)}
|
||||
style={{
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-4)',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div
|
||||
role="list"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Could not load lists
|
||||
{/* Plain text — XSS guard */}
|
||||
Loading lists…
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && !isLoading && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
padding: 'var(--space-2, 8px) var(--space-4, 16px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard */}
|
||||
Could not load lists
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
padding: 'var(--space-2, 8px) var(--space-4, 16px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard */}
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && lists.length === 0 && <ListsEmptyState />}
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && lists.length === 0 && <ListsEmptyState />}
|
||||
|
||||
{/* List cards */}
|
||||
{!isLoading && !isError && lists.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-4, 16px)',
|
||||
paddingBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ListCard key={list.id} list={list} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* List cards */}
|
||||
{!isLoading && !isError && lists.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-4, 16px)',
|
||||
paddingBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ListCard
|
||||
key={list.id}
|
||||
list={list}
|
||||
onDelete={handleDeleteRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog (D-06) */}
|
||||
<ListDeleteDialog
|
||||
list={deleteTarget}
|
||||
onClose={handleDeleteClose}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isPending={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Create list sheet (D-01) */}
|
||||
<CreateListSheet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user