@open-mercato/ui 0.6.1-develop.3219.1.ae5c8a5935 → 0.6.1-develop.3246.1.dbef9d7392
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/operations/LastOperationBanner.js +15 -9
- package/dist/backend/operations/LastOperationBanner.js.map +2 -2
- package/dist/backend/operations/store.js +64 -8
- package/dist/backend/operations/store.js.map +2 -2
- package/dist/backend/utils/bulkDelete.js +46 -0
- package/dist/backend/utils/bulkDelete.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/operations/LastOperationBanner.tsx +19 -11
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +46 -1
- package/src/backend/operations/__tests__/store.test.ts +142 -0
- package/src/backend/operations/store.ts +76 -7
- package/src/backend/utils/__tests__/bulkDelete.test.ts +103 -0
- package/src/backend/utils/bulkDelete.ts +77 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -21,18 +21,23 @@ function LastOperationBanner() {
|
|
|
21
21
|
async function handleUndo() {
|
|
22
22
|
const undoToken = operation?.undoToken;
|
|
23
23
|
if (!undoToken || isPending) return;
|
|
24
|
+
const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [undoToken];
|
|
24
25
|
setPendingToken(undoToken);
|
|
26
|
+
const completed = [];
|
|
25
27
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
for (const token of tokens.slice().reverse()) {
|
|
29
|
+
const call = await apiCall("/api/audit_logs/audit-logs/actions/undo", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ undoToken: token })
|
|
33
|
+
});
|
|
34
|
+
if (!call.ok) {
|
|
35
|
+
const message = call.result && typeof call.result.error === "string" && call.result.error || "";
|
|
36
|
+
throw new Error(message || t("audit_logs.banner.undo_failed", "Failed to undo"));
|
|
37
|
+
}
|
|
38
|
+
completed.push(token);
|
|
34
39
|
}
|
|
35
|
-
markUndoSuccess(
|
|
40
|
+
markUndoSuccess(tokens);
|
|
36
41
|
flash(t("audit_logs.banner.undo_success"), "success");
|
|
37
42
|
router.refresh();
|
|
38
43
|
if (typeof window !== "undefined") {
|
|
@@ -45,6 +50,7 @@ function LastOperationBanner() {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
} catch (err) {
|
|
53
|
+
if (completed.length > 0) markUndoSuccess(completed);
|
|
48
54
|
const message = err instanceof Error && err.message ? err.message : t("audit_logs.banner.undo_error");
|
|
49
55
|
flash(message, "error");
|
|
50
56
|
} finally {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/operations/LastOperationBanner.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n const isPending = pendingToken === operation.undoToken\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n setPendingToken(undoToken)\n try {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n const isPending = pendingToken === operation.undoToken\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0\n ? operation.bulkUndoTokens\n : [undoToken]\n setPendingToken(undoToken)\n const completed: string[] = []\n try {\n for (const token of tokens.slice().reverse()) {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken: token }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n completed.push(token)\n }\n markUndoSuccess(tokens)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n if (completed.length > 0) markUndoSuccess(completed)\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-amber-950\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-amber-900\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAwEM,SACE,KADF;AAvEN,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AAEd,SAAS,sBAAsB;AACpC,QAAM,IAAI,KAAK;AACf,QAAM,YAAY,iBAAiB;AACnC,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAwB,IAAI;AAC1E,QAAM,SAAS,UAAU;AAEzB,MAAI,CAAC,UAAW,QAAO;AAEvB,QAAM,WAAW,UAAU,eAAe,UAAU;AACpD,QAAM,kBAAkB,EAAE,QAAQ;AAClC,QAAM,QAAQ,oBAAoB,WAAW,WAAW;AACxD,QAAM,YAAY,iBAAiB,UAAU;AAE7C,iBAAe,aAAa;AAC1B,UAAM,YAAY,WAAW;AAC7B,QAAI,CAAC,aAAa,UAAW;AAC7B,UAAM,SAAS,UAAU,kBAAkB,UAAU,eAAe,SAAS,IACzE,UAAU,iBACV,CAAC,SAAS;AACd,oBAAgB,SAAS;AACzB,UAAM,YAAsB,CAAC;AAC7B,QAAI;AACF,iBAAW,SAAS,OAAO,MAAM,EAAE,QAAQ,GAAG;AAC5C,cAAM,OAAO,MAAM,QAAiC,2CAA2C;AAAA,UAC7F,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,WAAW,MAAM,CAAC;AAAA,QAC3C,CAAC;AACD,YAAI,CAAC,KAAK,IAAI;AACZ,gBAAM,UACH,KAAK,UAAU,OAAO,KAAK,OAAO,UAAU,YAAY,KAAK,OAAO,SACrE;AACF,gBAAM,IAAI,MAAM,WAAW,EAAE,iCAAiC,gBAAgB,CAAC;AAAA,QACjF;AACA,kBAAU,KAAK,KAAK;AAAA,MACtB;AACA,sBAAgB,MAAM;AACtB,YAAM,EAAE,gCAAgC,GAAG,SAAS;AACpD,aAAO,QAAQ;AACf,UAAI,OAAO,WAAW,aAAa;AACjC,YAAI;AACF,gBAAM,UAAU,OAAO,cAAc,eAAe,OAAO,UAAU,cAAc,WAC/E,UAAU,UAAU,YAAY,EAAE,SAAS,OAAO,IAClD;AACJ,cAAI,CAAC,WAAW,OAAO,OAAO,UAAU,WAAW,YAAY;AAC7D,mBAAO,SAAS,OAAO;AAAA,UACzB;AAAA,QACF,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,UAAU,SAAS,EAAG,iBAAgB,SAAS;AACnD,YAAM,UAAU,eAAe,SAAS,IAAI,UAAU,IAAI,UAAU,EAAE,8BAA8B;AACpG,YAAM,SAAS,OAAO;AAAA,IACxB,UAAE;AACA,sBAAgB,IAAI;AAAA,IACtB;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,6JACb;AAAA,yBAAC,SAAI,WAAU,oBACb;AAAA,0BAAC,UAAK,WAAU,8BACb,YAAE,kCAAkC,GACvC;AAAA,MACA,oBAAC,UAAK,WAAU,gCACb,iBACH;AAAA,OACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM;AAAE,eAAK,WAAW;AAAA,QAAE;AAAA,QACnC,UAAU;AAAA,QACV,WAAU;AAAA,QAEV;AAAA,8BAAC,SAAM,WAAU,eAAc,eAAY,QAAO;AAAA,UACjD,YAAY,EAAE,4BAA4B,IAAI,EAAE,wBAAwB;AAAA;AAAA;AAAA,IAC3E;AAAA,KACF;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -38,6 +38,8 @@ function isValidEntry(entry) {
|
|
|
38
38
|
}
|
|
39
39
|
function hydrateEntry(entry) {
|
|
40
40
|
const source = entry;
|
|
41
|
+
const bulkTokens = Array.isArray(source.bulkUndoTokens) ? source.bulkUndoTokens.filter((t) => typeof t === "string" && t.length > 0) : void 0;
|
|
42
|
+
const bulkCount = typeof source.bulkCount === "number" && Number.isFinite(source.bulkCount) ? source.bulkCount : void 0;
|
|
41
43
|
return {
|
|
42
44
|
id: String(source.id),
|
|
43
45
|
undoToken: String(source.undoToken),
|
|
@@ -46,7 +48,9 @@ function hydrateEntry(entry) {
|
|
|
46
48
|
resourceKind: typeof source.resourceKind === "string" ? source.resourceKind : null,
|
|
47
49
|
resourceId: typeof source.resourceId === "string" ? source.resourceId : null,
|
|
48
50
|
executedAt: typeof source.executedAt === "string" ? source.executedAt : new Date(source.receivedAt || now()).toISOString(),
|
|
49
|
-
receivedAt: typeof source.receivedAt === "number" ? source.receivedAt : now()
|
|
51
|
+
receivedAt: typeof source.receivedAt === "number" ? source.receivedAt : now(),
|
|
52
|
+
...bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {},
|
|
53
|
+
...bulkCount && bulkCount > 0 ? { bulkCount } : {}
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
56
|
function persist(state) {
|
|
@@ -104,19 +108,70 @@ function pushOperation(meta) {
|
|
|
104
108
|
return { stack, undone: [] };
|
|
105
109
|
});
|
|
106
110
|
}
|
|
107
|
-
function markUndoSuccess(
|
|
111
|
+
function markUndoSuccess(undoTokens) {
|
|
108
112
|
if (typeof window === "undefined") return;
|
|
113
|
+
const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens]);
|
|
114
|
+
if (tokenSet.size === 0) return;
|
|
109
115
|
const removed = [];
|
|
110
116
|
updateState((prev) => {
|
|
111
|
-
const
|
|
112
|
-
|
|
117
|
+
const nextStack = [];
|
|
118
|
+
for (const entry of prev.stack) {
|
|
119
|
+
const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null;
|
|
120
|
+
if (!bulk) {
|
|
121
|
+
if (tokenSet.has(entry.undoToken)) removed.push(entry);
|
|
122
|
+
else nextStack.push(entry);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const consumed = [];
|
|
126
|
+
const remaining = [];
|
|
127
|
+
for (const token of bulk) {
|
|
128
|
+
if (tokenSet.has(token)) consumed.push(token);
|
|
129
|
+
else remaining.push(token);
|
|
130
|
+
}
|
|
131
|
+
if (consumed.length === 0) {
|
|
132
|
+
nextStack.push(entry);
|
|
133
|
+
} else if (remaining.length === 0) {
|
|
113
134
|
removed.push(entry);
|
|
114
|
-
|
|
135
|
+
} else {
|
|
136
|
+
removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length });
|
|
137
|
+
nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length });
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
});
|
|
139
|
+
}
|
|
118
140
|
const undone = removed.length ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))] : prev.undone;
|
|
119
|
-
return { stack, undone };
|
|
141
|
+
return { stack: nextStack, undone };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function generateBulkId(seed) {
|
|
145
|
+
const cryptoRef = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
146
|
+
if (cryptoRef && typeof cryptoRef.randomUUID === "function") {
|
|
147
|
+
return `bulk:${cryptoRef.randomUUID()}`;
|
|
148
|
+
}
|
|
149
|
+
return `bulk:${seed}:${now()}`;
|
|
150
|
+
}
|
|
151
|
+
function coalesceLastOperations(count, options = {}) {
|
|
152
|
+
if (typeof window === "undefined" || count <= 1) return;
|
|
153
|
+
updateState((prev) => {
|
|
154
|
+
if (prev.stack.length < count) return prev;
|
|
155
|
+
const tail = prev.stack.slice(-count);
|
|
156
|
+
if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {
|
|
157
|
+
return prev;
|
|
158
|
+
}
|
|
159
|
+
const head = prev.stack.slice(0, prev.stack.length - count);
|
|
160
|
+
const last = tail[tail.length - 1];
|
|
161
|
+
const tokens = tail.map((entry) => entry.undoToken);
|
|
162
|
+
const bulkId = generateBulkId(last.id);
|
|
163
|
+
const synthetic = {
|
|
164
|
+
...last,
|
|
165
|
+
id: bulkId,
|
|
166
|
+
undoToken: bulkId,
|
|
167
|
+
actionLabel: options.actionLabel ?? last.actionLabel,
|
|
168
|
+
resourceKind: options.resourceKind ?? last.resourceKind,
|
|
169
|
+
resourceId: null,
|
|
170
|
+
bulkUndoTokens: tokens,
|
|
171
|
+
bulkCount: tail.length,
|
|
172
|
+
receivedAt: now()
|
|
173
|
+
};
|
|
174
|
+
return { stack: [...head, synthetic], undone: prev.undone };
|
|
120
175
|
});
|
|
121
176
|
}
|
|
122
177
|
function markRedoConsumed(logId) {
|
|
@@ -170,6 +225,7 @@ const operationStackConstants = {
|
|
|
170
225
|
};
|
|
171
226
|
export {
|
|
172
227
|
clearAllOperations,
|
|
228
|
+
coalesceLastOperations,
|
|
173
229
|
getLastOperation,
|
|
174
230
|
hasRedoCandidate,
|
|
175
231
|
markRedoConsumed,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/operations/store.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoToken: string) {\n if (typeof window === 'undefined') return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const stack = prev.stack.filter((entry) => {\n if (entry.undoToken === undoToken) {\n removed.push(entry)\n return false\n }\n return true\n })\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack, undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
|
|
5
|
+
"mappings": ";AACA,YAAY,WAAW;AAkBvB,MAAM,gBAAqC,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,EAAE;AAEnE,MAAM,cAAc;AACpB,MAAM,cAAc;AACpB,MAAM,wBAAwB;AAC9B,MAAM,qBAAqB,KAAK;AAEhC,IAAI,gBAAqC;AAEzC,IAAI,OAAO,WAAW,aAAa;AACjC,kBAAgB,UAAU;AAC5B;AAEA,MAAM,UAAU,IAAI,YAAY;AAEhC,SAAS,MAAM;AACb,SAAO,OAAO,gBAAgB,eAAe,YAAY,MACrD,KAAK,MAAM,YAAY,aAAa,YAAY,IAAI,CAAC,IACrD,KAAK,IAAI;AACf;AAEA,SAAS,YAAiC;AACxC,MAAI;AACF,UAAM,MAAM,OAAO,aAAa,QAAQ,WAAW;AACnD,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,UAAM,QAAQ,MAAM,QAAQ,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,YAAY,EAAE,IAAI,YAAY,IAAI,CAAC;AACnG,UAAM,SAAS,MAAM,QAAQ,OAAO,MAAM,IACtC,OAAO,OAAO,OAAO,YAAY,EAAE,IAAI,CAACA,SAAiB;AACvD,YAAM,WAAW,aAAaA,IAAG;AACjC,YAAM,YAAYA;AAClB,YAAM,WAAW,OAAO,UAAU,aAAa,WAAW,UAAU,WAAW,IAAI;AACnF,aAAO,EAAE,GAAG,UAAU,SAAS;AAAA,IACjC,CAAC,IACD,CAAC;AACL,WAAO,WAAW,EAAE,OAAO,OAAO,CAAC;AAAA,EACrC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,OAAyC;AAC7D,MAAI,SAAS,QAAQ,OAAO,UAAU,SAAU,QAAO;AACvD,QAAM,YAAY;AAClB,SACE,OAAO,UAAU,OAAO,YACrB,OAAO,UAAU,cAAc,YAC/B,OAAO,UAAU,cAAc,YAC/B,OAAO,UAAU,eAAe,YAChC,OAAO,UAAU,eAAe;AAEvC;AAEA,SAAS,aAAa,OAAgC;AACpD,QAAM,SAAS;AACf,QAAM,aAAa,MAAM,QAAQ,OAAO,cAAc,IAClD,OAAO,eAAe,OAAO,CAAC,MAAmB,OAAO,MAAM,YAAY,EAAE,SAAS,CAAC,IACtF;AACJ,QAAM,YAAY,OAAO,OAAO,cAAc,YAAY,OAAO,SAAS,OAAO,SAAS,IACtF,OAAO,YACP;AACJ,SAAO;AAAA,IACL,IAAI,OAAO,OAAO,EAAE;AAAA,IACpB,WAAW,OAAO,OAAO,SAAS;AAAA,IAClC,WAAW,OAAO,OAAO,SAAS;AAAA,IAClC,aAAa,OAAO,OAAO,gBAAgB,WAAW,OAAO,cAAc;AAAA,IAC3E,cAAc,OAAO,OAAO,iBAAiB,WAAW,OAAO,eAAe;AAAA,IAC9E,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAAA,IACxE,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa,IAAI,KAAM,OAAO,cAAqC,IAAI,CAAC,EAAE,YAAY;AAAA,IACjJ,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa,IAAI;AAAA,IAC5E,GAAI,cAAc,WAAW,SAAS,IAAI,EAAE,gBAAgB,WAAW,IAAI,CAAC;AAAA,IAC5E,GAAI,aAAa,YAAY,IAAI,EAAE,UAAU,IAAI,CAAC;AAAA,EACpD;AACF;AAEA,SAAS,QAAQ,OAA4B;AAC3C,MAAI,OAAO,WAAW,YAAa;AACnC,MAAI;AACF,WAAO,aAAa,QAAQ,aAAa,KAAK,UAAU,KAAK,CAAC;AAAA,EAChE,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,WAAW,OAAiD;AACnE,QAAM,YAAY,IAAI;AACtB,QAAM,QAAQ,MAAM,MACjB,OAAO,CAAC,OAAO,OAAO,QAAQ;AAE7B,UAAM,iBAAiB,IAAI,UAAU,CAAC,cAAc,UAAU,OAAO,MAAM,MAAM,UAAU,cAAc,MAAM,SAAS;AACxH,QAAI,mBAAmB,MAAO,QAAO;AACrC,WAAO,YAAY,MAAM,cAAc;AAAA,EACzC,CAAC,EACA,KAAK,CAAC,GAAG,MAAM,EAAE,aAAa,EAAE,UAAU,EAC1C,MAAM,CAAC,WAAW;AACrB,QAAM,SAAS,MAAM,OAClB,OAAO,CAAC,UAAU,YAAY,MAAM,YAAY,kBAAkB,EAClE,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ,EACtC,MAAM,CAAC,WAAW;AACrB,QAAM,OAAO,EAAE,OAAO,OAAO;AAC7B,SAAO;AACT;AAEA,SAAS,OAAO;AACd,UAAQ,cAAc,IAAI,MAAM,QAAQ,CAAC;AAC3C;AAEA,SAAS,YAAY,SAA6D;AAChF,QAAM,OAAO,WAAW,QAAQ,aAAa,CAAC;AAC9C,kBAAgB;AAChB,UAAQ,IAAI;AACZ,OAAK;AACP;AAEA,SAAS,UAAU,UAAsB;AACvC,QAAM,UAAU,MAAM,SAAS;AAC/B,UAAQ,iBAAiB,UAAU,OAAO;AAC1C,SAAO,MAAM,QAAQ,oBAAoB,UAAU,OAAO;AAC5D;AAEA,SAAS,oBAAyC;AAChD,kBAAgB,WAAW,aAAa;AACxC,SAAO;AACT;AAEO,SAAS,kBAAqB,UAAgD;AACnF,SAAO,MAAM;AAAA,IACX;AAAA,IACA,MAAM,SAAS,kBAAkB,CAAC;AAAA,IAClC,MAAM,SAAS,aAAa;AAAA,EAC9B;AACF;AAEO,SAAS,cAAc,MAAgC;AAC5D,MAAI,OAAO,WAAW,YAAa;AACnC,cAAY,CAAC,SAAS;AACpB,UAAM,QAAwB;AAAA,MAC5B,GAAG;AAAA,MACH,YAAY,IAAI;AAAA,IAClB;AACA,UAAM,QAAQ,KAAK,MAAM,OAAO,CAAC,SAAS,KAAK,OAAO,MAAM,MAAM,KAAK,cAAc,MAAM,SAAS;AACpG,UAAM,KAAK,KAAK;AAChB,WAAO,EAAE,OAAO,QAAQ,CAAC,EAAE;AAAA,EAC7B,CAAC;AACH;AAEO,SAAS,gBAAgB,YAA+B;AAC7D,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,WAAW,IAAI,IAAI,MAAM,QAAQ,UAAU,IAAI,aAAa,CAAC,UAAU,CAAC;AAC9E,MAAI,SAAS,SAAS,EAAG;AACzB,QAAM,UAA4B,CAAC;AACnC,cAAY,CAAC,SAAS;AACpB,UAAM,YAA8B,CAAC;AACrC,eAAW,SAAS,KAAK,OAAO;AAC9B,YAAM,OAAO,MAAM,kBAAkB,MAAM,eAAe,SAAS,IAAI,MAAM,iBAAiB;AAC9F,UAAI,CAAC,MAAM;AACT,YAAI,SAAS,IAAI,MAAM,SAAS,EAAG,SAAQ,KAAK,KAAK;AAAA,YAChD,WAAU,KAAK,KAAK;AACzB;AAAA,MACF;AACA,YAAM,WAAqB,CAAC;AAC5B,YAAM,YAAsB,CAAC;AAC7B,iBAAW,SAAS,MAAM;AACxB,YAAI,SAAS,IAAI,KAAK,EAAG,UAAS,KAAK,KAAK;AAAA,YACvC,WAAU,KAAK,KAAK;AAAA,MAC3B;AACA,UAAI,SAAS,WAAW,GAAG;AACzB,kBAAU,KAAK,KAAK;AAAA,MACtB,WAAW,UAAU,WAAW,GAAG;AACjC,gBAAQ,KAAK,KAAK;AAAA,MACpB,OAAO;AACL,gBAAQ,KAAK,EAAE,GAAG,OAAO,gBAAgB,UAAU,WAAW,SAAS,OAAO,CAAC;AAC/E,kBAAU,KAAK,EAAE,GAAG,OAAO,gBAAgB,WAAW,WAAW,UAAU,OAAO,CAAC;AAAA,MACrF;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,SACnB,CAAC,GAAG,KAAK,QAAQ,GAAG,QAAQ,IAAI,CAAC,WAAW,EAAE,GAAG,OAAO,UAAU,IAAI,EAAE,EAAE,CAAC,IAC3E,KAAK;AACT,WAAO,EAAE,OAAO,WAAW,OAAO;AAAA,EACpC,CAAC;AACH;AAQA,SAAS,eAAe,MAAsB;AAC5C,QAAM,YAAY,OAAO,eAAe,cAAe,WAA0D,SAAS;AAC1H,MAAI,aAAa,OAAO,UAAU,eAAe,YAAY;AAC3D,WAAO,QAAQ,UAAU,WAAW,CAAC;AAAA,EACvC;AACA,SAAO,QAAQ,IAAI,IAAI,IAAI,CAAC;AAC9B;AAEO,SAAS,uBAAuB,OAAe,UAA2B,CAAC,GAAS;AACzF,MAAI,OAAO,WAAW,eAAe,SAAS,EAAG;AACjD,cAAY,CAAC,SAAS;AACpB,QAAI,KAAK,MAAM,SAAS,MAAO,QAAO;AACtC,UAAM,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK;AACpC,QAAI,QAAQ,aAAa,CAAC,KAAK,MAAM,CAAC,UAAU,MAAM,cAAc,QAAQ,SAAS,GAAG;AACtF,aAAO;AAAA,IACT;AACA,UAAM,OAAO,KAAK,MAAM,MAAM,GAAG,KAAK,MAAM,SAAS,KAAK;AAC1D,UAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,UAAM,SAAS,KAAK,IAAI,CAAC,UAAU,MAAM,SAAS;AAClD,UAAM,SAAS,eAAe,KAAK,EAAE;AACrC,UAAM,YAA4B;AAAA,MAChC,GAAG;AAAA,MACH,IAAI;AAAA,MACJ,WAAW;AAAA,MACX,aAAa,QAAQ,eAAe,KAAK;AAAA,MACzC,cAAc,QAAQ,gBAAgB,KAAK;AAAA,MAC3C,YAAY;AAAA,MACZ,gBAAgB;AAAA,MAChB,WAAW,KAAK;AAAA,MAChB,YAAY,IAAI;AAAA,IAClB;AACA,WAAO,EAAE,OAAO,CAAC,GAAG,MAAM,SAAS,GAAG,QAAQ,KAAK,OAAO;AAAA,EAC5D,CAAC;AACH;AAEO,SAAS,iBAAiB,OAAe;AAC9C,MAAI,OAAO,WAAW,YAAa;AACnC,cAAY,CAAC,UAAU;AAAA,IACrB,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK,OAAO,OAAO,CAAC,UAAU,MAAM,OAAO,KAAK;AAAA,EAC1D,EAAE;AACJ;AAEO,SAAS,mBAA0C;AACxD,QAAM,QAAQ,kBAAkB;AAChC,MAAI,CAAC,MAAM,MAAM,OAAQ,QAAO;AAChC,QAAM,OAAO,MAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAC/C,QAAM,eAAe,KAAK,MAAM,KAAK,UAAU;AAC/C,QAAM,SAAS,IAAI,IAAI;AACvB,MAAI,OAAO,SAAS,YAAY,KAAK,eAAe,OAAQ,QAAO;AACnE,MAAI,CAAC,OAAO,SAAS,YAAY,KAAK,KAAK,aAAa,OAAQ,QAAO;AACvE,SAAO;AACT;AAEO,SAAS,mBAA0C;AACxD,SAAO,kBAAkB,yBAAyB;AACpD;AAEA,SAAS,0BAA0B,OAAmD;AACpF,MAAI,CAAC,MAAM,MAAM,OAAQ,QAAO;AAChC,QAAM,OAAO,MAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAC/C,QAAM,YAAY,IAAI;AACtB,QAAM,aAAa,KAAK,MAAM,KAAK,UAAU;AAC7C,QAAM,SAAS,YAAY;AAC3B,MAAI,OAAO,SAAS,UAAU,GAAG;AAC/B,WAAO,cAAc,SAAS,OAAO;AAAA,EACvC;AACA,SAAO,KAAK,cAAc,SAAS,OAAO;AAC5C;AAEO,SAAS,mBAAuC;AACrD,SAAO,kBAAkB,CAAC,UAAW,MAAM,OAAO,SAAS,MAAM,OAAO,MAAM,OAAO,SAAS,CAAC,IAAI,IAAK;AAC1G;AAEO,SAAS,iBAAiB,OAAwB;AACvD,QAAM,QAAQ,kBAAkB;AAChC,MAAI,CAAC,MAAM,OAAO,OAAQ,QAAO;AACjC,QAAM,MAAM,MAAM,OAAO,MAAM,OAAO,SAAS,CAAC;AAChD,SAAO,IAAI,OAAO;AACpB;AAEO,SAAS,qBAAqB;AACnC,MAAI,OAAO,WAAW,YAAa;AACnC,kBAAgB;AAChB,UAAQ,aAAa;AACrB,OAAK;AACP;AAEO,MAAM,0BAA0B;AAAA,EACrC;AACF;",
|
|
6
6
|
"names": ["raw"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
async function runBulkDelete(rows, deleteOne, options) {
|
|
3
|
+
const fallback = options?.fallbackErrorMessage ?? "Delete failed";
|
|
4
|
+
const logTag = options?.logTag;
|
|
5
|
+
const succeeded = [];
|
|
6
|
+
const failures = [];
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
try {
|
|
9
|
+
await deleteOne(row);
|
|
10
|
+
succeeded.push(row);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
const message = err instanceof Error && err.message.trim().length > 0 ? err.message : fallback;
|
|
13
|
+
const rawCode = err && typeof err === "object" ? err.code : void 0;
|
|
14
|
+
const code = typeof rawCode === "string" && rawCode.length > 0 ? rawCode : null;
|
|
15
|
+
failures.push({ id: row.id, code, message });
|
|
16
|
+
if (logTag && typeof console !== "undefined" && typeof console.warn === "function") {
|
|
17
|
+
console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { succeeded, failures };
|
|
22
|
+
}
|
|
23
|
+
function groupBulkDeleteFailures(failures) {
|
|
24
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
25
|
+
for (const failure of failures) {
|
|
26
|
+
const key = failure.code || failure.message || "unknown";
|
|
27
|
+
const existing = buckets.get(key);
|
|
28
|
+
if (existing) {
|
|
29
|
+
existing.count += 1;
|
|
30
|
+
existing.ids.push(failure.id);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
buckets.set(key, {
|
|
34
|
+
key,
|
|
35
|
+
count: 1,
|
|
36
|
+
sampleMessage: failure.message,
|
|
37
|
+
ids: [failure.id]
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return Array.from(buckets.values());
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
groupBulkDeleteFailures,
|
|
44
|
+
runBulkDelete
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=bulkDelete.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/utils/bulkDelete.ts"],
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nexport type BulkDeleteFailure = {\n id: string\n code: string | null\n message: string\n}\n\nexport type BulkDeleteOutcome<T extends { id: string }> = {\n succeeded: T[]\n failures: BulkDeleteFailure[]\n}\n\nexport type BulkDeleteOptions = {\n fallbackErrorMessage?: string\n logTag?: string\n}\n\nexport async function runBulkDelete<T extends { id: string }>(\n rows: T[],\n deleteOne: (row: T) => Promise<void>,\n options?: BulkDeleteOptions,\n): Promise<BulkDeleteOutcome<T>> {\n const fallback = options?.fallbackErrorMessage ?? 'Delete failed'\n const logTag = options?.logTag\n const succeeded: T[] = []\n const failures: BulkDeleteFailure[] = []\n for (const row of rows) {\n try {\n await deleteOne(row)\n succeeded.push(row)\n } catch (err) {\n const message =\n err instanceof Error && err.message.trim().length > 0 ? err.message : fallback\n const rawCode =\n err && typeof err === 'object' ? (err as { code?: unknown }).code : undefined\n const code = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : null\n failures.push({ id: row.id, code, message })\n if (logTag && typeof console !== 'undefined' && typeof console.warn === 'function') {\n console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err })\n }\n }\n }\n return { succeeded, failures }\n}\n\nexport type BulkDeleteFailureGroup = {\n key: string\n count: number\n sampleMessage: string\n ids: string[]\n}\n\n// Keyed by `code` when present so semantically identical failures collapse into\n// one toast even if their message text varies (e.g. row-specific dependency\n// counts). Falls back to the raw message when the server did not surface a code.\nexport function groupBulkDeleteFailures(\n failures: BulkDeleteFailure[],\n): BulkDeleteFailureGroup[] {\n const buckets = new Map<string, BulkDeleteFailureGroup>()\n for (const failure of failures) {\n const key = failure.code || failure.message || 'unknown'\n const existing = buckets.get(key)\n if (existing) {\n existing.count += 1\n existing.ids.push(failure.id)\n continue\n }\n buckets.set(key, {\n key,\n count: 1,\n sampleMessage: failure.message,\n ids: [failure.id],\n })\n }\n return Array.from(buckets.values())\n}\n"],
|
|
5
|
+
"mappings": ";AAkBA,eAAsB,cACpB,MACA,WACA,SAC+B;AAC/B,QAAM,WAAW,SAAS,wBAAwB;AAClD,QAAM,SAAS,SAAS;AACxB,QAAM,YAAiB,CAAC;AACxB,QAAM,WAAgC,CAAC;AACvC,aAAW,OAAO,MAAM;AACtB,QAAI;AACF,YAAM,UAAU,GAAG;AACnB,gBAAU,KAAK,GAAG;AAAA,IACpB,SAAS,KAAK;AACZ,YAAM,UACJ,eAAe,SAAS,IAAI,QAAQ,KAAK,EAAE,SAAS,IAAI,IAAI,UAAU;AACxE,YAAM,UACJ,OAAO,OAAO,QAAQ,WAAY,IAA2B,OAAO;AACtE,YAAM,OAAO,OAAO,YAAY,YAAY,QAAQ,SAAS,IAAI,UAAU;AAC3E,eAAS,KAAK,EAAE,IAAI,IAAI,IAAI,MAAM,QAAQ,CAAC;AAC3C,UAAI,UAAU,OAAO,YAAY,eAAe,OAAO,QAAQ,SAAS,YAAY;AAClF,gBAAQ,KAAK,IAAI,MAAM,wBAAwB,EAAE,IAAI,IAAI,IAAI,MAAM,SAAS,IAAI,CAAC;AAAA,MACnF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,WAAW,SAAS;AAC/B;AAYO,SAAS,wBACd,UAC0B;AAC1B,QAAM,UAAU,oBAAI,IAAoC;AACxD,aAAW,WAAW,UAAU;AAC9B,UAAM,MAAM,QAAQ,QAAQ,QAAQ,WAAW;AAC/C,UAAM,WAAW,QAAQ,IAAI,GAAG;AAChC,QAAI,UAAU;AACZ,eAAS,SAAS;AAClB,eAAS,IAAI,KAAK,QAAQ,EAAE;AAC5B;AAAA,IACF;AACA,YAAQ,IAAI,KAAK;AAAA,MACf;AAAA,MACA,OAAO;AAAA,MACP,eAAe,QAAQ;AAAA,MACvB,KAAK,CAAC,QAAQ,EAAE;AAAA,IAClB,CAAC;AAAA,EACH;AACA,SAAO,MAAM,KAAK,QAAQ,OAAO,CAAC;AACpC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.1-develop.
|
|
3
|
+
"version": "0.6.1-develop.3246.1.dbef9d7392",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -140,12 +140,12 @@
|
|
|
140
140
|
"remark-gfm": "^4.0.1"
|
|
141
141
|
},
|
|
142
142
|
"peerDependencies": {
|
|
143
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
143
|
+
"@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
|
|
144
144
|
"react": ">=18.0.0",
|
|
145
145
|
"react-dom": ">=18.0.0"
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
148
|
+
"@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
|
|
149
149
|
"@testing-library/dom": "^10.4.1",
|
|
150
150
|
"@testing-library/jest-dom": "^6.9.1",
|
|
151
151
|
"@testing-library/react": "^16.3.1",
|
|
@@ -24,20 +24,27 @@ export function LastOperationBanner() {
|
|
|
24
24
|
async function handleUndo() {
|
|
25
25
|
const undoToken = operation?.undoToken
|
|
26
26
|
if (!undoToken || isPending) return
|
|
27
|
+
const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0
|
|
28
|
+
? operation.bulkUndoTokens
|
|
29
|
+
: [undoToken]
|
|
27
30
|
setPendingToken(undoToken)
|
|
31
|
+
const completed: string[] = []
|
|
28
32
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
for (const token of tokens.slice().reverse()) {
|
|
34
|
+
const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ undoToken: token }),
|
|
38
|
+
})
|
|
39
|
+
if (!call.ok) {
|
|
40
|
+
const message =
|
|
41
|
+
(call.result && typeof call.result.error === 'string' && call.result.error) ||
|
|
42
|
+
''
|
|
43
|
+
throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))
|
|
44
|
+
}
|
|
45
|
+
completed.push(token)
|
|
39
46
|
}
|
|
40
|
-
markUndoSuccess(
|
|
47
|
+
markUndoSuccess(tokens)
|
|
41
48
|
flash(t('audit_logs.banner.undo_success'), 'success')
|
|
42
49
|
router.refresh()
|
|
43
50
|
if (typeof window !== 'undefined') {
|
|
@@ -53,6 +60,7 @@ export function LastOperationBanner() {
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
} catch (err) {
|
|
63
|
+
if (completed.length > 0) markUndoSuccess(completed)
|
|
56
64
|
const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')
|
|
57
65
|
flash(message, 'error')
|
|
58
66
|
} finally {
|
|
@@ -78,10 +78,55 @@ describe('LastOperationBanner', () => {
|
|
|
78
78
|
method: 'POST',
|
|
79
79
|
}))
|
|
80
80
|
})
|
|
81
|
-
expect(markUndoSuccess).toHaveBeenCalledWith('token-123')
|
|
81
|
+
expect(markUndoSuccess).toHaveBeenCalledWith(['token-123'])
|
|
82
82
|
expect(flash).toHaveBeenCalledWith('Undo completed', 'success')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('iterates bulkUndoTokens in reverse and reports completed tokens to the store on full success', async () => {
|
|
86
|
+
;(useLastOperation as jest.Mock).mockReturnValue({
|
|
87
|
+
...mockOperation,
|
|
88
|
+
undoToken: 'bulk:abc',
|
|
89
|
+
bulkUndoTokens: ['tk-1', 'tk-2', 'tk-3'],
|
|
90
|
+
bulkCount: 3,
|
|
91
|
+
})
|
|
92
|
+
;(apiCall as jest.Mock).mockResolvedValue({
|
|
93
|
+
ok: true,
|
|
94
|
+
status: 200,
|
|
95
|
+
result: {},
|
|
96
|
+
response: createMockResponse(200),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
renderWithProviders(<LastOperationBanner />, { dict })
|
|
100
|
+
fireEvent.click(screen.getByRole('button', { name: /undo/i }))
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(apiCall).toHaveBeenCalledTimes(3)
|
|
104
|
+
})
|
|
105
|
+
const tokenBodies = (apiCall as jest.Mock).mock.calls.map((call) => JSON.parse(call[1].body))
|
|
106
|
+
expect(tokenBodies.map((body: { undoToken: string }) => body.undoToken)).toEqual(['tk-3', 'tk-2', 'tk-1'])
|
|
107
|
+
expect(markUndoSuccess).toHaveBeenCalledWith(['tk-1', 'tk-2', 'tk-3'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('on partial bulk failure flashes the error and reports only the completed tokens', async () => {
|
|
111
|
+
;(useLastOperation as jest.Mock).mockReturnValue({
|
|
112
|
+
...mockOperation,
|
|
113
|
+
undoToken: 'bulk:abc',
|
|
114
|
+
bulkUndoTokens: ['tk-1', 'tk-2', 'tk-3'],
|
|
115
|
+
bulkCount: 3,
|
|
116
|
+
})
|
|
117
|
+
;(apiCall as jest.Mock)
|
|
118
|
+
.mockResolvedValueOnce({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
|
|
119
|
+
.mockResolvedValueOnce({ ok: false, status: 500, result: { error: 'boom' }, response: createMockResponse(500) })
|
|
120
|
+
|
|
121
|
+
renderWithProviders(<LastOperationBanner />, { dict })
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: /undo/i }))
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(flash).toHaveBeenCalledWith('boom', 'error')
|
|
126
|
+
})
|
|
127
|
+
expect(markUndoSuccess).toHaveBeenCalledWith(['tk-3'])
|
|
128
|
+
})
|
|
129
|
+
|
|
85
130
|
it('surfaces undo errors from the API', async () => {
|
|
86
131
|
;(apiCall as jest.Mock).mockResolvedValue({
|
|
87
132
|
ok: false,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
coalesceLastOperations,
|
|
7
|
+
markUndoSuccess,
|
|
8
|
+
pushOperation,
|
|
9
|
+
clearAllOperations,
|
|
10
|
+
} from '../store'
|
|
11
|
+
|
|
12
|
+
function makeMeta(id: string, undoToken: string, commandId = 'customers.companies.delete') {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
undoToken,
|
|
16
|
+
commandId,
|
|
17
|
+
actionLabel: 'Delete company',
|
|
18
|
+
resourceKind: 'customers.company',
|
|
19
|
+
resourceId: id,
|
|
20
|
+
executedAt: new Date().toISOString(),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('operations store — bulk coalesce', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
clearAllOperations()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('replaces the last N entries with a single synthetic bulk entry', () => {
|
|
30
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
31
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
32
|
+
pushOperation(makeMeta('op-3', 'tk-3'))
|
|
33
|
+
|
|
34
|
+
coalesceLastOperations(3, {
|
|
35
|
+
commandId: 'customers.companies.delete',
|
|
36
|
+
actionLabel: 'Delete 3 companies',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
40
|
+
expect(state.stack).toHaveLength(1)
|
|
41
|
+
const synthetic = state.stack[0]
|
|
42
|
+
expect(synthetic.bulkUndoTokens).toEqual(['tk-1', 'tk-2', 'tk-3'])
|
|
43
|
+
expect(synthetic.bulkCount).toBe(3)
|
|
44
|
+
expect(synthetic.actionLabel).toBe('Delete 3 companies')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is a no-op when count is 1 or less', () => {
|
|
48
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
49
|
+
coalesceLastOperations(1, { commandId: 'customers.companies.delete' })
|
|
50
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
51
|
+
expect(state.stack).toHaveLength(1)
|
|
52
|
+
expect(state.stack[0].bulkUndoTokens).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('refuses to coalesce when commandIds in the tail do not match', () => {
|
|
56
|
+
pushOperation(makeMeta('op-1', 'tk-1', 'customers.companies.delete'))
|
|
57
|
+
pushOperation(makeMeta('op-2', 'tk-2', 'customers.people.delete'))
|
|
58
|
+
|
|
59
|
+
coalesceLastOperations(2, { commandId: 'customers.companies.delete' })
|
|
60
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
61
|
+
expect(state.stack).toHaveLength(2)
|
|
62
|
+
expect(state.stack[1].bulkUndoTokens).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('preserves earlier entries that fall outside the coalesce window', () => {
|
|
66
|
+
pushOperation(makeMeta('earlier', 'tk-earlier', 'customers.deals.update'))
|
|
67
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
68
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
69
|
+
|
|
70
|
+
coalesceLastOperations(2, {
|
|
71
|
+
commandId: 'customers.companies.delete',
|
|
72
|
+
actionLabel: 'Delete 2 companies',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
76
|
+
expect(state.stack).toHaveLength(2)
|
|
77
|
+
expect(state.stack[0].id).toBe('earlier')
|
|
78
|
+
expect(state.stack[1].bulkCount).toBe(2)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('markUndoSuccess — bulk-aware', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
clearAllOperations()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('removes a bulk entry when ALL of its bulkUndoTokens match', () => {
|
|
88
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
89
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
90
|
+
coalesceLastOperations(2, {
|
|
91
|
+
commandId: 'customers.companies.delete',
|
|
92
|
+
actionLabel: 'Delete 2 companies',
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
markUndoSuccess(['tk-1', 'tk-2'])
|
|
96
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
97
|
+
expect(state.stack).toHaveLength(0)
|
|
98
|
+
expect(state.undone).toHaveLength(1)
|
|
99
|
+
expect(state.undone[0].bulkUndoTokens).toEqual(['tk-1', 'tk-2'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('splits a bulk entry on partial undo, keeping the unconsumed tokens on the stack', () => {
|
|
103
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
104
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
105
|
+
pushOperation(makeMeta('op-3', 'tk-3'))
|
|
106
|
+
coalesceLastOperations(3, {
|
|
107
|
+
commandId: 'customers.companies.delete',
|
|
108
|
+
actionLabel: 'Delete 3 companies',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
markUndoSuccess(['tk-2', 'tk-3'])
|
|
112
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
113
|
+
expect(state.stack).toHaveLength(1)
|
|
114
|
+
expect(state.stack[0].bulkUndoTokens).toEqual(['tk-1'])
|
|
115
|
+
expect(state.stack[0].bulkCount).toBe(1)
|
|
116
|
+
expect(state.undone).toHaveLength(1)
|
|
117
|
+
expect(state.undone[0].bulkUndoTokens).toEqual(['tk-2', 'tk-3'])
|
|
118
|
+
expect(state.undone[0].bulkCount).toBe(2)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('coalesce assigns a synthetic id distinct from the source operations', () => {
|
|
122
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
123
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
124
|
+
coalesceLastOperations(2, {
|
|
125
|
+
commandId: 'customers.companies.delete',
|
|
126
|
+
actionLabel: 'Delete 2 companies',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
130
|
+
expect(state.stack[0].id).not.toBe('op-1')
|
|
131
|
+
expect(state.stack[0].id).not.toBe('op-2')
|
|
132
|
+
expect(state.stack[0].id).toMatch(/^bulk:/)
|
|
133
|
+
expect(state.stack[0].undoToken).toMatch(/^bulk:/)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('accepts a single string for legacy callers', () => {
|
|
137
|
+
pushOperation(makeMeta('op-solo', 'tk-solo'))
|
|
138
|
+
markUndoSuccess('tk-solo')
|
|
139
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
140
|
+
expect(state.stack).toHaveLength(0)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -4,6 +4,8 @@ import type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands
|
|
|
4
4
|
|
|
5
5
|
export type OperationEntry = OperationMetadataPayload & {
|
|
6
6
|
receivedAt: number
|
|
7
|
+
bulkUndoTokens?: string[]
|
|
8
|
+
bulkCount?: number
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export type UndoneEntry = OperationEntry & {
|
|
@@ -71,6 +73,12 @@ function isValidEntry(entry: unknown): entry is OperationEntry {
|
|
|
71
73
|
|
|
72
74
|
function hydrateEntry(entry: unknown): OperationEntry {
|
|
73
75
|
const source = entry as Partial<OperationEntry> & Record<string, unknown>
|
|
76
|
+
const bulkTokens = Array.isArray(source.bulkUndoTokens)
|
|
77
|
+
? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)
|
|
78
|
+
: undefined
|
|
79
|
+
const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)
|
|
80
|
+
? source.bulkCount
|
|
81
|
+
: undefined
|
|
74
82
|
return {
|
|
75
83
|
id: String(source.id),
|
|
76
84
|
undoToken: String(source.undoToken),
|
|
@@ -80,6 +88,8 @@ function hydrateEntry(entry: unknown): OperationEntry {
|
|
|
80
88
|
resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,
|
|
81
89
|
executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),
|
|
82
90
|
receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),
|
|
91
|
+
...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),
|
|
92
|
+
...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -154,21 +164,80 @@ export function pushOperation(meta: OperationMetadataPayload) {
|
|
|
154
164
|
})
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
export function markUndoSuccess(
|
|
167
|
+
export function markUndoSuccess(undoTokens: string | string[]) {
|
|
158
168
|
if (typeof window === 'undefined') return
|
|
169
|
+
const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])
|
|
170
|
+
if (tokenSet.size === 0) return
|
|
159
171
|
const removed: OperationEntry[] = []
|
|
160
172
|
updateState((prev) => {
|
|
161
|
-
const
|
|
162
|
-
|
|
173
|
+
const nextStack: OperationEntry[] = []
|
|
174
|
+
for (const entry of prev.stack) {
|
|
175
|
+
const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null
|
|
176
|
+
if (!bulk) {
|
|
177
|
+
if (tokenSet.has(entry.undoToken)) removed.push(entry)
|
|
178
|
+
else nextStack.push(entry)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const consumed: string[] = []
|
|
182
|
+
const remaining: string[] = []
|
|
183
|
+
for (const token of bulk) {
|
|
184
|
+
if (tokenSet.has(token)) consumed.push(token)
|
|
185
|
+
else remaining.push(token)
|
|
186
|
+
}
|
|
187
|
+
if (consumed.length === 0) {
|
|
188
|
+
nextStack.push(entry)
|
|
189
|
+
} else if (remaining.length === 0) {
|
|
163
190
|
removed.push(entry)
|
|
164
|
-
|
|
191
|
+
} else {
|
|
192
|
+
removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })
|
|
193
|
+
nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })
|
|
165
194
|
}
|
|
166
|
-
|
|
167
|
-
})
|
|
195
|
+
}
|
|
168
196
|
const undone = removed.length
|
|
169
197
|
? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]
|
|
170
198
|
: prev.undone
|
|
171
|
-
return { stack, undone }
|
|
199
|
+
return { stack: nextStack, undone }
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type CoalesceOptions = {
|
|
204
|
+
commandId?: string
|
|
205
|
+
actionLabel?: string | null
|
|
206
|
+
resourceKind?: string | null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function generateBulkId(seed: string): string {
|
|
210
|
+
const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined
|
|
211
|
+
if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {
|
|
212
|
+
return `bulk:${cryptoRef.randomUUID()}`
|
|
213
|
+
}
|
|
214
|
+
return `bulk:${seed}:${now()}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {
|
|
218
|
+
if (typeof window === 'undefined' || count <= 1) return
|
|
219
|
+
updateState((prev) => {
|
|
220
|
+
if (prev.stack.length < count) return prev
|
|
221
|
+
const tail = prev.stack.slice(-count)
|
|
222
|
+
if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {
|
|
223
|
+
return prev
|
|
224
|
+
}
|
|
225
|
+
const head = prev.stack.slice(0, prev.stack.length - count)
|
|
226
|
+
const last = tail[tail.length - 1]
|
|
227
|
+
const tokens = tail.map((entry) => entry.undoToken)
|
|
228
|
+
const bulkId = generateBulkId(last.id)
|
|
229
|
+
const synthetic: OperationEntry = {
|
|
230
|
+
...last,
|
|
231
|
+
id: bulkId,
|
|
232
|
+
undoToken: bulkId,
|
|
233
|
+
actionLabel: options.actionLabel ?? last.actionLabel,
|
|
234
|
+
resourceKind: options.resourceKind ?? last.resourceKind,
|
|
235
|
+
resourceId: null,
|
|
236
|
+
bulkUndoTokens: tokens,
|
|
237
|
+
bulkCount: tail.length,
|
|
238
|
+
receivedAt: now(),
|
|
239
|
+
}
|
|
240
|
+
return { stack: [...head, synthetic], undone: prev.undone }
|
|
172
241
|
})
|
|
173
242
|
}
|
|
174
243
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { groupBulkDeleteFailures, runBulkDelete, type BulkDeleteFailure } from '../bulkDelete'
|
|
2
|
+
|
|
3
|
+
describe('runBulkDelete', () => {
|
|
4
|
+
it('captures error message and code from thrown error', async () => {
|
|
5
|
+
const dependencyErr = Object.assign(new Error('Cannot delete company: linked deals (2).'), {
|
|
6
|
+
code: 'COMPANY_HAS_DEPENDENTS',
|
|
7
|
+
status: 422,
|
|
8
|
+
})
|
|
9
|
+
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
|
|
10
|
+
const deleteOne = jest.fn().mockImplementation(async (row: { id: string }) => {
|
|
11
|
+
if (row.id === 'b') throw dependencyErr
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const { succeeded, failures } = await runBulkDelete(rows, deleteOne)
|
|
15
|
+
|
|
16
|
+
expect(succeeded).toEqual([{ id: 'a' }, { id: 'c' }])
|
|
17
|
+
expect(failures).toEqual([
|
|
18
|
+
{ id: 'b', code: 'COMPANY_HAS_DEPENDENTS', message: 'Cannot delete company: linked deals (2).' },
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('falls back to fallbackErrorMessage when the error has no message', async () => {
|
|
23
|
+
const empty = Object.assign(new Error(''), {})
|
|
24
|
+
const deleteOne = jest.fn().mockRejectedValue(empty)
|
|
25
|
+
|
|
26
|
+
const { failures } = await runBulkDelete([{ id: 'x' }], deleteOne, {
|
|
27
|
+
fallbackErrorMessage: 'Failed to delete record.',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(failures[0]).toEqual({ id: 'x', code: null, message: 'Failed to delete record.' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('treats non-string code as null', async () => {
|
|
34
|
+
const err = Object.assign(new Error('boom'), { code: 500 })
|
|
35
|
+
const deleteOne = jest.fn().mockRejectedValue(err)
|
|
36
|
+
|
|
37
|
+
const { failures } = await runBulkDelete([{ id: 'x' }], deleteOne)
|
|
38
|
+
|
|
39
|
+
expect(failures[0].code).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('logs failures via console.warn when logTag is provided', async () => {
|
|
43
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
44
|
+
try {
|
|
45
|
+
const err = Object.assign(new Error('linked deals (2).'), { code: 'COMPANY_HAS_DEPENDENTS' })
|
|
46
|
+
const deleteOne = jest.fn().mockRejectedValue(err)
|
|
47
|
+
|
|
48
|
+
await runBulkDelete([{ id: 'x' }], deleteOne, { logTag: 'customers.companies.list' })
|
|
49
|
+
|
|
50
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
51
|
+
const [tag, payload] = warnSpy.mock.calls[0]
|
|
52
|
+
expect(tag).toBe('[customers.companies.list] bulk delete failed')
|
|
53
|
+
expect(payload).toMatchObject({ id: 'x', code: 'COMPANY_HAS_DEPENDENTS' })
|
|
54
|
+
} finally {
|
|
55
|
+
warnSpy.mockRestore()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not log when logTag is omitted', async () => {
|
|
60
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
61
|
+
try {
|
|
62
|
+
const deleteOne = jest.fn().mockRejectedValue(new Error('boom'))
|
|
63
|
+
await runBulkDelete([{ id: 'x' }], deleteOne)
|
|
64
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
65
|
+
} finally {
|
|
66
|
+
warnSpy.mockRestore()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('groupBulkDeleteFailures', () => {
|
|
72
|
+
it('groups failures by error code and counts occurrences', () => {
|
|
73
|
+
const failures: BulkDeleteFailure[] = [
|
|
74
|
+
{ id: '1', code: 'HAS_DEPENDENTS', message: 'Cannot delete (a)' },
|
|
75
|
+
{ id: '2', code: 'HAS_DEPENDENTS', message: 'Cannot delete (b)' },
|
|
76
|
+
{ id: '3', code: 'OTHER_ERROR', message: 'Different' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
const groups = groupBulkDeleteFailures(failures)
|
|
80
|
+
|
|
81
|
+
expect(groups).toHaveLength(2)
|
|
82
|
+
const dependents = groups.find((g) => g.key === 'HAS_DEPENDENTS')!
|
|
83
|
+
expect(dependents.count).toBe(2)
|
|
84
|
+
expect(dependents.ids).toEqual(['1', '2'])
|
|
85
|
+
expect(dependents.sampleMessage).toBe('Cannot delete (a)')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('falls back to message when no code is present', () => {
|
|
89
|
+
const failures: BulkDeleteFailure[] = [
|
|
90
|
+
{ id: '1', code: null, message: 'Same reason' },
|
|
91
|
+
{ id: '2', code: null, message: 'Same reason' },
|
|
92
|
+
{ id: '3', code: null, message: 'Different reason' },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const groups = groupBulkDeleteFailures(failures)
|
|
96
|
+
|
|
97
|
+
expect(groups.map((g) => g.count).sort()).toEqual([1, 2])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('returns empty array when no failures', () => {
|
|
101
|
+
expect(groupBulkDeleteFailures([])).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
export type BulkDeleteFailure = {
|
|
4
|
+
id: string
|
|
5
|
+
code: string | null
|
|
6
|
+
message: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type BulkDeleteOutcome<T extends { id: string }> = {
|
|
10
|
+
succeeded: T[]
|
|
11
|
+
failures: BulkDeleteFailure[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type BulkDeleteOptions = {
|
|
15
|
+
fallbackErrorMessage?: string
|
|
16
|
+
logTag?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runBulkDelete<T extends { id: string }>(
|
|
20
|
+
rows: T[],
|
|
21
|
+
deleteOne: (row: T) => Promise<void>,
|
|
22
|
+
options?: BulkDeleteOptions,
|
|
23
|
+
): Promise<BulkDeleteOutcome<T>> {
|
|
24
|
+
const fallback = options?.fallbackErrorMessage ?? 'Delete failed'
|
|
25
|
+
const logTag = options?.logTag
|
|
26
|
+
const succeeded: T[] = []
|
|
27
|
+
const failures: BulkDeleteFailure[] = []
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
try {
|
|
30
|
+
await deleteOne(row)
|
|
31
|
+
succeeded.push(row)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const message =
|
|
34
|
+
err instanceof Error && err.message.trim().length > 0 ? err.message : fallback
|
|
35
|
+
const rawCode =
|
|
36
|
+
err && typeof err === 'object' ? (err as { code?: unknown }).code : undefined
|
|
37
|
+
const code = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : null
|
|
38
|
+
failures.push({ id: row.id, code, message })
|
|
39
|
+
if (logTag && typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
40
|
+
console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { succeeded, failures }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type BulkDeleteFailureGroup = {
|
|
48
|
+
key: string
|
|
49
|
+
count: number
|
|
50
|
+
sampleMessage: string
|
|
51
|
+
ids: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Keyed by `code` when present so semantically identical failures collapse into
|
|
55
|
+
// one toast even if their message text varies (e.g. row-specific dependency
|
|
56
|
+
// counts). Falls back to the raw message when the server did not surface a code.
|
|
57
|
+
export function groupBulkDeleteFailures(
|
|
58
|
+
failures: BulkDeleteFailure[],
|
|
59
|
+
): BulkDeleteFailureGroup[] {
|
|
60
|
+
const buckets = new Map<string, BulkDeleteFailureGroup>()
|
|
61
|
+
for (const failure of failures) {
|
|
62
|
+
const key = failure.code || failure.message || 'unknown'
|
|
63
|
+
const existing = buckets.get(key)
|
|
64
|
+
if (existing) {
|
|
65
|
+
existing.count += 1
|
|
66
|
+
existing.ids.push(failure.id)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
buckets.set(key, {
|
|
70
|
+
key,
|
|
71
|
+
count: 1,
|
|
72
|
+
sampleMessage: failure.message,
|
|
73
|
+
ids: [failure.id],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return Array.from(buckets.values())
|
|
77
|
+
}
|