@object-ui/core 3.1.5 → 3.3.1
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/CHANGELOG.md +18 -0
- package/README.md +20 -1
- package/dist/actions/ActionRunner.d.ts +9 -0
- package/dist/actions/ActionRunner.js +41 -4
- package/dist/adapters/ValueDataSource.d.ts +5 -1
- package/dist/adapters/ValueDataSource.js +30 -1
- package/dist/errors/index.js +2 -3
- package/dist/evaluator/ExpressionCache.d.ts +9 -10
- package/dist/evaluator/ExpressionCache.js +29 -8
- package/dist/evaluator/SafeExpressionParser.d.ts +131 -0
- package/dist/evaluator/SafeExpressionParser.js +851 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/protocols/DndProtocol.js +2 -14
- package/dist/protocols/KeyboardProtocol.js +1 -4
- package/dist/protocols/NotificationProtocol.js +3 -13
- package/dist/utils/debug.js +2 -1
- package/dist/utils/filter-converter.js +25 -5
- package/package.json +33 -9
- package/.turbo/turbo-build.log +0 -4
- package/src/__benchmarks__/core.bench.ts +0 -64
- package/src/__tests__/protocols/DndProtocol.test.ts +0 -186
- package/src/__tests__/protocols/KeyboardProtocol.test.ts +0 -177
- package/src/__tests__/protocols/NotificationProtocol.test.ts +0 -142
- package/src/__tests__/protocols/ResponsiveProtocol.test.ts +0 -176
- package/src/__tests__/protocols/SharingProtocol.test.ts +0 -188
- package/src/actions/ActionEngine.ts +0 -268
- package/src/actions/ActionRunner.ts +0 -717
- package/src/actions/TransactionManager.ts +0 -521
- package/src/actions/UndoManager.ts +0 -215
- package/src/actions/__tests__/ActionEngine.test.ts +0 -206
- package/src/actions/__tests__/ActionRunner.params.test.ts +0 -134
- package/src/actions/__tests__/ActionRunner.test.ts +0 -711
- package/src/actions/__tests__/TransactionManager.test.ts +0 -447
- package/src/actions/__tests__/UndoManager.test.ts +0 -320
- package/src/actions/index.ts +0 -12
- package/src/adapters/ApiDataSource.ts +0 -376
- package/src/adapters/README.md +0 -180
- package/src/adapters/ValueDataSource.ts +0 -438
- package/src/adapters/__tests__/ApiDataSource.test.ts +0 -418
- package/src/adapters/__tests__/ValueDataSource.test.ts +0 -472
- package/src/adapters/__tests__/resolveDataSource.test.ts +0 -144
- package/src/adapters/index.ts +0 -15
- package/src/adapters/resolveDataSource.ts +0 -79
- package/src/builder/__tests__/schema-builder.test.ts +0 -235
- package/src/builder/schema-builder.ts +0 -584
- package/src/data-scope/DataScopeManager.ts +0 -269
- package/src/data-scope/ViewDataProvider.ts +0 -282
- package/src/data-scope/__tests__/DataScopeManager.test.ts +0 -211
- package/src/data-scope/__tests__/ViewDataProvider.test.ts +0 -270
- package/src/data-scope/index.ts +0 -24
- package/src/errors/__tests__/errors.test.ts +0 -292
- package/src/errors/index.ts +0 -270
- package/src/evaluator/ExpressionCache.ts +0 -192
- package/src/evaluator/ExpressionContext.ts +0 -118
- package/src/evaluator/ExpressionEvaluator.ts +0 -315
- package/src/evaluator/FormulaFunctions.ts +0 -398
- package/src/evaluator/__tests__/ExpressionCache.test.ts +0 -135
- package/src/evaluator/__tests__/ExpressionContext.test.ts +0 -110
- package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +0 -131
- package/src/evaluator/__tests__/FormulaFunctions.test.ts +0 -447
- package/src/evaluator/index.ts +0 -12
- package/src/index.ts +0 -38
- package/src/protocols/DndProtocol.ts +0 -184
- package/src/protocols/KeyboardProtocol.ts +0 -185
- package/src/protocols/NotificationProtocol.ts +0 -159
- package/src/protocols/ResponsiveProtocol.ts +0 -210
- package/src/protocols/SharingProtocol.ts +0 -185
- package/src/protocols/index.ts +0 -13
- package/src/query/__tests__/query-ast.test.ts +0 -211
- package/src/query/__tests__/window-functions.test.ts +0 -275
- package/src/query/index.ts +0 -7
- package/src/query/query-ast.ts +0 -341
- package/src/registry/PluginScopeImpl.ts +0 -259
- package/src/registry/PluginSystem.ts +0 -206
- package/src/registry/Registry.ts +0 -219
- package/src/registry/WidgetRegistry.ts +0 -316
- package/src/registry/__tests__/PluginSystem.test.ts +0 -309
- package/src/registry/__tests__/Registry.test.ts +0 -293
- package/src/registry/__tests__/WidgetRegistry.test.ts +0 -321
- package/src/registry/__tests__/plugin-scope-integration.test.ts +0 -283
- package/src/theme/ThemeEngine.ts +0 -530
- package/src/theme/__tests__/ThemeEngine.test.ts +0 -668
- package/src/theme/index.ts +0 -24
- package/src/types/index.ts +0 -21
- package/src/utils/__tests__/debug-collector.test.ts +0 -102
- package/src/utils/__tests__/debug.test.ts +0 -134
- package/src/utils/__tests__/expand-fields.test.ts +0 -120
- package/src/utils/__tests__/extract-records.test.ts +0 -50
- package/src/utils/__tests__/filter-converter.test.ts +0 -118
- package/src/utils/__tests__/merge-views-into-objects.test.ts +0 -110
- package/src/utils/__tests__/normalize-quick-filter.test.ts +0 -123
- package/src/utils/debug-collector.ts +0 -100
- package/src/utils/debug.ts +0 -147
- package/src/utils/expand-fields.ts +0 -76
- package/src/utils/extract-records.ts +0 -33
- package/src/utils/filter-converter.ts +0 -133
- package/src/utils/merge-views-into-objects.ts +0 -36
- package/src/utils/normalize-quick-filter.ts +0 -78
- package/src/validation/__tests__/object-validation-engine.test.ts +0 -567
- package/src/validation/__tests__/schema-validator.test.ts +0 -118
- package/src/validation/__tests__/validation-engine.test.ts +0 -102
- package/src/validation/index.ts +0 -10
- package/src/validation/schema-validator.ts +0 -344
- package/src/validation/validation-engine.ts +0 -528
- package/src/validation/validators/index.ts +0 -25
- package/src/validation/validators/object-validation-engine.ts +0 -722
- package/tsconfig.json +0 -15
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -2
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
10
|
-
import { UndoManager, type UndoableOperation } from '../UndoManager';
|
|
11
|
-
|
|
12
|
-
function makeOp(id: string, type: 'create' | 'update' | 'delete' = 'update'): UndoableOperation {
|
|
13
|
-
return {
|
|
14
|
-
id,
|
|
15
|
-
type,
|
|
16
|
-
objectName: 'Account',
|
|
17
|
-
recordId: `rec-${id}`,
|
|
18
|
-
timestamp: Date.now(),
|
|
19
|
-
description: `op-${id}`,
|
|
20
|
-
undoData: { prev: id },
|
|
21
|
-
redoData: { next: id },
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('UndoManager', () => {
|
|
26
|
-
let manager: UndoManager;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
manager = new UndoManager({ maxHistory: 5 });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Basic push / pop (existing behaviour)
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
describe('basic push/pop', () => {
|
|
36
|
-
it('pushes and peeks', () => {
|
|
37
|
-
const op = makeOp('1');
|
|
38
|
-
manager.push(op);
|
|
39
|
-
expect(manager.canUndo).toBe(true);
|
|
40
|
-
expect(manager.peekUndo()).toEqual(op);
|
|
41
|
-
expect(manager.undoCount).toBe(1);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('popUndo moves to redo stack', () => {
|
|
45
|
-
const op = makeOp('1');
|
|
46
|
-
manager.push(op);
|
|
47
|
-
const popped = manager.popUndo();
|
|
48
|
-
expect(popped).toEqual(op);
|
|
49
|
-
expect(manager.canUndo).toBe(false);
|
|
50
|
-
expect(manager.canRedo).toBe(true);
|
|
51
|
-
expect(manager.peekRedo()).toEqual(op);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('popRedo moves back to undo stack', () => {
|
|
55
|
-
manager.push(makeOp('1'));
|
|
56
|
-
manager.popUndo();
|
|
57
|
-
const redone = manager.popRedo();
|
|
58
|
-
expect(redone?.id).toBe('1');
|
|
59
|
-
expect(manager.canUndo).toBe(true);
|
|
60
|
-
expect(manager.canRedo).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('push clears redo stack', () => {
|
|
64
|
-
manager.push(makeOp('1'));
|
|
65
|
-
manager.popUndo();
|
|
66
|
-
expect(manager.canRedo).toBe(true);
|
|
67
|
-
manager.push(makeOp('2'));
|
|
68
|
-
expect(manager.canRedo).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('trims beyond maxHistory', () => {
|
|
72
|
-
for (let i = 0; i < 7; i++) manager.push(makeOp(String(i)));
|
|
73
|
-
expect(manager.undoCount).toBe(5);
|
|
74
|
-
// Oldest operations should have been shifted out
|
|
75
|
-
expect(manager.getHistory()[0].id).toBe('2');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('clear removes everything', () => {
|
|
79
|
-
manager.push(makeOp('1'));
|
|
80
|
-
manager.clear();
|
|
81
|
-
expect(manager.canUndo).toBe(false);
|
|
82
|
-
expect(manager.canRedo).toBe(false);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('subscribe notifies on changes', () => {
|
|
86
|
-
const listener = vi.fn();
|
|
87
|
-
const unsub = manager.subscribe(listener);
|
|
88
|
-
manager.push(makeOp('1'));
|
|
89
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
90
|
-
unsub();
|
|
91
|
-
manager.push(makeOp('2'));
|
|
92
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// pushBatch
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
describe('pushBatch', () => {
|
|
100
|
-
it('pushes multiple operations atomically', () => {
|
|
101
|
-
const ops = [makeOp('a'), makeOp('b'), makeOp('c')];
|
|
102
|
-
manager.pushBatch(ops);
|
|
103
|
-
expect(manager.undoCount).toBe(3);
|
|
104
|
-
expect(manager.getHistory().map((o) => o.id)).toEqual(['a', 'b', 'c']);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('clears redo stack', () => {
|
|
108
|
-
manager.push(makeOp('1'));
|
|
109
|
-
manager.popUndo();
|
|
110
|
-
expect(manager.canRedo).toBe(true);
|
|
111
|
-
manager.pushBatch([makeOp('x')]);
|
|
112
|
-
expect(manager.canRedo).toBe(false);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('notifies listeners exactly once', () => {
|
|
116
|
-
const listener = vi.fn();
|
|
117
|
-
manager.subscribe(listener);
|
|
118
|
-
manager.pushBatch([makeOp('a'), makeOp('b')]);
|
|
119
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('is a no-op for empty array', () => {
|
|
123
|
-
const listener = vi.fn();
|
|
124
|
-
manager.subscribe(listener);
|
|
125
|
-
manager.pushBatch([]);
|
|
126
|
-
expect(listener).not.toHaveBeenCalled();
|
|
127
|
-
expect(manager.undoCount).toBe(0);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('trims to maxHistory when batch exceeds limit', () => {
|
|
131
|
-
const ops = Array.from({ length: 8 }, (_, i) => makeOp(String(i)));
|
|
132
|
-
manager.pushBatch(ops);
|
|
133
|
-
expect(manager.undoCount).toBe(5);
|
|
134
|
-
// Oldest should be trimmed
|
|
135
|
-
expect(manager.getHistory()[0].id).toBe('3');
|
|
136
|
-
expect(manager.getHistory()[4].id).toBe('7');
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// popUndoBatch / popRedoBatch
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
describe('popUndoBatch', () => {
|
|
144
|
-
it('pops multiple operations from undo to redo', () => {
|
|
145
|
-
manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
|
|
146
|
-
const popped = manager.popUndoBatch(2);
|
|
147
|
-
expect(popped.map((o) => o.id)).toEqual(['b', 'c']);
|
|
148
|
-
expect(manager.undoCount).toBe(1);
|
|
149
|
-
expect(manager.redoCount).toBe(2);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('clamps to available stack size', () => {
|
|
153
|
-
manager.push(makeOp('1'));
|
|
154
|
-
const popped = manager.popUndoBatch(10);
|
|
155
|
-
expect(popped).toHaveLength(1);
|
|
156
|
-
expect(manager.undoCount).toBe(0);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('returns empty array when nothing to undo', () => {
|
|
160
|
-
expect(manager.popUndoBatch(3)).toEqual([]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('notifies listeners once', () => {
|
|
164
|
-
manager.pushBatch([makeOp('a'), makeOp('b')]);
|
|
165
|
-
const listener = vi.fn();
|
|
166
|
-
manager.subscribe(listener);
|
|
167
|
-
manager.popUndoBatch(2);
|
|
168
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('popRedoBatch', () => {
|
|
173
|
-
it('pops multiple operations from redo to undo', () => {
|
|
174
|
-
manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
|
|
175
|
-
manager.popUndoBatch(3);
|
|
176
|
-
expect(manager.redoCount).toBe(3);
|
|
177
|
-
const redone = manager.popRedoBatch(2);
|
|
178
|
-
expect(redone.map((o) => o.id)).toEqual(['b', 'c']);
|
|
179
|
-
expect(manager.undoCount).toBe(2);
|
|
180
|
-
expect(manager.redoCount).toBe(1);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('clamps to available stack size', () => {
|
|
184
|
-
manager.push(makeOp('1'));
|
|
185
|
-
manager.popUndo();
|
|
186
|
-
const redone = manager.popRedoBatch(5);
|
|
187
|
-
expect(redone).toHaveLength(1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('returns empty array when nothing to redo', () => {
|
|
191
|
-
expect(manager.popRedoBatch(3)).toEqual([]);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// getHistory / getRedoHistory
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
describe('getHistory / getRedoHistory', () => {
|
|
199
|
-
it('getHistory returns shallow copy of undo stack', () => {
|
|
200
|
-
manager.push(makeOp('1'));
|
|
201
|
-
manager.push(makeOp('2'));
|
|
202
|
-
const history = manager.getHistory();
|
|
203
|
-
expect(history).toHaveLength(2);
|
|
204
|
-
// Mutating the copy must not affect the manager
|
|
205
|
-
history.pop();
|
|
206
|
-
expect(manager.undoCount).toBe(2);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('getRedoHistory returns shallow copy of redo stack', () => {
|
|
210
|
-
manager.push(makeOp('1'));
|
|
211
|
-
manager.push(makeOp('2'));
|
|
212
|
-
manager.popUndo();
|
|
213
|
-
const redoHistory = manager.getRedoHistory();
|
|
214
|
-
expect(redoHistory).toHaveLength(1);
|
|
215
|
-
expect(redoHistory[0].id).toBe('2');
|
|
216
|
-
// Mutating the copy must not affect the manager
|
|
217
|
-
redoHistory.pop();
|
|
218
|
-
expect(manager.redoCount).toBe(1);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// ---------------------------------------------------------------------------
|
|
223
|
-
// saveToStorage / loadFromStorage
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
describe('persistence', () => {
|
|
226
|
-
let storage: Record<string, string>;
|
|
227
|
-
|
|
228
|
-
beforeEach(() => {
|
|
229
|
-
storage = {};
|
|
230
|
-
vi.stubGlobal('localStorage', {
|
|
231
|
-
getItem: vi.fn((key: string) => storage[key] ?? null),
|
|
232
|
-
setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
|
|
233
|
-
removeItem: vi.fn((key: string) => { delete storage[key]; }),
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('round-trips undo/redo stacks through localStorage', () => {
|
|
238
|
-
manager.push(makeOp('1'));
|
|
239
|
-
manager.push(makeOp('2'));
|
|
240
|
-
manager.popUndo(); // '2' goes to redo
|
|
241
|
-
|
|
242
|
-
manager.saveToStorage();
|
|
243
|
-
|
|
244
|
-
const fresh = new UndoManager({ maxHistory: 5 });
|
|
245
|
-
fresh.loadFromStorage();
|
|
246
|
-
|
|
247
|
-
expect(fresh.undoCount).toBe(1);
|
|
248
|
-
expect(fresh.redoCount).toBe(1);
|
|
249
|
-
expect(fresh.peekUndo()?.id).toBe('1');
|
|
250
|
-
expect(fresh.peekRedo()?.id).toBe('2');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('preserves all UndoableOperation fields', () => {
|
|
254
|
-
const op = makeOp('full');
|
|
255
|
-
manager.push(op);
|
|
256
|
-
manager.saveToStorage();
|
|
257
|
-
|
|
258
|
-
const fresh = new UndoManager();
|
|
259
|
-
fresh.loadFromStorage();
|
|
260
|
-
const restored = fresh.peekUndo()!;
|
|
261
|
-
expect(restored.id).toBe(op.id);
|
|
262
|
-
expect(restored.type).toBe(op.type);
|
|
263
|
-
expect(restored.objectName).toBe(op.objectName);
|
|
264
|
-
expect(restored.recordId).toBe(op.recordId);
|
|
265
|
-
expect(restored.timestamp).toBe(op.timestamp);
|
|
266
|
-
expect(restored.description).toBe(op.description);
|
|
267
|
-
expect(restored.undoData).toEqual(op.undoData);
|
|
268
|
-
expect(restored.redoData).toEqual(op.redoData);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('is a no-op when localStorage has no data', () => {
|
|
272
|
-
const fresh = new UndoManager();
|
|
273
|
-
fresh.loadFromStorage();
|
|
274
|
-
expect(fresh.undoCount).toBe(0);
|
|
275
|
-
expect(fresh.redoCount).toBe(0);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('handles corrupt localStorage data gracefully', () => {
|
|
279
|
-
storage['objectui:undo-history'] = 'not-valid-json!!!';
|
|
280
|
-
const fresh = new UndoManager();
|
|
281
|
-
expect(() => fresh.loadFromStorage()).not.toThrow();
|
|
282
|
-
expect(fresh.undoCount).toBe(0);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('notifies listeners after loading', () => {
|
|
286
|
-
manager.push(makeOp('1'));
|
|
287
|
-
manager.saveToStorage();
|
|
288
|
-
|
|
289
|
-
const fresh = new UndoManager();
|
|
290
|
-
const listener = vi.fn();
|
|
291
|
-
fresh.subscribe(listener);
|
|
292
|
-
fresh.loadFromStorage();
|
|
293
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// ---------------------------------------------------------------------------
|
|
298
|
-
// Boundary: batch larger than maxHistory
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
describe('boundary: batch larger than maxHistory', () => {
|
|
301
|
-
it('trims oldest entries when batch overflows maxHistory', () => {
|
|
302
|
-
// maxHistory is 5
|
|
303
|
-
manager.push(makeOp('existing'));
|
|
304
|
-
const batch = Array.from({ length: 6 }, (_, i) => makeOp(`batch-${i}`));
|
|
305
|
-
manager.pushBatch(batch);
|
|
306
|
-
expect(manager.undoCount).toBe(5);
|
|
307
|
-
// 1 existing + 6 batch = 7 total, trimmed to last 5
|
|
308
|
-
const ids = manager.getHistory().map((o) => o.id);
|
|
309
|
-
expect(ids).toEqual(['batch-1', 'batch-2', 'batch-3', 'batch-4', 'batch-5']);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('works when batch itself equals maxHistory', () => {
|
|
313
|
-
const batch = Array.from({ length: 5 }, (_, i) => makeOp(`b-${i}`));
|
|
314
|
-
manager.pushBatch(batch);
|
|
315
|
-
expect(manager.undoCount).toBe(5);
|
|
316
|
-
expect(manager.getHistory()[0].id).toBe('b-0');
|
|
317
|
-
expect(manager.getHistory()[4].id).toBe('b-4');
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
});
|
package/src/actions/index.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export * from './ActionRunner.js';
|
|
10
|
-
export * from './ActionEngine.js';
|
|
11
|
-
export * from './TransactionManager.js';
|
|
12
|
-
export * from './UndoManager.js';
|
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI — ApiDataSource
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*
|
|
8
|
-
* A DataSource adapter for the `provider: 'api'` ViewData mode.
|
|
9
|
-
* Makes raw HTTP requests using the HttpRequest configs from ViewData.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type {
|
|
13
|
-
DataSource,
|
|
14
|
-
QueryParams,
|
|
15
|
-
QueryResult,
|
|
16
|
-
HttpRequest,
|
|
17
|
-
HttpMethod,
|
|
18
|
-
AggregateParams,
|
|
19
|
-
AggregateResult,
|
|
20
|
-
} from '@object-ui/types';
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Configuration
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
export interface ApiDataSourceConfig {
|
|
27
|
-
/** HttpRequest config for read operations (find, findOne) */
|
|
28
|
-
read?: HttpRequest;
|
|
29
|
-
/** HttpRequest config for write operations (create, update, delete) */
|
|
30
|
-
write?: HttpRequest;
|
|
31
|
-
/** Custom fetch implementation (defaults to globalThis.fetch) */
|
|
32
|
-
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
33
|
-
/** Default headers applied to all requests */
|
|
34
|
-
defaultHeaders?: Record<string, string>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Helpers
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/** Build a full URL with query params */
|
|
42
|
-
function buildUrl(
|
|
43
|
-
base: string,
|
|
44
|
-
pathSuffix?: string,
|
|
45
|
-
queryParams?: Record<string, unknown>,
|
|
46
|
-
): string {
|
|
47
|
-
let url = base;
|
|
48
|
-
if (pathSuffix) {
|
|
49
|
-
url = url.replace(/\/+$/, '') + '/' + pathSuffix.replace(/^\/+/, '');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (queryParams && Object.keys(queryParams).length > 0) {
|
|
53
|
-
const search = new URLSearchParams();
|
|
54
|
-
for (const [key, value] of Object.entries(queryParams)) {
|
|
55
|
-
if (value !== undefined && value !== null) {
|
|
56
|
-
search.set(key, String(value));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
const qs = search.toString();
|
|
60
|
-
if (qs) {
|
|
61
|
-
url += (url.includes('?') ? '&' : '?') + qs;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return url;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Convert QueryParams to flat query string params */
|
|
69
|
-
function queryParamsToRecord(params?: QueryParams): Record<string, unknown> {
|
|
70
|
-
if (!params) return {};
|
|
71
|
-
|
|
72
|
-
const out: Record<string, unknown> = {};
|
|
73
|
-
|
|
74
|
-
if (params.$select?.length) {
|
|
75
|
-
out.$select = params.$select.join(',');
|
|
76
|
-
}
|
|
77
|
-
if (params.$filter && Object.keys(params.$filter).length > 0) {
|
|
78
|
-
out.$filter = JSON.stringify(params.$filter);
|
|
79
|
-
}
|
|
80
|
-
if (params.$orderby) {
|
|
81
|
-
if (Array.isArray(params.$orderby)) {
|
|
82
|
-
if (typeof params.$orderby[0] === 'string') {
|
|
83
|
-
out.$orderby = (params.$orderby as string[]).join(',');
|
|
84
|
-
} else {
|
|
85
|
-
out.$orderby = (params.$orderby as Array<{ field: string; order?: string }>)
|
|
86
|
-
.map((s) => `${s.field} ${s.order || 'asc'}`)
|
|
87
|
-
.join(',');
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
out.$orderby = Object.entries(params.$orderby)
|
|
91
|
-
.map(([field, order]) => `${field} ${order}`)
|
|
92
|
-
.join(',');
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (params.$skip !== undefined) out.$skip = params.$skip;
|
|
96
|
-
if (params.$top !== undefined) out.$top = params.$top;
|
|
97
|
-
if (params.$expand?.length) out.$expand = params.$expand.join(',');
|
|
98
|
-
if (params.$search) out.$search = params.$search;
|
|
99
|
-
if (params.$count) out.$count = 'true';
|
|
100
|
-
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Merge two header objects, giving priority to the second */
|
|
105
|
-
function mergeHeaders(
|
|
106
|
-
base?: Record<string, string>,
|
|
107
|
-
override?: Record<string, string>,
|
|
108
|
-
): Record<string, string> {
|
|
109
|
-
return { ...base, ...override };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
// ApiDataSource
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* ApiDataSource — a DataSource adapter for raw HTTP APIs.
|
|
118
|
-
*
|
|
119
|
-
* Used when `ViewData.provider === 'api'`. The read and write HttpRequest
|
|
120
|
-
* configs define the endpoints; all CRUD methods map onto HTTP verbs.
|
|
121
|
-
*
|
|
122
|
-
* Read operations use the `read` config, write operations use the `write` config.
|
|
123
|
-
* Both fall back to each other when one is not provided.
|
|
124
|
-
*
|
|
125
|
-
* @example
|
|
126
|
-
* ```ts
|
|
127
|
-
* const ds = new ApiDataSource({
|
|
128
|
-
* read: { url: '/api/contacts', method: 'GET' },
|
|
129
|
-
* write: { url: '/api/contacts', method: 'POST' },
|
|
130
|
-
* });
|
|
131
|
-
*
|
|
132
|
-
* const result = await ds.find('contacts', { $top: 10 });
|
|
133
|
-
* ```
|
|
134
|
-
*/
|
|
135
|
-
export class ApiDataSource<T = any> implements DataSource<T> {
|
|
136
|
-
private readConfig: HttpRequest | undefined;
|
|
137
|
-
private writeConfig: HttpRequest | undefined;
|
|
138
|
-
private fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
139
|
-
private defaultHeaders: Record<string, string>;
|
|
140
|
-
|
|
141
|
-
constructor(config: ApiDataSourceConfig) {
|
|
142
|
-
this.readConfig = config.read;
|
|
143
|
-
this.writeConfig = config.write;
|
|
144
|
-
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
145
|
-
this.defaultHeaders = config.defaultHeaders ?? {};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// -----------------------------------------------------------------------
|
|
149
|
-
// Internal request executor
|
|
150
|
-
// -----------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
private async request<R = any>(
|
|
153
|
-
base: HttpRequest | undefined,
|
|
154
|
-
options: {
|
|
155
|
-
pathSuffix?: string;
|
|
156
|
-
method?: HttpMethod;
|
|
157
|
-
queryParams?: Record<string, unknown>;
|
|
158
|
-
body?: unknown;
|
|
159
|
-
headers?: Record<string, string>;
|
|
160
|
-
} = {},
|
|
161
|
-
): Promise<R> {
|
|
162
|
-
if (!base) {
|
|
163
|
-
throw new Error(
|
|
164
|
-
'ApiDataSource: No HTTP configuration provided for this operation. ' +
|
|
165
|
-
'Ensure the ViewData has read/write configs.',
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const method = options.method ?? base.method ?? 'GET';
|
|
170
|
-
|
|
171
|
-
// Merge query params: base.params + extra queryParams
|
|
172
|
-
const allQuery = {
|
|
173
|
-
...(base.params as Record<string, unknown> | undefined),
|
|
174
|
-
...options.queryParams,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const url = buildUrl(base.url, options.pathSuffix, allQuery);
|
|
178
|
-
|
|
179
|
-
const headers = mergeHeaders(
|
|
180
|
-
mergeHeaders(this.defaultHeaders, base.headers),
|
|
181
|
-
options.headers,
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const init: RequestInit = {
|
|
185
|
-
method,
|
|
186
|
-
headers,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// Attach body for non-GET methods
|
|
190
|
-
if (options.body !== undefined && method !== 'GET') {
|
|
191
|
-
if (
|
|
192
|
-
options.body instanceof FormData ||
|
|
193
|
-
options.body instanceof Blob
|
|
194
|
-
) {
|
|
195
|
-
init.body = options.body as FormData | Blob;
|
|
196
|
-
} else if (typeof options.body === 'string') {
|
|
197
|
-
init.body = options.body;
|
|
198
|
-
if (!headers['Content-Type']) {
|
|
199
|
-
headers['Content-Type'] = 'text/plain';
|
|
200
|
-
}
|
|
201
|
-
} else {
|
|
202
|
-
init.body = JSON.stringify(options.body);
|
|
203
|
-
if (!headers['Content-Type']) {
|
|
204
|
-
headers['Content-Type'] = 'application/json';
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const response = await this.fetchFn(url, init);
|
|
210
|
-
|
|
211
|
-
if (!response.ok) {
|
|
212
|
-
const text = await response.text().catch(() => '');
|
|
213
|
-
throw new Error(
|
|
214
|
-
`ApiDataSource: HTTP ${response.status} ${response.statusText} — ${text}`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Try to parse as JSON; fall back to empty object
|
|
219
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
220
|
-
if (contentType.includes('application/json')) {
|
|
221
|
-
return response.json();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Non-JSON response — return text wrapped in an object
|
|
225
|
-
const text = await response.text();
|
|
226
|
-
return text as unknown as R;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// -----------------------------------------------------------------------
|
|
230
|
-
// DataSource interface
|
|
231
|
-
// -----------------------------------------------------------------------
|
|
232
|
-
|
|
233
|
-
async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
|
|
234
|
-
const queryParams = queryParamsToRecord(params);
|
|
235
|
-
const raw = await this.request<any>(this.readConfig, {
|
|
236
|
-
method: 'GET',
|
|
237
|
-
queryParams,
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// Normalize: the API might return an array, an object with `data`, or a QueryResult
|
|
241
|
-
return this.normalizeQueryResult(raw);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async findOne(_resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
|
|
245
|
-
try {
|
|
246
|
-
const queryParams = queryParamsToRecord(params);
|
|
247
|
-
const raw = await this.request<T>(this.readConfig, {
|
|
248
|
-
pathSuffix: String(id),
|
|
249
|
-
method: 'GET',
|
|
250
|
-
queryParams,
|
|
251
|
-
});
|
|
252
|
-
return raw ?? null;
|
|
253
|
-
} catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async create(_resource: string, data: Partial<T>): Promise<T> {
|
|
259
|
-
const config = this.writeConfig ?? this.readConfig;
|
|
260
|
-
return this.request<T>(config, {
|
|
261
|
-
method: 'POST',
|
|
262
|
-
body: data,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async update(_resource: string, id: string | number, data: Partial<T>): Promise<T> {
|
|
267
|
-
const config = this.writeConfig ?? this.readConfig;
|
|
268
|
-
return this.request<T>(config, {
|
|
269
|
-
pathSuffix: String(id),
|
|
270
|
-
method: 'PATCH',
|
|
271
|
-
body: data,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async delete(_resource: string, id: string | number): Promise<boolean> {
|
|
276
|
-
const config = this.writeConfig ?? this.readConfig;
|
|
277
|
-
try {
|
|
278
|
-
await this.request(config, {
|
|
279
|
-
pathSuffix: String(id),
|
|
280
|
-
method: 'DELETE',
|
|
281
|
-
});
|
|
282
|
-
return true;
|
|
283
|
-
} catch {
|
|
284
|
-
return false;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async getObjectSchema(_objectName: string): Promise<any> {
|
|
289
|
-
// Generic API endpoints typically don't expose schema metadata.
|
|
290
|
-
// Return a minimal stub so schema-dependent components don't crash.
|
|
291
|
-
return { name: _objectName, fields: {} };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async getView(_objectName: string, _viewId: string): Promise<any | null> {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async getApp(_appId: string): Promise<any | null> {
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
|
|
303
|
-
const queryParams: Record<string, unknown> = {
|
|
304
|
-
field: params.field,
|
|
305
|
-
function: params.function,
|
|
306
|
-
groupBy: params.groupBy,
|
|
307
|
-
};
|
|
308
|
-
if (params.filter) {
|
|
309
|
-
queryParams.filter = typeof params.filter === 'string'
|
|
310
|
-
? params.filter
|
|
311
|
-
: JSON.stringify(params.filter);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const raw = await this.request<any>(this.readConfig, {
|
|
315
|
-
pathSuffix: 'aggregate',
|
|
316
|
-
method: 'GET',
|
|
317
|
-
queryParams,
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// Normalize: the API might return an array or an object with data/results
|
|
321
|
-
if (Array.isArray(raw)) return raw;
|
|
322
|
-
if (raw?.data && Array.isArray(raw.data)) return raw.data;
|
|
323
|
-
if (raw?.results && Array.isArray(raw.results)) return raw.results;
|
|
324
|
-
return [];
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// -----------------------------------------------------------------------
|
|
328
|
-
// Helpers
|
|
329
|
-
// -----------------------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Normalize various API response shapes into a QueryResult.
|
|
333
|
-
*
|
|
334
|
-
* Supported shapes:
|
|
335
|
-
* - `T[]` → wrap in QueryResult
|
|
336
|
-
* - `{ data: T[] }` → extract data
|
|
337
|
-
* - `{ items: T[] }` → extract items
|
|
338
|
-
* - `{ results: T[] }` → extract results
|
|
339
|
-
* - `{ records: T[] }` → extract records (Salesforce-style)
|
|
340
|
-
* - `{ value: T[] }` → extract value (OData-style)
|
|
341
|
-
* - Full QueryResult (has data + totalCount) → return as-is
|
|
342
|
-
*/
|
|
343
|
-
private normalizeQueryResult(raw: any): QueryResult<T> {
|
|
344
|
-
if (Array.isArray(raw)) {
|
|
345
|
-
return { data: raw, total: raw.length };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (raw && typeof raw === 'object') {
|
|
349
|
-
// Already a QueryResult
|
|
350
|
-
if (Array.isArray(raw.data) && ('total' in raw || 'totalCount' in raw)) {
|
|
351
|
-
return {
|
|
352
|
-
data: raw.data,
|
|
353
|
-
total: raw.total ?? raw.totalCount ?? raw.data.length,
|
|
354
|
-
hasMore: raw.hasMore,
|
|
355
|
-
cursor: raw.cursor,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Common envelope patterns
|
|
360
|
-
for (const key of ['data', 'items', 'results', 'records', 'value']) {
|
|
361
|
-
if (Array.isArray(raw[key])) {
|
|
362
|
-
return {
|
|
363
|
-
data: raw[key],
|
|
364
|
-
total: raw.total ?? raw.totalCount ?? raw.count ?? raw[key].length,
|
|
365
|
-
hasMore: raw.hasMore ?? raw.hasNextPage,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Single-object response — wrap as array
|
|
371
|
-
return { data: [raw as T], total: 1 };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return { data: [], total: 0 };
|
|
375
|
-
}
|
|
376
|
-
}
|