@object-ui/core 3.1.5 → 3.3.0

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.
@@ -9,3 +9,4 @@ export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './ExpressionCache.js';
11
11
  export * from './FormulaFunctions.js';
12
+ export * from './SafeExpressionParser.js';
@@ -9,3 +9,4 @@ export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './ExpressionCache.js';
11
11
  export * from './FormulaFunctions.js';
12
+ export * from './SafeExpressionParser.js';
@@ -35,16 +35,10 @@ export function resolveDndConfig(config) {
35
35
  * @returns Component props object for a draggable element
36
36
  */
37
37
  export function createDragItemProps(item) {
38
- const ariaLabel = typeof item.ariaLabel === 'string'
39
- ? item.ariaLabel
40
- : item.ariaLabel?.defaultValue;
41
- const label = typeof item.label === 'string'
42
- ? item.label
43
- : item.label?.defaultValue;
44
38
  return {
45
39
  draggable: !(item.disabled ?? false),
46
40
  'aria-roledescription': 'draggable',
47
- 'aria-label': ariaLabel ?? label,
41
+ 'aria-label': item.ariaLabel ?? item.label,
48
42
  'aria-describedby': item.ariaDescribedBy,
49
43
  role: item.role ?? 'listitem',
50
44
  'data-drag-type': item.type,
@@ -63,15 +57,9 @@ export function createDragItemProps(item) {
63
57
  * @returns Component props object for a droppable area
64
58
  */
65
59
  export function createDropZoneProps(zone) {
66
- const ariaLabel = typeof zone.ariaLabel === 'string'
67
- ? zone.ariaLabel
68
- : zone.ariaLabel?.defaultValue;
69
- const label = typeof zone.label === 'string'
70
- ? zone.label
71
- : zone.label?.defaultValue;
72
60
  return {
73
61
  'aria-dropeffect': zone.dropEffect ?? 'move',
74
- 'aria-label': ariaLabel ?? label,
62
+ 'aria-label': zone.ariaLabel ?? zone.label,
75
63
  'aria-describedby': zone.ariaDescribedBy,
76
64
  role: zone.role ?? 'list',
77
65
  'data-drop-accept': zone.accept.join(','),
@@ -15,14 +15,11 @@
15
15
  * @returns Fully resolved keyboard navigation configuration
16
16
  */
17
17
  export function resolveKeyboardConfig(config) {
18
- const ariaLabel = typeof config.ariaLabel === 'string'
19
- ? config.ariaLabel
20
- : config.ariaLabel?.defaultValue;
21
18
  return {
22
19
  shortcuts: config.shortcuts ?? [],
23
20
  focusManagement: resolveFocusManagement(config.focusManagement),
24
21
  rovingTabindex: config.rovingTabindex ?? false,
25
- ariaLabel,
22
+ ariaLabel: config.ariaLabel,
26
23
  ariaDescribedBy: config.ariaDescribedBy,
27
24
  role: config.role,
28
25
  };
@@ -65,16 +65,6 @@ export function resolveNotificationConfig(config) {
65
65
  // ============================================================================
66
66
  // Spec Notification → Toast
67
67
  // ============================================================================
68
- /**
69
- * Extract the display string from a translatable value (string or Translation object).
70
- */
71
- function resolveTranslatableString(value) {
72
- if (value === undefined)
73
- return undefined;
74
- if (typeof value === 'string')
75
- return value;
76
- return value.defaultValue;
77
- }
78
68
  /**
79
69
  * Convert a spec Notification to a toast-compatible object.
80
70
  *
@@ -83,13 +73,13 @@ function resolveTranslatableString(value) {
83
73
  */
84
74
  export function specNotificationToToast(notification) {
85
75
  const actions = (notification.actions ?? []).map((a) => ({
86
- label: typeof a.label === 'string' ? a.label : a.label?.defaultValue ?? '',
76
+ label: a.label,
87
77
  action: a.action,
88
78
  variant: a.variant ?? 'primary',
89
79
  }));
90
80
  return {
91
- title: resolveTranslatableString(notification.title),
92
- description: resolveTranslatableString(notification.message) ?? '',
81
+ title: notification.title,
82
+ description: notification.message ?? '',
93
83
  variant: mapSeverityToVariant(notification.severity ?? 'info'),
94
84
  position: mapPosition(notification.position ?? 'top_right'),
95
85
  duration: notification.duration ?? 5000,
@@ -66,7 +66,8 @@ export function isDebugEnabled() {
66
66
  if (g === true || g === 'true')
67
67
  return true;
68
68
  // 3. process.env
69
- if (typeof process !== 'undefined' && process.env?.OBJECTUI_DEBUG === 'true')
69
+ const proc = globalThis.process;
70
+ if (proc?.env?.OBJECTUI_DEBUG === 'true')
70
71
  return true;
71
72
  return false;
72
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "3.1.5",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,14 +25,14 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@objectstack/spec": "^3.3.0",
29
- "lodash": "^4.17.23",
28
+ "@objectstack/spec": "^4.0.3",
29
+ "lodash": "^4.18.1",
30
30
  "zod": "^4.3.6",
31
- "@object-ui/types": "3.1.5"
31
+ "@object-ui/types": "3.3.0"
32
32
  },
33
33
  "devDependencies": {
34
- "typescript": "^5.9.3",
35
- "vitest": "^4.1.0"
34
+ "typescript": "^6.0.2",
35
+ "vitest": "^4.1.4"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc",
@@ -82,10 +82,10 @@ describe('DndProtocol', () => {
82
82
  expect(props['aria-label']).toBe('Aria Label');
83
83
  });
84
84
 
85
- it('should handle translation object for ariaLabel', () => {
85
+ it('should use ariaLabel when provided as string', () => {
86
86
  const item = {
87
87
  type: 'card',
88
- ariaLabel: { key: 'drag.label', defaultValue: 'Translated label' },
88
+ ariaLabel: 'Translated label',
89
89
  } as unknown as DragItem;
90
90
  const props = createDragItemProps(item);
91
91
 
@@ -131,10 +131,10 @@ describe('DndProtocol', () => {
131
131
  expect(props['data-drop-max-items']).toBe(10);
132
132
  });
133
133
 
134
- it('should handle ariaLabel as translation object', () => {
134
+ it('should use ariaLabel string when provided', () => {
135
135
  const zone = {
136
136
  accept: ['card'],
137
- ariaLabel: { key: 'drop.label', defaultValue: 'Drop here' },
137
+ ariaLabel: 'Drop here',
138
138
  } as unknown as DropZone;
139
139
  const props = createDropZoneProps(zone);
140
140
 
@@ -32,9 +32,9 @@ describe('KeyboardProtocol', () => {
32
32
  expect(resolved.ariaLabel).toBe('Navigation');
33
33
  });
34
34
 
35
- it('should resolve ariaLabel from translation object', () => {
35
+ it('should resolve ariaLabel from string', () => {
36
36
  const config = {
37
- ariaLabel: { key: 'nav.label', defaultValue: 'Nav panel' },
37
+ ariaLabel: 'Nav panel',
38
38
  } as unknown as KeyboardNavigationConfig;
39
39
  const resolved = resolveKeyboardConfig(config);
40
40
 
@@ -101,10 +101,10 @@ describe('NotificationProtocol', () => {
101
101
  expect(toast.actions).toEqual([]);
102
102
  });
103
103
 
104
- it('should handle translation objects for title and message', () => {
104
+ it('should handle string title and message', () => {
105
105
  const notification = {
106
- title: { key: 'notify.title', defaultValue: 'Heads up' },
107
- message: { key: 'notify.msg', defaultValue: 'Something happened' },
106
+ title: 'Heads up',
107
+ message: 'Something happened',
108
108
  } as unknown as SpecNotification;
109
109
  const toast = specNotificationToToast(notification);
110
110
 
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type {
13
13
  DataSource,
14
+ MutationEvent,
14
15
  QueryParams,
15
16
  QueryResult,
16
17
  AggregateParams,
@@ -228,6 +229,7 @@ function selectFields<T>(record: T, fields?: string[]): T {
228
229
  export class ValueDataSource<T = any> implements DataSource<T> {
229
230
  private items: T[];
230
231
  private idField: string | undefined;
232
+ private mutationListeners = new Set<(event: MutationEvent<T>) => void>();
231
233
 
232
234
  constructor(config: ValueDataSourceConfig<T>) {
233
235
  // Deep clone to prevent external mutation
@@ -235,6 +237,13 @@ export class ValueDataSource<T = any> implements DataSource<T> {
235
237
  this.idField = config.idField;
236
238
  }
237
239
 
240
+ /** Notify all mutation subscribers */
241
+ private emitMutation(event: MutationEvent<T>): void {
242
+ for (const listener of this.mutationListeners) {
243
+ try { listener(event); } catch (err) { console.warn('ValueDataSource: mutation listener error', err); }
244
+ }
245
+ }
246
+
238
247
  // -----------------------------------------------------------------------
239
248
  // DataSource interface
240
249
  // -----------------------------------------------------------------------
@@ -308,6 +317,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
308
317
  (record as any)[field] = `auto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
309
318
  }
310
319
  this.items.push(record);
320
+ this.emitMutation({ type: 'create', resource: _resource, record: { ...record } });
311
321
  return { ...record };
312
322
  }
313
323
 
@@ -323,6 +333,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
323
333
  throw new Error(`ValueDataSource: Record with id "${id}" not found`);
324
334
  }
325
335
  this.items[index] = { ...this.items[index], ...data };
336
+ this.emitMutation({ type: 'update', resource: _resource, id, record: { ...this.items[index] } });
326
337
  return { ...this.items[index] };
327
338
  }
328
339
 
@@ -332,6 +343,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
332
343
  );
333
344
  if (index === -1) return false;
334
345
  this.items.splice(index, 1);
346
+ this.emitMutation({ type: 'delete', resource: _resource, id });
335
347
  return true;
336
348
  }
337
349
 
@@ -422,6 +434,15 @@ export class ValueDataSource<T = any> implements DataSource<T> {
422
434
  });
423
435
  }
424
436
 
437
+ // -----------------------------------------------------------------------
438
+ // Mutation subscription (P2 — Event Bus)
439
+ // -----------------------------------------------------------------------
440
+
441
+ onMutation(callback: (event: MutationEvent<T>) => void): () => void {
442
+ this.mutationListeners.add(callback);
443
+ return () => { this.mutationListeners.delete(callback); };
444
+ }
445
+
425
446
  // -----------------------------------------------------------------------
426
447
  // Extra utilities
427
448
  // -----------------------------------------------------------------------
@@ -470,3 +470,102 @@ describe('ValueDataSource — aggregate', () => {
470
470
  expect(result.find((r: any) => r.category === 'B')?.amount).toBe(50);
471
471
  });
472
472
  });
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // onMutation (P2 — Event Bus)
476
+ // ---------------------------------------------------------------------------
477
+
478
+ describe('ValueDataSource — onMutation', () => {
479
+ it('should emit "create" event when a record is created', async () => {
480
+ const ds = createDS();
481
+ const events: any[] = [];
482
+ ds.onMutation((e) => events.push(e));
483
+
484
+ await ds.create('users', { name: 'Zara', age: 40 });
485
+
486
+ expect(events).toHaveLength(1);
487
+ expect(events[0].type).toBe('create');
488
+ expect(events[0].resource).toBe('users');
489
+ expect(events[0].record.name).toBe('Zara');
490
+ });
491
+
492
+ it('should emit "update" event when a record is updated', async () => {
493
+ const ds = createDS();
494
+ const events: any[] = [];
495
+ ds.onMutation((e) => events.push(e));
496
+
497
+ await ds.update('users', '1', { age: 31 });
498
+
499
+ expect(events).toHaveLength(1);
500
+ expect(events[0].type).toBe('update');
501
+ expect(events[0].resource).toBe('users');
502
+ expect(events[0].id).toBe('1');
503
+ expect(events[0].record.age).toBe(31);
504
+ });
505
+
506
+ it('should emit "delete" event when a record is deleted', async () => {
507
+ const ds = createDS();
508
+ const events: any[] = [];
509
+ ds.onMutation((e) => events.push(e));
510
+
511
+ await ds.delete('users', '2');
512
+
513
+ expect(events).toHaveLength(1);
514
+ expect(events[0].type).toBe('delete');
515
+ expect(events[0].resource).toBe('users');
516
+ expect(events[0].id).toBe('2');
517
+ expect(events[0].record).toBeUndefined();
518
+ });
519
+
520
+ it('should not emit "delete" for non-existent record', async () => {
521
+ const ds = createDS();
522
+ const events: any[] = [];
523
+ ds.onMutation((e) => events.push(e));
524
+
525
+ await ds.delete('users', '999');
526
+
527
+ expect(events).toHaveLength(0);
528
+ });
529
+
530
+ it('should support multiple subscribers', async () => {
531
+ const ds = createDS();
532
+ const eventsA: any[] = [];
533
+ const eventsB: any[] = [];
534
+ ds.onMutation((e) => eventsA.push(e));
535
+ ds.onMutation((e) => eventsB.push(e));
536
+
537
+ await ds.create('users', { name: 'Multi' });
538
+
539
+ expect(eventsA).toHaveLength(1);
540
+ expect(eventsB).toHaveLength(1);
541
+ });
542
+
543
+ it('should unsubscribe correctly', async () => {
544
+ const ds = createDS();
545
+ const events: any[] = [];
546
+ const unsub = ds.onMutation((e) => events.push(e));
547
+
548
+ await ds.create('users', { name: 'Before' });
549
+ expect(events).toHaveLength(1);
550
+
551
+ unsub();
552
+
553
+ await ds.create('users', { name: 'After' });
554
+ expect(events).toHaveLength(1); // No new event
555
+ });
556
+
557
+ it('should emit events for bulk operations', async () => {
558
+ const ds = createDS();
559
+ const events: any[] = [];
560
+ ds.onMutation((e) => events.push(e));
561
+
562
+ await ds.bulk!('users', 'create', [
563
+ { name: 'Bulk1' },
564
+ { name: 'Bulk2' },
565
+ ]);
566
+
567
+ // Bulk create calls create() for each item
568
+ expect(events).toHaveLength(2);
569
+ expect(events.every((e: any) => e.type === 'create')).toBe(true);
570
+ });
571
+ });
@@ -120,8 +120,8 @@ export class ObjectUIError extends Error {
120
120
  this.name = 'ObjectUIError';
121
121
 
122
122
  // Maintains proper stack trace for where error was thrown (only in V8)
123
- if (Error.captureStackTrace) {
124
- Error.captureStackTrace(this, this.constructor);
123
+ if ((Error as any).captureStackTrace) {
124
+ (Error as any).captureStackTrace(this, this.constructor);
125
125
  }
126
126
  }
127
127
 
@@ -203,7 +203,7 @@ function interpolate(
203
203
  params: Record<string, string>,
204
204
  ): string {
205
205
  return template.replace(/\$\{(\w+)\}/g, (_match, key: string) => {
206
- if (!(key in params) && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
206
+ if (!(key in params) && (globalThis as any).process?.env?.NODE_ENV !== 'production') {
207
207
  console.warn(`[ObjectUI] Missing interpolation parameter "${key}" in error message template.`);
208
208
  }
209
209
  return params[key] ?? `\${${key}}`;
@@ -254,8 +254,7 @@ export function createError(
254
254
  */
255
255
  export function formatErrorMessage(
256
256
  error: ObjectUIError,
257
- isDev: boolean = typeof process !== 'undefined' &&
258
- process.env?.NODE_ENV !== 'production',
257
+ isDev: boolean = (globalThis as any).process?.env?.NODE_ENV !== 'production',
259
258
  ): string {
260
259
  const entry = ERROR_CODES[error.code];
261
260
 
@@ -8,14 +8,16 @@
8
8
 
9
9
  /**
10
10
  * @object-ui/core - Expression Cache
11
- *
11
+ *
12
12
  * Caches compiled expressions to avoid re-parsing on every render.
13
13
  * Provides significant performance improvement for frequently evaluated expressions.
14
- *
14
+ *
15
15
  * @module evaluator
16
16
  * @packageDocumentation
17
17
  */
18
18
 
19
+ import { SafeExpressionParser } from './SafeExpressionParser.js';
20
+
19
21
  /**
20
22
  * A compiled expression function that can be executed with context values
21
23
  */
@@ -112,16 +114,28 @@ export class ExpressionCache {
112
114
  }
113
115
 
114
116
  /**
115
- * Compile an expression into a function
117
+ * Compile an expression into a CSP-safe callable function.
118
+ *
119
+ * Uses `SafeExpressionParser` — a recursive-descent interpreter — instead of
120
+ * `new Function()` so that the expression engine works under strict
121
+ * Content Security Policy headers that forbid `'unsafe-eval'`.
122
+ *
123
+ * A single parser instance is created per compiled expression and reused
124
+ * across all invocations of the returned closure (`evaluate()` resets all
125
+ * internal state on every call), avoiding repeated allocations on hot paths.
116
126
  */
117
127
  private compileExpression(expression: string, varNames: string[]): CompiledExpression {
118
- // SECURITY NOTE: Using Function constructor for expression evaluation.
119
- // This is a controlled use case with:
120
- // 1. Sanitization check (isDangerous) performed by caller
121
- // 2. Strict mode enabled ("use strict")
122
- // 3. Limited scope (only varNames variables available)
123
- // 4. No access to global objects (process, window, etc.)
124
- return new Function(...varNames, `"use strict"; return (${expression});`) as CompiledExpression;
128
+ // One parser per compiled expression reused across hot-path calls.
129
+ const parser = new SafeExpressionParser();
130
+
131
+ return (...args: unknown[]) => {
132
+ // Reconstruct the named variable context from positional arguments.
133
+ const context: Record<string, unknown> = {};
134
+ for (let i = 0; i < varNames.length; i++) {
135
+ context[varNames[i]] = args[i];
136
+ }
137
+ return parser.evaluate(expression, context);
138
+ };
125
139
  }
126
140
 
127
141
  /**