@open-mercato/ui 0.6.1-develop.3227.1.2b7b8ab258 → 0.6.1-develop.3256.1.fe3dec2464
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/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- 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/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
- 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
|
@@ -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
|
+
}
|