feat: re-order lists
This commit is contained in:
@@ -115,6 +115,7 @@ const listsApiMocks = vi.hoisted(() => ({
|
||||
removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
|
||||
deleteListApi: vi.fn<() => Promise<unknown>>(),
|
||||
deleteListItemApi: vi.fn<() => Promise<unknown>>(),
|
||||
orderListsApi: vi.fn<() => Promise<unknown>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/lists', () => listsApiMocks)
|
||||
@@ -531,6 +532,103 @@ describe('useListsStore', () => {
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts lists by position, breaking ties (e.g. two lists both at position 0) by newest first', async () => {
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-b',
|
||||
name: 'B',
|
||||
position: 1,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
pendingSync: false,
|
||||
})
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-a',
|
||||
name: 'A',
|
||||
position: 0,
|
||||
created_at: '2024-01-02T00:00:00.000Z',
|
||||
pendingSync: false,
|
||||
})
|
||||
// Newly created lists always come back from the server at position 0 (so
|
||||
// a new list is always on top), so a not-yet-synced local list (no
|
||||
// position of its own yet, defaulting to 0) must also win the tiebreak
|
||||
// against any existing position-0 list by virtue of being newest.
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-new',
|
||||
name: 'New',
|
||||
created_at: '2024-01-03T00:00:00.000Z',
|
||||
pendingSync: true,
|
||||
})
|
||||
await store.refresh()
|
||||
|
||||
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-new', 'list-a', 'list-b'])
|
||||
})
|
||||
|
||||
it('reorders lists optimistically and pushes the new order via the dedicated endpoint', async () => {
|
||||
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: 'list-a', name: 'A', position: 1 },
|
||||
{ id: 'list-b', name: 'B', position: 0 },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
await store.reorderLists(['list-b', 'list-a'])
|
||||
|
||||
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-b', 'list-a'])
|
||||
expect(store.lists.every((list) => list.pendingSync)).toBe(true)
|
||||
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-b', 'list-a'] })
|
||||
expect(store.lists.find((list) => list.id === 'list-a')?.pendingSync).toBe(false)
|
||||
expect(store.lists.find((list) => list.id === 'list-b')?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('supersedes a stale queued reorder instead of replaying both', async () => {
|
||||
listsApiMocks.orderListsApi.mockResolvedValue(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
await store.reorderLists(['list-b', 'list-a'])
|
||||
await store.reorderLists(['list-a', 'list-b'])
|
||||
expect(store.pendingCount).toBe(1)
|
||||
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledTimes(1)
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-a', 'list-b'] })
|
||||
})
|
||||
|
||||
it('remaps a queued reorder entry when one of its lists gets its server id assigned mid-flight', async () => {
|
||||
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-list-1', name: 'New list' })
|
||||
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: 'server-list-1', name: 'New list', position: 0 },
|
||||
{ id: 'list-a', name: 'A', position: 1 },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('New list')
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
// Queues an "orderLists" entry referencing the not-yet-synced localList.id,
|
||||
// created after the still-pending "createList" entry.
|
||||
await store.reorderLists([localList.id, 'list-a'])
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({
|
||||
list_ids: ['server-list-1', 'list-a'],
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
|
||||
let resolveGetLists!: (value: unknown[]) => void
|
||||
listsApiMocks.getListsApi.mockImplementationOnce(
|
||||
|
||||
+76
-2
@@ -19,6 +19,7 @@ import {
|
||||
removeUserFromListApi,
|
||||
deleteListApi,
|
||||
deleteListItemApi,
|
||||
orderListsApi,
|
||||
} from '@/api/lists'
|
||||
|
||||
// A burst of rapid edits (ticking off several items, typing then blurring a
|
||||
@@ -42,8 +43,18 @@ export const useListsStore = defineStore('lists', () => {
|
||||
const error = ref<string | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
|
||||
// Lists carry a server-assigned `position`, rearranged via reorderLists().
|
||||
// New lists (local-only or freshly synced) always come back as position 0
|
||||
// - by design, so a new list always lands at the top - which means several
|
||||
// lists can share a position. created_at desc breaks that tie, newest
|
||||
// first, and also covers a local list created but not yet synced (no
|
||||
// position of its own yet: defaults to 0 below).
|
||||
const sortedLists = computed(() =>
|
||||
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
|
||||
[...lists.value].sort((a, b) => {
|
||||
const positionDiff = (a.position ?? 0) - (b.position ?? 0)
|
||||
if (positionDiff !== 0) return positionDiff
|
||||
return (b.created_at ?? '').localeCompare(a.created_at ?? '')
|
||||
}),
|
||||
)
|
||||
|
||||
function itemsForList(listId: string) {
|
||||
@@ -245,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
// Applies a full reordering of the user's lists (e.g. from a drag-and-drop
|
||||
// gesture). Positions are reassigned optimistically to every list in
|
||||
// `orderedIds`, and marked pendingSync so a pull racing the queued
|
||||
// "orderLists" entry can't clobber the optimistic order before it syncs.
|
||||
async function reorderLists(orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => db.lists.update(id, { position: index, pendingSync: true })),
|
||||
)
|
||||
for (const [index, id] of orderedIds.entries()) {
|
||||
const existing = lists.value.find((entry) => entry.id === id)
|
||||
if (existing) {
|
||||
existing.position = index
|
||||
existing.pendingSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only the latest requested order matters, so any not-yet-synced
|
||||
// "orderLists" entry is superseded rather than left to also replay.
|
||||
const staleEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
|
||||
if (staleEntries.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleEntries.map((entry) => entry.id!))
|
||||
pendingCount.value = Math.max(0, pendingCount.value - staleEntries.length)
|
||||
}
|
||||
|
||||
await enqueue({
|
||||
type: 'orderLists',
|
||||
payload: { list_ids: orderedIds },
|
||||
})
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
async function deleteListItem(itemId: string) {
|
||||
await db.listItems.delete(itemId)
|
||||
removeLocalListItem(itemId)
|
||||
@@ -292,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
|
||||
payload: updatedPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// "orderLists" entries aren't tied to a single localListId (they carry
|
||||
// every list's id in payload.list_ids), so they need their own remap pass.
|
||||
const affectedOrderEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
|
||||
for (const orderEntry of affectedOrderEntries) {
|
||||
if (orderEntry.type !== 'orderLists' || !orderEntry.payload.list_ids.includes(oldId)) continue
|
||||
await db.syncQueue.update(orderEntry.id!, {
|
||||
payload: {
|
||||
list_ids: orderEntry.payload.list_ids.map((id) => (id === oldId ? newId : id)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list item id to the id assigned by
|
||||
@@ -374,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
|
||||
await deleteListItemApi(entry.payload.id)
|
||||
break
|
||||
}
|
||||
case 'orderLists': {
|
||||
await orderListsApi(entry.payload)
|
||||
await Promise.all(
|
||||
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
|
||||
)
|
||||
const affectedIds = new Set(entry.payload.list_ids)
|
||||
for (const list of lists.value) {
|
||||
if (affectedIds.has(list.id)) list.pendingSync = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +449,15 @@ export const useListsStore = defineStore('lists', () => {
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('createdAt').toArray()
|
||||
|
||||
for (const entry of queue) {
|
||||
for (const snapshotEntry of queue) {
|
||||
// Re-read the entry rather than trusting the queue snapshot: an
|
||||
// earlier iteration this same pass may have remapped a temporary id
|
||||
// referenced in this entry's payload (e.g. remapListId rewriting a
|
||||
// queued "orderLists" entry after its "createList" entry synced).
|
||||
const entry =
|
||||
snapshotEntry.id !== undefined
|
||||
? ((await db.syncQueue.get(snapshotEntry.id)) ?? snapshotEntry)
|
||||
: snapshotEntry
|
||||
try {
|
||||
await processSyncEntry(entry)
|
||||
if (entry.id !== undefined) {
|
||||
@@ -572,6 +645,7 @@ export const useListsStore = defineStore('lists', () => {
|
||||
removeUserFromList,
|
||||
deleteList,
|
||||
deleteListItem,
|
||||
reorderLists,
|
||||
sync,
|
||||
pullFromServer,
|
||||
pullListItems,
|
||||
|
||||
Reference in New Issue
Block a user