import { ComputedRef, Ref, computed, ref } from 'vue'; import { api } from 'src/services'; type Row = Record; interface PendingRowEdit { vtId: string | number; row: Row; } interface PendingRowInsert { // Stands in for vt_id until the row exists in the database; q-table's // row-key="vt_id" needs every row to carry a unique, stable key. tempId: string; // vt_id (or tempId) of the row this one was added next to. Both the grid // preview and the insert endpoint (anchor_vt_id + position, which shifts // row_index around the anchor) position by it. A pending row can anchor // another pending row; the flush resolves temp anchors to database ids. anchorKey: string; position: 'before' | 'after'; row: Row; } const TEMP_ROW_KEY_PREFIX = 'vt-pending-'; /** * Staging buffer for server-mode row changes (cell edits, added rows, deleted * rows). * * Client-mode tables keep every change in `selectedTable.source.rows` and get * written out by the toolbar Save (useTableActions.saveSettings sends the whole * source). Server-mode tables send `source.rows = []` there, so their changes * need the per-row endpoints — but they must follow the same rule: nothing * reaches the database until the user presses Save in TheTableActions. The grid * previews what is staged through `applyPendingRowChanges`, and * `flushPendingRowChanges` is what actually writes. * * Module-level state on purpose: the editor (TheTable.vue) and the Save button * handler (useTableActions.ts) live in sibling trees. Only one table is ever * staged — staging for a different table drops the previous buffer, so changes * abandoned by navigating away are never written. */ const bufferedTableId: Ref = ref(null); const pendingEdits: Ref> = ref(new Map()); const pendingInserts: Ref = ref([]); const pendingDeletes: Ref> = ref(new Set()); let tempRowCounter = 0; // Set by TheTable.vue (the owner of server-mode pagination) so a flush can // refetch the page it just wrote to. A plain module-level callback, not a // prop, for the same reason the buffer above is module-level: the editor and // the Save button handler live in sibling trees. let reloadCallback: (() => Promise) | null = null; function isPendingRowKey(key: unknown): boolean { return typeof key === 'string' && key.startsWith(TEMP_ROW_KEY_PREFIX); } function hasPending(): boolean { return ( pendingEdits.value.size > 0 || pendingInserts.value.length > 0 || pendingDeletes.value.size > 0 ); } export function usePendingRowChanges() { function registerReloadCallback(cb: (() => Promise) | null) { reloadCallback = cb; } // Anything staged at all — drives the unsaved-changes warning on navigation. const hasPendingRowChanges: ComputedRef = computed(() => hasPending() ); // How far the staged changes move `tableId`'s row count away from the // server total. Zero when the buffer belongs to a different table. function pendingRowCountDeltaFor(tableId: string | number): number { if (bufferedTableId.value !== tableId) return 0; return pendingInserts.value.length - pendingDeletes.value.size; } function clearPendingRowChanges() { bufferedTableId.value = null; pendingEdits.value = new Map(); pendingInserts.value = []; pendingDeletes.value = new Set(); } function stageFor(tableId: string | number) { if (bufferedTableId.value !== null && bufferedTableId.value !== tableId) { clearPendingRowChanges(); } bufferedTableId.value = tableId; } function stageRowEdit( tableId: string | number, vtId: string | number, row: Row ) { stageFor(tableId); // An edit to a row that hasn't been inserted yet belongs to that insert: // there is no database row to UPDATE, the values have to go out with it. if (isPendingRowKey(vtId)) { const insert = pendingInserts.value.find((i) => i.tempId === vtId); if (!insert) return; insert.row = { ...insert.row, ...row }; pendingInserts.value = [...pendingInserts.value]; return; } // Snapshot the row: a later page fetch replaces the row objects, and the // committed values must survive that. const edits = new Map(pendingEdits.value); edits.set(String(vtId), { vtId, row: { ...row } }); pendingEdits.value = edits; } /** * Stages a new row next to `anchorKey`. The staged row carries its temp key, * so a further row can be added next to it before either one is saved. */ function stageRowInsert( tableId: string | number, params: { anchorKey: string; position: 'before' | 'after'; row: Row; } ) { stageFor(tableId); const tempId = `${TEMP_ROW_KEY_PREFIX}${++tempRowCounter}`; const row: Row = { ...params.row, vt_id: tempId }; pendingInserts.value = [ ...pendingInserts.value, { ...params, tempId, row }, ]; } function stageRowDelete(tableId: string | number, vtId: string | number) { stageFor(tableId); const key = String(vtId); // Deleting a row that was never inserted just drops the staged insert; // rows added next to it re-anchor so they stay previewed in place. if (isPendingRowKey(key)) { const removed = pendingInserts.value.find((i) => i.tempId === key); if (!removed) return; pendingInserts.value = pendingInserts.value .filter((i) => i.tempId !== key) .map((i) => i.anchorKey === key ? { ...i, anchorKey: removed.anchorKey } : i ); return; } // A row on its way out has no use for a staged edit. if (pendingEdits.value.has(key)) { const edits = new Map(pendingEdits.value); edits.delete(key); pendingEdits.value = edits; } pendingDeletes.value = new Set(pendingDeletes.value).add(key); } /** * Overlays the staged changes on a fetched page so the grid shows what Save * will write: edited values applied, added rows previewed next to their * anchor, deleted rows hidden. * * Added rows are placed before deleted ones are dropped, so a row added next * to a row that is itself staged for deletion still lands in the right spot. * An added row whose anchor sits on another page simply isn't previewed here * — it is still written on Save. */ function applyPendingRowChanges(tableId: string | number, rows: Row[]): Row[] { if (bufferedTableId.value !== tableId || !hasPending()) { return rows; } let result = pendingEdits.value.size ? rows.map((row) => { const edit = pendingEdits.value.get(String(row.vt_id)); return edit ? { ...row, ...edit.row } : row; }) : [...rows]; for (const insert of pendingInserts.value) { const anchorAt = result.findIndex( (row) => String(row.vt_id) === insert.anchorKey ); if (anchorAt === -1) continue; result.splice( insert.position === 'after' ? anchorAt + 1 : anchorAt, 0, insert.row ); } if (pendingDeletes.value.size) { result = result.filter( (row) => !pendingDeletes.value.has(String(row.vt_id)) ); } return result; } /** * Writes every staged change for `tableId`. Inserts go first, in the order * they were created: each one anchors on a row that must still exist in the * database — possibly a row staged for deletion (deleted last, matching the * preview, which places added rows before dropping deleted ones), possibly * an earlier staged insert (creation order guarantees its database id is * known by then). Then edits, then deletes. * * Writes are tracked locally (tempId/key sets) instead of being spliced out * of the reactive buffer as they succeed: the buffer drives the grid's * preview overlay, and removing an entry the instant its request resolves * would blank that row out until the refetch below lands moments later — * that gap is exactly the "row vanishes, then reappears" flicker this * function used to cause. Successful entries are only removed once the * refetch (registerReloadCallback) has swapped the real rows into * serverRows, so the overlay and the fetched page change together. * * Stops at the first failure and keeps the unwritten changes staged so the * user can retry with Save; whatever already went through is still * refetched and dropped from the buffer. */ async function flushPendingRowChanges( tableId: string | number ): Promise<{ error?: unknown }> { if (bufferedTableId.value !== tableId || !hasPending()) { return {}; } let wrote = false; const writtenInsertIds = new Set(); const writtenEditKeys = new Set(); const writtenDeleteKeys = new Set(); // tempId -> real vt_id for every insert that made it to the database, so // a not-yet-flushed insert left in the buffer after a partial failure // still anchors on something that exists (its own reactive anchorKey is // otherwise never touched — only the insertQueue copies are, above). const resolvedAnchors = new Map(); // Local working copy: anchor re-pointing (below) must be visible to the // rest of this loop without touching the reactive pendingInserts (and // therefore the preview) before the refetch. const insertQueue = pendingInserts.value.map((i) => ({ ...i })); async function finish(error?: unknown) { if (wrote && reloadCallback) { await reloadCallback(); } if (writtenInsertIds.size) { pendingInserts.value = pendingInserts.value .filter((i) => !writtenInsertIds.has(i.tempId)) .map((i) => { const resolved = resolvedAnchors.get(i.anchorKey); return resolved !== undefined ? { ...i, anchorKey: resolved } : i; }); } if (writtenEditKeys.size) { const edits = new Map(pendingEdits.value); writtenEditKeys.forEach((k) => edits.delete(k)); pendingEdits.value = edits; } if (writtenDeleteKeys.size) { const deletes = new Set(pendingDeletes.value); writtenDeleteKeys.forEach((k) => deletes.delete(k)); pendingDeletes.value = deletes; } if (!error) clearPendingRowChanges(); return error === undefined ? {} : { error }; } for (const insert of insertQueue) { // vt_id is the placeholder key the grid needed; the row gets its real // one from the insert (the endpoint strips it anyway). const row = { ...insert.row }; delete row.vt_id; const { response, error } = await api.insertRow({ tableId, anchorVtId: insert.anchorKey, position: insert.position, row, }); if (error) return finish(error); wrote = true; writtenInsertIds.add(insert.tempId); // Rows staged after this one may anchor on its temp key; point the // still-queued copies at the database id so their own inserts anchor // on the real row. const newVtId = (response?.data as { vt_id?: number } | undefined) ?.vt_id; if (newVtId !== undefined) { resolvedAnchors.set(insert.tempId, String(newVtId)); for (const queued of insertQueue) { if (queued.anchorKey === insert.tempId) { queued.anchorKey = String(newVtId); } } } } for (const [key, edit] of pendingEdits.value) { const { error } = await api.updateRow({ tableId, vtId: edit.vtId, row: edit.row, }); if (error) return finish(error); wrote = true; writtenEditKeys.add(key); } for (const key of pendingDeletes.value) { const { error } = await api.deleteRow({ tableId, vtId: key }); if (error) return finish(error); wrote = true; writtenDeleteKeys.add(key); } return finish(); } return { registerReloadCallback, hasPendingRowChanges, pendingRowCountDeltaFor, stageRowEdit, stageRowInsert, stageRowDelete, applyPendingRowChanges, flushPendingRowChanges, clearPendingRowChanges, }; }