style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -11,10 +11,10 @@
|
||||
* Uses React Query's QueryClient directly (no mocked server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { act } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import type { ListItem, ListItemsResponse } from '../api/listsClient.js';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,94 +26,88 @@ function makeItem(overrides: Partial<ListItem> = {}): ListItem {
|
||||
checked: false,
|
||||
rank: 'a0',
|
||||
...overrides,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeItemsResponse(items: ListItem[]): ListItemsResponse {
|
||||
return { items }
|
||||
return { items };
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ListDetail — D-07 optimistic update + rollback', () => {
|
||||
let queryClient: QueryClient
|
||||
const LIST_ID = 10
|
||||
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]))
|
||||
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] })
|
||||
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,
|
||||
),
|
||||
}))
|
||||
})
|
||||
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)
|
||||
})
|
||||
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]))
|
||||
const item = makeItem({ checked: true });
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
|
||||
|
||||
await act(async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
|
||||
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,
|
||||
),
|
||||
}))
|
||||
})
|
||||
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)
|
||||
})
|
||||
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', () => {
|
||||
const item = makeItem({ checked: false })
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
|
||||
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])
|
||||
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
|
||||
|
||||
// Step 2: apply optimistic update
|
||||
act(() => {
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
||||
items: (old?.items ?? []).map((i) =>
|
||||
i.id === item.id ? { ...i, checked: true } : i,
|
||||
),
|
||||
}))
|
||||
})
|
||||
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)
|
||||
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
|
||||
expect(after?.items[0].checked).toBe(true);
|
||||
|
||||
// Step 3: simulate onError rollback
|
||||
act(() => {
|
||||
if (previous) {
|
||||
queryClient.setQueryData(['list', LIST_ID], 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)
|
||||
})
|
||||
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)', () => {
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]));
|
||||
|
||||
const optimisticItem: ListItem = {
|
||||
id: -Date.now(),
|
||||
@@ -121,30 +115,30 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
||||
text: 'milk',
|
||||
checked: false,
|
||||
rank: 'a0',
|
||||
}
|
||||
};
|
||||
|
||||
act(() => {
|
||||
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')
|
||||
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)
|
||||
})
|
||||
expect(data?.items[0].id).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('removes the optimistically-added item if the server POST returns an error', () => {
|
||||
const existingItem = makeItem({ id: 1 })
|
||||
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])
|
||||
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
|
||||
|
||||
// Apply optimistic add
|
||||
const optimisticItem: ListItem = {
|
||||
@@ -153,65 +147,63 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
||||
text: 'optimistic',
|
||||
checked: false,
|
||||
rank: 'a1',
|
||||
}
|
||||
};
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
||||
items: [...(old?.items ?? []), optimisticItem],
|
||||
}))
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items,
|
||||
).toHaveLength(2)
|
||||
expect(queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items).toHaveLength(2);
|
||||
|
||||
// Simulate rollback on error
|
||||
act(() => {
|
||||
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
|
||||
})
|
||||
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
|
||||
})
|
||||
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]
|
||||
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)
|
||||
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')
|
||||
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' })
|
||||
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] })
|
||||
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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
* Security: item text rendered as plain-text JSX children (T-04-06 XSS guard).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -39,25 +39,20 @@ import {
|
||||
useSensors,
|
||||
useSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { generateKeyBetween } from 'fractional-indexing'
|
||||
import {
|
||||
fetchListItems,
|
||||
addItem,
|
||||
patchListItem,
|
||||
deleteItem,
|
||||
} from '../api/listsClient.js'
|
||||
import { ItemRow } from '../components/ItemRow.js'
|
||||
import { AddItemInput } from '../components/AddItemInput.js'
|
||||
import { LiveSyncIndicator } from '../components/LiveSyncIndicator.js'
|
||||
import { useListSSE } from '../hooks/useListSSE.js'
|
||||
import type { SyncState } from '../hooks/useListSSE.js'
|
||||
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
|
||||
} from '@dnd-kit/sortable';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
import { fetchListItems, addItem, patchListItem, deleteItem } from '../api/listsClient.js';
|
||||
import { ItemRow } from '../components/ItemRow.js';
|
||||
import { AddItemInput } from '../components/AddItemInput.js';
|
||||
import { LiveSyncIndicator } from '../components/LiveSyncIndicator.js';
|
||||
import { useListSSE } from '../hooks/useListSSE.js';
|
||||
import type { SyncState } from '../hooks/useListSSE.js';
|
||||
import type { ListItem, ListItemsResponse } from '../api/listsClient.js';
|
||||
|
||||
/**
|
||||
* ListEmptyState — shown inside ListDetail when list has no items.
|
||||
@@ -97,23 +92,23 @@ function ListEmptyState() {
|
||||
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 { listId } = useParams<{ listId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const parsedListId = Number(listId);
|
||||
|
||||
const [completedExpanded, setCompletedExpanded] = useState(true)
|
||||
const [syncState, setSyncState] = useState<SyncState>('connected')
|
||||
const [completedExpanded, setCompletedExpanded] = useState(true);
|
||||
const [syncState, setSyncState] = useState<SyncState>('connected');
|
||||
|
||||
// ── Live sync (LIST-04) ──────────────────────────────────────────────────
|
||||
// Bounded-backoff SSE hook (D-11). On open: invalidates ['list', listId] (D-10).
|
||||
// On event: invalidates ['list', listId] → background refetch.
|
||||
// Polling fallback: refetchInterval:30000 below stays always active (D-12).
|
||||
useListSSE({ listId: parsedListId, onStateChange: setSyncState })
|
||||
useListSSE({ listId: parsedListId, onStateChange: setSyncState });
|
||||
|
||||
// ── dnd-kit sensors ────────────────────────────────────────────────────────
|
||||
// PointerSensor: desktop mouse — immediate activation
|
||||
@@ -131,7 +126,7 @@ export function ListDetail() {
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
// ── Data fetch ─────────────────────────────────────────────────────────────
|
||||
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
|
||||
@@ -140,25 +135,25 @@ export function ListDetail() {
|
||||
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 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)
|
||||
.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])
|
||||
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 optimisticRank = generateKeyBetween(lastRank, null)
|
||||
const lastRank = activeItems.at(-1)?.rank ?? null;
|
||||
const optimisticRank = generateKeyBetween(lastRank, null);
|
||||
|
||||
const optimisticItem: ListItem = {
|
||||
id: -Date.now(), // temporary negative id
|
||||
@@ -166,111 +161,109 @@ export function ListDetail() {
|
||||
text,
|
||||
checked: false,
|
||||
rank: optimisticRank,
|
||||
}
|
||||
};
|
||||
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
|
||||
items: [...(old?.items ?? []), optimisticItem],
|
||||
}))
|
||||
}));
|
||||
|
||||
return { previous, optimisticItem }
|
||||
return { previous, optimisticItem };
|
||||
},
|
||||
onError: (_err, _text, context) => {
|
||||
// Rollback on rejection
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous)
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||
void 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])
|
||||
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,
|
||||
),
|
||||
}))
|
||||
items: (old?.items ?? []).map((item) => (item.id === itemId ? { ...item, checked } : item)),
|
||||
}));
|
||||
|
||||
return { previous }
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous)
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||
void 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] })
|
||||
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: () => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// ── Reorder mutation (D-13 single-row rank write, D-15 LWW convergence) ──
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: ({ itemId, position }: { itemId: number; position: string }) =>
|
||||
patchListItem(itemId, { position }),
|
||||
onMutate: async ({ itemId, position }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
|
||||
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
|
||||
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] });
|
||||
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId]);
|
||||
|
||||
// Optimistic: update the moved item's rank in cache immediately
|
||||
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
|
||||
items: (old?.items ?? []).map((item) =>
|
||||
item.id === itemId ? { ...item, rank: position } : item,
|
||||
),
|
||||
}))
|
||||
}));
|
||||
|
||||
return { previous }
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
// Rollback: animate back to previous order (D-14 — CSS transition in ItemRow handles animation)
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous)
|
||||
queryClient.setQueryData(['list', parsedListId], context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function handleAdd(text: string) {
|
||||
addMutation.mutate(text)
|
||||
addMutation.mutate(text);
|
||||
}
|
||||
|
||||
function handleCheck(itemId: number, checked: boolean) {
|
||||
checkMutation.mutate({ itemId, checked })
|
||||
checkMutation.mutate({ itemId, checked });
|
||||
}
|
||||
|
||||
function handleDelete(itemId: number) {
|
||||
deleteMutation.mutate(itemId)
|
||||
deleteMutation.mutate(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,29 +282,29 @@ export function ListDetail() {
|
||||
* via SSE (Plan 06) or the 30s polling fallback (D-12, D-15).
|
||||
*/
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
const { active, over } = event;
|
||||
|
||||
// No-op: dropped outside or on itself
|
||||
if (!over || active.id === over.id) return
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// Find source and destination indices in the sorted active list
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === active.id)
|
||||
const overIndex = activeItems.findIndex((i) => i.id === over.id)
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === active.id);
|
||||
const overIndex = activeItems.findIndex((i) => i.id === over.id);
|
||||
|
||||
if (activeIndex === -1 || overIndex === -1) return
|
||||
if (activeIndex === -1 || overIndex === -1) return;
|
||||
|
||||
// Build the new order by moving the item to the target position
|
||||
const newOrder = [...activeItems]
|
||||
const [moved] = newOrder.splice(activeIndex, 1)
|
||||
newOrder.splice(overIndex, 0, moved)
|
||||
const newOrder = [...activeItems];
|
||||
const [moved] = newOrder.splice(activeIndex, 1);
|
||||
newOrder.splice(overIndex, 0, moved);
|
||||
|
||||
// Derive prevRank/nextRank from the reordered list at the moved item's new position
|
||||
const prevRank = newOrder[overIndex - 1]?.rank ?? null
|
||||
const nextRank = newOrder[overIndex + 1]?.rank ?? null
|
||||
const prevRank = newOrder[overIndex - 1]?.rank ?? null;
|
||||
const nextRank = newOrder[overIndex + 1]?.rank ?? null;
|
||||
|
||||
const newRank = generateKeyBetween(prevRank, nextRank)
|
||||
const newRank = generateKeyBetween(prevRank, nextRank);
|
||||
|
||||
reorderMutation.mutate({ itemId: Number(active.id), position: newRank })
|
||||
reorderMutation.mutate({ itemId: Number(active.id), position: newRank });
|
||||
}
|
||||
|
||||
// ── Loading / error states ─────────────────────────────────────────────────
|
||||
@@ -327,7 +320,7 @@ export function ListDetail() {
|
||||
>
|
||||
Invalid list.
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
@@ -355,7 +348,9 @@ export function ListDetail() {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => { void navigate('/lists') }}
|
||||
onClick={() => {
|
||||
void navigate('/lists');
|
||||
}}
|
||||
aria-label="Back to lists"
|
||||
style={{
|
||||
background: 'none',
|
||||
@@ -501,5 +496,5 @@ export function ListDetail() {
|
||||
{/* Sticky add-item input */}
|
||||
<AddItemInput onAdd={handleAdd} isPending={addMutation.isPending} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,75 +18,75 @@
|
||||
* as plain-text JSX children — no dangerouslySetInnerHTML anywhere.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
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'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Plus } from 'lucide-react';
|
||||
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 navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<List | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<List | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['lists'],
|
||||
queryFn: fetchLists,
|
||||
retry: 2,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
const lists = data?.lists ?? []
|
||||
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'])
|
||||
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 }
|
||||
}));
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['lists'], context.previous)
|
||||
queryClient.setQueryData(['lists'], context.previous);
|
||||
}
|
||||
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
|
||||
},
|
||||
onSettled: () => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void navigate('/lists')
|
||||
setDeleteTarget(null)
|
||||
void navigate('/lists');
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleDeleteRequest = (list: List) => {
|
||||
setDeleteTarget(list)
|
||||
}
|
||||
setDeleteTarget(list);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) return
|
||||
deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate(deleteTarget.id);
|
||||
};
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,7 +196,9 @@ export function ListsIndex() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refetch() }}
|
||||
onClick={() => {
|
||||
void refetch();
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
@@ -229,11 +231,7 @@ export function ListsIndex() {
|
||||
}}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ListCard
|
||||
key={list.id}
|
||||
list={list}
|
||||
onDelete={handleDeleteRequest}
|
||||
/>
|
||||
<ListCard key={list.id} list={list} onDelete={handleDeleteRequest} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -251,5 +249,5 @@ export function ListsIndex() {
|
||||
{/* Create list sheet (D-01) */}
|
||||
<CreateListSheet />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user