fix(lists): add items offline via the outbox; recognisable input field
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab97638361
commit
28ebbe7263
9 files changed
+152
-21
No files matched your search
@@ -47,3 +47,28 @@ describe('outbox', () => {
|
||||
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list items added offline', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('show up as stand-ins and follow edits until they are sent', async () => {
|
||||
const { pendingListItems, updateQueued, dropQueued, OFFLINE_ITEM_PREFIX } = await import('../offline');
|
||||
const milk = enqueue('POST', '/api/v1/lists/lst_1/items', { content: 'Hafermilch', category: 'Kühlregal', quantity: '' });
|
||||
const bread = enqueue('POST', '/api/v1/lists/lst_2/items', { content: 'Brot', category: '', quantity: '1' });
|
||||
enqueue('POST', '/api/v1/inbox', { content: 'kein Listeneintrag' });
|
||||
|
||||
let items = pendingListItems();
|
||||
expect(items.map((i) => [i.listId, i.content])).toEqual([
|
||||
['lst_1', 'Hafermilch'],
|
||||
['lst_2', 'Brot'],
|
||||
]);
|
||||
expect(items[0].id).toBe(OFFLINE_ITEM_PREFIX + milk.id);
|
||||
|
||||
updateQueued(milk.id, { quantity: '2' });
|
||||
dropQueued(bread.id);
|
||||
items = pendingListItems();
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].quantity).toBe('2');
|
||||
expect(getOutbox()).toHaveLength(2); // the inbox entry stays
|
||||
});
|
||||
});
|
||||
+34
-4
@@ -52,6 +52,9 @@ import {
|
||||
clearOfflineData,
|
||||
enqueue,
|
||||
flushOutbox,
|
||||
dropQueued,
|
||||
updateQueued,
|
||||
OFFLINE_ITEM_PREFIX,
|
||||
} from './offline';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -961,13 +964,32 @@ export const api = {
|
||||
},
|
||||
|
||||
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, category, quantity }),
|
||||
});
|
||||
const path = `/api/v1/lists/${listId}/items`;
|
||||
try {
|
||||
return await request<ListItem>(path, { method: 'POST', body: JSON.stringify({ content, category, quantity }) });
|
||||
} catch (err) {
|
||||
if (!(err instanceof OfflineError)) throw err;
|
||||
// Kept in the outbox and sent when the connection is back; until then a stand-in in the list
|
||||
const entry = enqueue('POST', path, { content, category, quantity });
|
||||
return {
|
||||
id: OFFLINE_ITEM_PREFIX + entry.id,
|
||||
listId,
|
||||
content,
|
||||
category,
|
||||
quantity,
|
||||
isCompleted: false,
|
||||
sortOrder: 0,
|
||||
createdAt: entry.createdAt,
|
||||
} as ListItem;
|
||||
}
|
||||
},
|
||||
|
||||
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
|
||||
// Added offline and checked off before it was sent: nothing left to send
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX) && isCompleted) {
|
||||
dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length));
|
||||
return { id: itemId, listId, isCompleted: true } as ListItem;
|
||||
}
|
||||
try {
|
||||
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -985,6 +1007,10 @@ export const api = {
|
||||
itemId: string,
|
||||
payload: { content?: string; category?: string; quantity?: string }
|
||||
): Promise<ListItem> {
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||
updateQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length), payload);
|
||||
return { id: itemId, listId, ...payload } as ListItem;
|
||||
}
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -992,6 +1018,10 @@ export const api = {
|
||||
},
|
||||
|
||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||
dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
|
||||
@@ -154,6 +154,43 @@ export function enqueue(method: OutboxEntry['method'], path: string, body: Recor
|
||||
|
||||
export const outboxCount = () => getOutbox().length;
|
||||
|
||||
/** Changes the body of a queued entry (e.g. the quantity of a list item added offline). */
|
||||
export function updateQueued(entryId: string, body: Record<string, unknown>): boolean {
|
||||
const entries = getOutbox();
|
||||
const entry = entries.find((e) => e.id === entryId);
|
||||
if (!entry) return false;
|
||||
entry.body = { ...entry.body, ...body };
|
||||
saveOutbox(entries);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes a queued entry that is no longer wanted (e.g. a list item added and deleted while offline). */
|
||||
export function dropQueued(entryId: string): boolean {
|
||||
const entries = getOutbox();
|
||||
const rest = entries.filter((e) => e.id !== entryId);
|
||||
if (rest.length === entries.length) return false;
|
||||
saveOutbox(rest);
|
||||
return true;
|
||||
}
|
||||
|
||||
// List items added offline: a stand-in id until the server knows them, derived from the outbox entry
|
||||
export const OFFLINE_ITEM_PREFIX = 'itm_offline_';
|
||||
const LIST_ITEMS_PATH = /^\/api\/v1\/lists\/([^/]+)\/items$/;
|
||||
|
||||
/** List items still waiting in the outbox, as stand-ins for the list view (also after a reload). */
|
||||
export function pendingListItems(): { listId: string; id: string; content: string; category: string; quantity: string; createdAt: string }[] {
|
||||
return getOutbox()
|
||||
.filter((e) => e.method === 'POST' && LIST_ITEMS_PATH.test(e.path))
|
||||
.map((e) => ({
|
||||
listId: (e.path.match(LIST_ITEMS_PATH) as RegExpMatchArray)[1],
|
||||
id: OFFLINE_ITEM_PREFIX + e.id,
|
||||
content: String(e.body.content || ''),
|
||||
category: String(e.body.category || ''),
|
||||
quantity: String(e.body.quantity || ''),
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
let flushing: Promise<number> | null = null;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user