@object-ui/core 3.0.2 → 3.1.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.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/actions/ActionEngine.d.ts +98 -0
  4. package/dist/actions/ActionEngine.js +222 -0
  5. package/dist/actions/UndoManager.d.ts +80 -0
  6. package/dist/actions/UndoManager.js +193 -0
  7. package/dist/actions/index.d.ts +2 -0
  8. package/dist/actions/index.js +2 -0
  9. package/dist/adapters/ApiDataSource.d.ts +2 -1
  10. package/dist/adapters/ApiDataSource.js +25 -0
  11. package/dist/adapters/ValueDataSource.d.ts +2 -1
  12. package/dist/adapters/ValueDataSource.js +99 -3
  13. package/dist/data-scope/ViewDataProvider.d.ts +143 -0
  14. package/dist/data-scope/ViewDataProvider.js +153 -0
  15. package/dist/data-scope/index.d.ts +1 -0
  16. package/dist/data-scope/index.js +1 -0
  17. package/dist/evaluator/ExpressionEvaluator.d.ts +7 -0
  18. package/dist/evaluator/ExpressionEvaluator.js +19 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +5 -0
  21. package/dist/protocols/DndProtocol.d.ts +84 -0
  22. package/dist/protocols/DndProtocol.js +113 -0
  23. package/dist/protocols/KeyboardProtocol.d.ts +93 -0
  24. package/dist/protocols/KeyboardProtocol.js +108 -0
  25. package/dist/protocols/NotificationProtocol.d.ts +71 -0
  26. package/dist/protocols/NotificationProtocol.js +99 -0
  27. package/dist/protocols/ResponsiveProtocol.d.ts +73 -0
  28. package/dist/protocols/ResponsiveProtocol.js +158 -0
  29. package/dist/protocols/SharingProtocol.d.ts +71 -0
  30. package/dist/protocols/SharingProtocol.js +124 -0
  31. package/dist/protocols/index.d.ts +12 -0
  32. package/dist/protocols/index.js +12 -0
  33. package/dist/utils/debug-collector.d.ts +59 -0
  34. package/dist/utils/debug-collector.js +73 -0
  35. package/dist/utils/debug.d.ts +37 -2
  36. package/dist/utils/debug.js +62 -3
  37. package/dist/utils/expand-fields.d.ts +40 -0
  38. package/dist/utils/expand-fields.js +68 -0
  39. package/dist/utils/extract-records.d.ts +16 -0
  40. package/dist/utils/extract-records.js +32 -0
  41. package/dist/utils/normalize-quick-filter.d.ts +29 -0
  42. package/dist/utils/normalize-quick-filter.js +66 -0
  43. package/package.json +3 -3
  44. package/src/__tests__/protocols/DndProtocol.test.ts +186 -0
  45. package/src/__tests__/protocols/KeyboardProtocol.test.ts +177 -0
  46. package/src/__tests__/protocols/NotificationProtocol.test.ts +142 -0
  47. package/src/__tests__/protocols/ResponsiveProtocol.test.ts +176 -0
  48. package/src/__tests__/protocols/SharingProtocol.test.ts +188 -0
  49. package/src/actions/ActionEngine.ts +268 -0
  50. package/src/actions/UndoManager.ts +215 -0
  51. package/src/actions/__tests__/ActionEngine.test.ts +206 -0
  52. package/src/actions/__tests__/UndoManager.test.ts +320 -0
  53. package/src/actions/index.ts +2 -0
  54. package/src/adapters/ApiDataSource.ts +27 -0
  55. package/src/adapters/ValueDataSource.ts +109 -3
  56. package/src/adapters/__tests__/ValueDataSource.test.ts +147 -0
  57. package/src/data-scope/ViewDataProvider.ts +282 -0
  58. package/src/data-scope/__tests__/ViewDataProvider.test.ts +270 -0
  59. package/src/data-scope/index.ts +8 -0
  60. package/src/evaluator/ExpressionEvaluator.ts +22 -0
  61. package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +31 -1
  62. package/src/index.ts +5 -0
  63. package/src/protocols/DndProtocol.ts +184 -0
  64. package/src/protocols/KeyboardProtocol.ts +185 -0
  65. package/src/protocols/NotificationProtocol.ts +159 -0
  66. package/src/protocols/ResponsiveProtocol.ts +210 -0
  67. package/src/protocols/SharingProtocol.ts +185 -0
  68. package/src/{index.test.ts → protocols/index.ts} +5 -7
  69. package/src/utils/__tests__/debug-collector.test.ts +102 -0
  70. package/src/utils/__tests__/debug.test.ts +52 -1
  71. package/src/utils/__tests__/expand-fields.test.ts +120 -0
  72. package/src/utils/__tests__/extract-records.test.ts +50 -0
  73. package/src/utils/__tests__/normalize-quick-filter.test.ts +123 -0
  74. package/src/utils/debug-collector.ts +100 -0
  75. package/src/utils/debug.ts +87 -6
  76. package/src/utils/expand-fields.ts +76 -0
  77. package/src/utils/extract-records.ts +33 -0
  78. package/src/utils/normalize-quick-filter.ts +78 -0
  79. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,210 @@
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
+ /**
10
+ * @object-ui/core - Responsive Protocol Bridge
11
+ *
12
+ * Converts spec-aligned ResponsiveConfig schemas into Tailwind CSS
13
+ * utility classes for visibility, grid columns, and ordering across
14
+ * breakpoints. Also provides runtime width-based visibility checks.
15
+ *
16
+ * @module protocols/ResponsiveProtocol
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import type { SpecResponsiveConfig } from '@object-ui/types';
21
+
22
+ // ============================================================================
23
+ // Breakpoint Definitions
24
+ // ============================================================================
25
+
26
+ /** Breakpoint name type matching Tailwind defaults. */
27
+ export type BreakpointKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
28
+
29
+ /** Breakpoint minimum pixel widths aligned with Tailwind CSS defaults. */
30
+ export const BREAKPOINT_VALUES: Record<BreakpointKey, number> = {
31
+ xs: 0,
32
+ sm: 640,
33
+ md: 768,
34
+ lg: 1024,
35
+ xl: 1280,
36
+ '2xl': 1536,
37
+ };
38
+
39
+ /** Ordered breakpoint keys from smallest to largest. */
40
+ const BREAKPOINT_ORDER: BreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
41
+
42
+ // ============================================================================
43
+ // Resolved Types
44
+ // ============================================================================
45
+
46
+ /** Fully resolved responsive configuration. */
47
+ export interface ResolvedResponsiveConfig {
48
+ breakpoint?: BreakpointKey;
49
+ hiddenOn: BreakpointKey[];
50
+ columns: Partial<Record<BreakpointKey, number>>;
51
+ order: Partial<Record<BreakpointKey, number>>;
52
+ }
53
+
54
+ // ============================================================================
55
+ // Config Resolution
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Resolve a responsive configuration by applying defaults.
60
+ *
61
+ * @param config - SpecResponsiveConfig from the spec
62
+ * @returns Fully resolved responsive configuration
63
+ */
64
+ export function resolveResponsiveConfig(config: SpecResponsiveConfig): ResolvedResponsiveConfig {
65
+ return {
66
+ breakpoint: config.breakpoint as BreakpointKey | undefined,
67
+ hiddenOn: (config.hiddenOn ?? []) as BreakpointKey[],
68
+ columns: (config.columns ?? {}) as Partial<Record<BreakpointKey, number>>,
69
+ order: (config.order ?? {}) as Partial<Record<BreakpointKey, number>>,
70
+ };
71
+ }
72
+
73
+ // ============================================================================
74
+ // Visibility Classes
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Generate Tailwind CSS classes for responsive visibility.
79
+ *
80
+ * If `breakpoint` is set, the element is hidden below that breakpoint
81
+ * (e.g. breakpoint "md" → `['hidden', 'md:block']`).
82
+ *
83
+ * If `hiddenOn` contains breakpoints, the element is hidden at those
84
+ * specific sizes (e.g. hiddenOn: ["sm", "lg"] → `['sm:hidden', 'md:block', 'lg:hidden', 'xl:block']`).
85
+ *
86
+ * @param config - SpecResponsiveConfig from the spec
87
+ * @returns Array of Tailwind CSS class strings
88
+ */
89
+ export function getVisibilityClasses(config: SpecResponsiveConfig): string[] {
90
+ const classes: string[] = [];
91
+
92
+ // Minimum breakpoint visibility
93
+ if (config.breakpoint) {
94
+ const bp = config.breakpoint as BreakpointKey;
95
+ if (bp !== 'xs') {
96
+ classes.push('hidden');
97
+ classes.push(`${bp}:block`);
98
+ }
99
+ }
100
+
101
+ // Per-breakpoint hidden overrides
102
+ const hiddenOn = (config.hiddenOn ?? []) as BreakpointKey[];
103
+ if (hiddenOn.length > 0) {
104
+ for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
105
+ const bp = BREAKPOINT_ORDER[i];
106
+ const isHidden = hiddenOn.includes(bp);
107
+ const prevHidden = i > 0 ? hiddenOn.includes(BREAKPOINT_ORDER[i - 1]) : false;
108
+
109
+ if (isHidden && !prevHidden) {
110
+ classes.push(bp === 'xs' ? 'hidden' : `${bp}:hidden`);
111
+ } else if (!isHidden && prevHidden) {
112
+ classes.push(bp === 'xs' ? 'block' : `${bp}:block`);
113
+ }
114
+ }
115
+ }
116
+
117
+ return classes;
118
+ }
119
+
120
+ // ============================================================================
121
+ // Column Classes
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Generate Tailwind grid-cols classes for responsive column layouts.
126
+ *
127
+ * @param config - SpecResponsiveConfig from the spec
128
+ * @returns Array of Tailwind CSS grid column class strings
129
+ */
130
+ export function getColumnClasses(config: SpecResponsiveConfig): string[] {
131
+ const classes: string[] = [];
132
+ const columns = (config.columns ?? {}) as Partial<Record<BreakpointKey, number>>;
133
+
134
+ for (const bp of BREAKPOINT_ORDER) {
135
+ const cols = columns[bp];
136
+ if (cols == null) continue;
137
+ const prefix = bp === 'xs' ? '' : `${bp}:`;
138
+ classes.push(`${prefix}grid-cols-${cols}`);
139
+ }
140
+
141
+ return classes;
142
+ }
143
+
144
+ // ============================================================================
145
+ // Order Classes
146
+ // ============================================================================
147
+
148
+ /**
149
+ * Generate Tailwind order utility classes for responsive ordering.
150
+ *
151
+ * @param config - SpecResponsiveConfig from the spec
152
+ * @returns Array of Tailwind CSS order class strings
153
+ */
154
+ export function getOrderClasses(config: SpecResponsiveConfig): string[] {
155
+ const classes: string[] = [];
156
+ const order = (config.order ?? {}) as Partial<Record<BreakpointKey, number>>;
157
+
158
+ for (const bp of BREAKPOINT_ORDER) {
159
+ const ord = order[bp];
160
+ if (ord == null) continue;
161
+ const prefix = bp === 'xs' ? '' : `${bp}:`;
162
+ classes.push(`${prefix}order-${ord}`);
163
+ }
164
+
165
+ return classes;
166
+ }
167
+
168
+ // ============================================================================
169
+ // Runtime Width Check
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Determine whether a component should be hidden at a given viewport width.
174
+ *
175
+ * Checks both the minimum `breakpoint` threshold and the `hiddenOn` list.
176
+ *
177
+ * @param config - SpecResponsiveConfig from the spec
178
+ * @param width - Current viewport width in pixels
179
+ * @returns `true` if the component should be hidden at the given width
180
+ */
181
+ export function shouldHideAtBreakpoint(config: SpecResponsiveConfig, width: number): boolean {
182
+ // Check minimum breakpoint
183
+ if (config.breakpoint) {
184
+ const minWidth = BREAKPOINT_VALUES[config.breakpoint as BreakpointKey];
185
+ if (minWidth !== undefined && width < minWidth) {
186
+ return true;
187
+ }
188
+ }
189
+
190
+ // Check hiddenOn list
191
+ const hiddenOn = (config.hiddenOn ?? []) as BreakpointKey[];
192
+ if (hiddenOn.length > 0) {
193
+ const currentBp = getCurrentBreakpoint(width);
194
+ return hiddenOn.includes(currentBp);
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * Determine the current breakpoint name for a given width.
202
+ */
203
+ function getCurrentBreakpoint(width: number): BreakpointKey {
204
+ for (let i = BREAKPOINT_ORDER.length - 1; i >= 0; i--) {
205
+ if (width >= BREAKPOINT_VALUES[BREAKPOINT_ORDER[i]]) {
206
+ return BREAKPOINT_ORDER[i];
207
+ }
208
+ }
209
+ return 'xs';
210
+ }
@@ -0,0 +1,185 @@
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
+ /**
10
+ * @object-ui/core - Sharing Protocol Bridge
11
+ *
12
+ * Converts spec-aligned SharingConfig and EmbedConfig schemas into
13
+ * runtime-usable configurations. Provides embed code generation and
14
+ * configuration validation.
15
+ *
16
+ * @module protocols/SharingProtocol
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import type { SharingConfig, EmbedConfig } from '@object-ui/types';
21
+
22
+ // ============================================================================
23
+ // Resolved Types
24
+ // ============================================================================
25
+
26
+ /** Fully resolved sharing configuration. */
27
+ export interface ResolvedSharingConfig {
28
+ enabled: boolean;
29
+ publicLink?: string;
30
+ password?: string;
31
+ allowedDomains: string[];
32
+ expiresAt?: string;
33
+ allowAnonymous: boolean;
34
+ }
35
+
36
+ /** Fully resolved embed configuration. */
37
+ export interface ResolvedEmbedConfig {
38
+ enabled: boolean;
39
+ allowedOrigins: string[];
40
+ width: string;
41
+ height: string;
42
+ showHeader: boolean;
43
+ showNavigation: boolean;
44
+ responsive: boolean;
45
+ }
46
+
47
+ /** Validation result for sharing configuration. */
48
+ export interface SharingValidationResult {
49
+ valid: boolean;
50
+ errors: string[];
51
+ }
52
+
53
+ // ============================================================================
54
+ // Sharing Config Resolution
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Resolve a sharing configuration by applying spec defaults.
59
+ *
60
+ * @param config - Partial SharingConfig from the spec
61
+ * @returns Fully resolved sharing configuration
62
+ */
63
+ export function resolveSharingConfig(config: Partial<SharingConfig>): ResolvedSharingConfig {
64
+ return {
65
+ enabled: config.enabled ?? false,
66
+ publicLink: config.publicLink,
67
+ password: config.password,
68
+ allowedDomains: config.allowedDomains ?? [],
69
+ expiresAt: config.expiresAt,
70
+ allowAnonymous: config.allowAnonymous ?? false,
71
+ };
72
+ }
73
+
74
+ // ============================================================================
75
+ // Embed Config Resolution
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Resolve an embed configuration by applying spec defaults.
80
+ *
81
+ * @param config - Partial EmbedConfig from the spec
82
+ * @returns Fully resolved embed configuration
83
+ */
84
+ export function resolveEmbedConfig(config: Partial<EmbedConfig>): ResolvedEmbedConfig {
85
+ return {
86
+ enabled: config.enabled ?? false,
87
+ allowedOrigins: config.allowedOrigins ?? [],
88
+ width: config.width ?? '100%',
89
+ height: config.height ?? '600px',
90
+ showHeader: config.showHeader ?? true,
91
+ showNavigation: config.showNavigation ?? false,
92
+ responsive: config.responsive ?? true,
93
+ };
94
+ }
95
+
96
+ // ============================================================================
97
+ // Embed Code Generation
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Generate an HTML iframe embed snippet from an EmbedConfig and URL.
102
+ *
103
+ * @param config - EmbedConfig from the spec
104
+ * @param url - The URL to embed
105
+ * @returns HTML string containing an iframe element
106
+ */
107
+ export function generateEmbedCode(config: EmbedConfig, url: string): string {
108
+ const resolved = resolveEmbedConfig(config);
109
+ const sanitizedUrl = escapeHtmlAttr(url);
110
+ const title = 'Embedded content';
111
+
112
+ const parts = [
113
+ `<iframe`,
114
+ ` src="${sanitizedUrl}"`,
115
+ ` width="${escapeHtmlAttr(resolved.width)}"`,
116
+ ` height="${escapeHtmlAttr(resolved.height)}"`,
117
+ ` title="${title}"`,
118
+ ` frameborder="0"`,
119
+ ` allowfullscreen`,
120
+ ];
121
+
122
+ if (resolved.responsive) {
123
+ parts.push(` style="max-width: 100%; border: none;"`);
124
+ } else {
125
+ parts.push(` style="border: none;"`);
126
+ }
127
+
128
+ parts.push(`></iframe>`);
129
+
130
+ return parts.join('\n');
131
+ }
132
+
133
+ /**
134
+ * Escape a string for safe use in an HTML attribute value.
135
+ */
136
+ function escapeHtmlAttr(value: string): string {
137
+ return value
138
+ .replace(/&/g, '&amp;')
139
+ .replace(/"/g, '&quot;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;');
142
+ }
143
+
144
+ // ============================================================================
145
+ // Sharing Config Validation
146
+ // ============================================================================
147
+
148
+ /**
149
+ * Validate a sharing configuration and return any errors.
150
+ *
151
+ * @param config - SharingConfig to validate
152
+ * @returns Validation result with `valid` flag and error messages
153
+ */
154
+ export function validateSharingConfig(config: SharingConfig): SharingValidationResult {
155
+ const errors: string[] = [];
156
+
157
+ if (config.enabled && !config.publicLink) {
158
+ errors.push('A public link is required when sharing is enabled.');
159
+ }
160
+
161
+ if (config.expiresAt) {
162
+ const expiryDate = new Date(config.expiresAt);
163
+ if (isNaN(expiryDate.getTime())) {
164
+ errors.push('expiresAt must be a valid ISO 8601 date string.');
165
+ }
166
+ }
167
+
168
+ if (config.allowedDomains) {
169
+ for (const domain of config.allowedDomains) {
170
+ if (!domain || domain.trim().length === 0) {
171
+ errors.push('allowedDomains contains an empty or whitespace-only entry.');
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ if (config.password !== undefined && config.password.length === 0) {
178
+ errors.push('Password must not be an empty string when provided.');
179
+ }
180
+
181
+ return {
182
+ valid: errors.length === 0,
183
+ errors,
184
+ };
185
+ }
@@ -6,10 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
- import { describe, it, expect } from 'vitest';
10
-
11
- describe('core', () => {
12
- it('should work', () => {
13
- expect(true).toBe(true);
14
- });
15
- });
9
+ export * from './DndProtocol.js';
10
+ export * from './KeyboardProtocol.js';
11
+ export * from './NotificationProtocol.js';
12
+ export * from './ResponsiveProtocol.js';
13
+ export * from './SharingProtocol.js';
@@ -0,0 +1,102 @@
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, vi, beforeEach } from 'vitest';
10
+ import { DebugCollector } from '../debug-collector';
11
+
12
+ describe('DebugCollector', () => {
13
+ beforeEach(() => {
14
+ DebugCollector.resetInstance();
15
+ });
16
+
17
+ it('should return a singleton instance', () => {
18
+ const a = DebugCollector.getInstance();
19
+ const b = DebugCollector.getInstance();
20
+ expect(a).toBe(b);
21
+ });
22
+
23
+ it('should collect perf entries', () => {
24
+ const collector = DebugCollector.getInstance();
25
+ collector.addPerf({ type: 'button', id: 'btn1', durationMs: 5.2, timestamp: Date.now() });
26
+ const entries = collector.getEntries('perf');
27
+ expect(entries).toHaveLength(1);
28
+ expect(entries[0].kind).toBe('perf');
29
+ expect((entries[0].data as any).type).toBe('button');
30
+ });
31
+
32
+ it('should collect expr entries', () => {
33
+ const collector = DebugCollector.getInstance();
34
+ collector.addExpr({ expression: '${data.x > 1}', result: true, timestamp: Date.now() });
35
+ const entries = collector.getEntries('expr');
36
+ expect(entries).toHaveLength(1);
37
+ expect(entries[0].kind).toBe('expr');
38
+ expect((entries[0].data as any).result).toBe(true);
39
+ });
40
+
41
+ it('should collect event entries', () => {
42
+ const collector = DebugCollector.getInstance();
43
+ collector.addEvent({ action: 'navigate', payload: { to: '/home' }, timestamp: Date.now() });
44
+ const entries = collector.getEntries('event');
45
+ expect(entries).toHaveLength(1);
46
+ expect(entries[0].kind).toBe('event');
47
+ expect((entries[0].data as any).action).toBe('navigate');
48
+ });
49
+
50
+ it('should return all entries when no kind filter', () => {
51
+ const collector = DebugCollector.getInstance();
52
+ collector.addPerf({ type: 'text', durationMs: 1, timestamp: Date.now() });
53
+ collector.addExpr({ expression: 'a', result: 1, timestamp: Date.now() });
54
+ collector.addEvent({ action: 'click', timestamp: Date.now() });
55
+ expect(collector.getEntries()).toHaveLength(3);
56
+ });
57
+
58
+ it('should notify subscribers on new entry', () => {
59
+ const collector = DebugCollector.getInstance();
60
+ const fn = vi.fn();
61
+ collector.subscribe(fn);
62
+ collector.addPerf({ type: 'card', durationMs: 2, timestamp: Date.now() });
63
+ expect(fn).toHaveBeenCalledTimes(1);
64
+ expect(fn).toHaveBeenCalledWith(expect.objectContaining({ kind: 'perf' }));
65
+ });
66
+
67
+ it('should allow unsubscribe', () => {
68
+ const collector = DebugCollector.getInstance();
69
+ const fn = vi.fn();
70
+ const unsub = collector.subscribe(fn);
71
+ unsub();
72
+ collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
73
+ expect(fn).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('should clear entries', () => {
77
+ const collector = DebugCollector.getInstance();
78
+ collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
79
+ collector.addExpr({ expression: 'a', result: 1, timestamp: Date.now() });
80
+ collector.clear();
81
+ expect(collector.getEntries()).toHaveLength(0);
82
+ });
83
+
84
+ it('should cap entries at MAX_ENTRIES', () => {
85
+ const collector = DebugCollector.getInstance();
86
+ for (let i = 0; i < 250; i++) {
87
+ collector.addPerf({ type: `c${i}`, durationMs: i, timestamp: Date.now() });
88
+ }
89
+ // MAX_ENTRIES is 200
90
+ expect(collector.getEntries().length).toBeLessThanOrEqual(200);
91
+ });
92
+
93
+ it('should swallow subscriber errors gracefully', () => {
94
+ const collector = DebugCollector.getInstance();
95
+ const badFn = vi.fn(() => { throw new Error('boom'); });
96
+ const goodFn = vi.fn();
97
+ collector.subscribe(badFn);
98
+ collector.subscribe(goodFn);
99
+ collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
100
+ expect(goodFn).toHaveBeenCalledTimes(1);
101
+ });
102
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
- import { debugLog, debugTime, debugTimeEnd } from '../debug';
10
+ import { debugLog, debugTime, debugTimeEnd, parseDebugFlags, isDebugEnabled } from '../debug';
11
11
 
12
12
  describe('Debug Utilities', () => {
13
13
  let consoleSpy: ReturnType<typeof vi.spyOn>;
@@ -80,4 +80,55 @@ describe('Debug Utilities', () => {
80
80
  expect(consoleSpy).not.toHaveBeenCalled();
81
81
  });
82
82
  });
83
+
84
+ describe('parseDebugFlags', () => {
85
+ it('should return enabled:false for empty search string', () => {
86
+ expect(parseDebugFlags('')).toEqual({ enabled: false });
87
+ });
88
+
89
+ it('should detect __debug master switch', () => {
90
+ expect(parseDebugFlags('?__debug')).toEqual({ enabled: true });
91
+ });
92
+
93
+ it('should detect individual sub-flags', () => {
94
+ expect(parseDebugFlags('?__debug_schema')).toEqual({ enabled: true, schema: true });
95
+ expect(parseDebugFlags('?__debug_perf')).toEqual({ enabled: true, perf: true });
96
+ expect(parseDebugFlags('?__debug_data')).toEqual({ enabled: true, data: true });
97
+ expect(parseDebugFlags('?__debug_expr')).toEqual({ enabled: true, expr: true });
98
+ expect(parseDebugFlags('?__debug_events')).toEqual({ enabled: true, events: true });
99
+ expect(parseDebugFlags('?__debug_registry')).toEqual({ enabled: true, registry: true });
100
+ });
101
+
102
+ it('should combine multiple sub-flags', () => {
103
+ const flags = parseDebugFlags('?__debug_schema&__debug_perf&__debug_data');
104
+ expect(flags).toEqual({ enabled: true, schema: true, perf: true, data: true });
105
+ });
106
+
107
+ it('should handle unrelated params gracefully', () => {
108
+ const flags = parseDebugFlags('?foo=bar&baz=1');
109
+ expect(flags).toEqual({ enabled: false });
110
+ });
111
+
112
+ it('should handle __debug mixed with sub-flags', () => {
113
+ const flags = parseDebugFlags('?__debug&__debug_schema');
114
+ expect(flags).toEqual({ enabled: true, schema: true });
115
+ });
116
+ });
117
+
118
+ describe('isDebugEnabled', () => {
119
+ it('should return true when globalThis.OBJECTUI_DEBUG is true', () => {
120
+ (globalThis as any).OBJECTUI_DEBUG = true;
121
+ expect(isDebugEnabled()).toBe(true);
122
+ });
123
+
124
+ it('should return true when globalThis.OBJECTUI_DEBUG is "true"', () => {
125
+ (globalThis as any).OBJECTUI_DEBUG = 'true';
126
+ expect(isDebugEnabled()).toBe(true);
127
+ });
128
+
129
+ it('should return false when no debug flag is set', () => {
130
+ (globalThis as any).OBJECTUI_DEBUG = undefined;
131
+ expect(isDebugEnabled()).toBe(false);
132
+ });
133
+ });
83
134
  });
@@ -0,0 +1,120 @@
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 } from 'vitest';
10
+ import { buildExpandFields } from '../expand-fields';
11
+
12
+ describe('buildExpandFields', () => {
13
+ const sampleFields = {
14
+ name: { type: 'text', label: 'Name' },
15
+ email: { type: 'email', label: 'Email' },
16
+ account: { type: 'lookup', label: 'Account', reference_to: 'accounts' },
17
+ parent: { type: 'master_detail', label: 'Parent', reference_to: 'contacts' },
18
+ status: { type: 'select', label: 'Status' },
19
+ };
20
+
21
+ it('should return lookup and master_detail field names', () => {
22
+ const result = buildExpandFields(sampleFields);
23
+ expect(result).toEqual(['account', 'parent']);
24
+ });
25
+
26
+ it('should return empty array when no lookup/master_detail fields exist', () => {
27
+ const fields = {
28
+ name: { type: 'text' },
29
+ age: { type: 'number' },
30
+ };
31
+ expect(buildExpandFields(fields)).toEqual([]);
32
+ });
33
+
34
+ it('should return empty array for null/undefined schema', () => {
35
+ expect(buildExpandFields(null)).toEqual([]);
36
+ expect(buildExpandFields(undefined)).toEqual([]);
37
+ });
38
+
39
+ it('should return empty array for empty fields object', () => {
40
+ expect(buildExpandFields({})).toEqual([]);
41
+ });
42
+
43
+ it('should filter by string columns when provided', () => {
44
+ const result = buildExpandFields(sampleFields, ['name', 'account']);
45
+ expect(result).toEqual(['account']);
46
+ });
47
+
48
+ it('should filter by ListColumn objects with field property', () => {
49
+ const columns = [
50
+ { field: 'name', label: 'Name' },
51
+ { field: 'parent', label: 'Parent Contact' },
52
+ ];
53
+ const result = buildExpandFields(sampleFields, columns);
54
+ expect(result).toEqual(['parent']);
55
+ });
56
+
57
+ it('should support columns with name property', () => {
58
+ const columns = [
59
+ { name: 'account', label: 'Account' },
60
+ ];
61
+ const result = buildExpandFields(sampleFields, columns);
62
+ expect(result).toEqual(['account']);
63
+ });
64
+
65
+ it('should support columns with fieldName property', () => {
66
+ const columns = [
67
+ { fieldName: 'parent', label: 'Parent' },
68
+ ];
69
+ const result = buildExpandFields(sampleFields, columns);
70
+ expect(result).toEqual(['parent']);
71
+ });
72
+
73
+ it('should return empty array when columns have no lookup fields', () => {
74
+ const result = buildExpandFields(sampleFields, ['name', 'email']);
75
+ expect(result).toEqual([]);
76
+ });
77
+
78
+ it('should handle mixed string and object columns', () => {
79
+ const columns = [
80
+ 'name',
81
+ { field: 'account' },
82
+ 'parent',
83
+ ];
84
+ const result = buildExpandFields(sampleFields, columns);
85
+ expect(result).toEqual(['account', 'parent']);
86
+ });
87
+
88
+ it('should return all lookup fields when columns is empty array', () => {
89
+ // Empty columns array does not satisfy the length > 0 check,
90
+ // so no column restriction is applied → all lookup fields returned
91
+ const result = buildExpandFields(sampleFields, []);
92
+ expect(result).toEqual(['account', 'parent']);
93
+ });
94
+
95
+ it('should handle malformed field definitions gracefully', () => {
96
+ const fields = {
97
+ name: null,
98
+ account: { type: 'lookup' },
99
+ broken: 'not-an-object',
100
+ empty: {},
101
+ };
102
+ const result = buildExpandFields(fields as any);
103
+ expect(result).toEqual(['account']);
104
+ });
105
+
106
+ it('should handle only lookup fields', () => {
107
+ const fields = {
108
+ ref1: { type: 'lookup', reference_to: 'obj1' },
109
+ ref2: { type: 'lookup', reference_to: 'obj2' },
110
+ };
111
+ expect(buildExpandFields(fields)).toEqual(['ref1', 'ref2']);
112
+ });
113
+
114
+ it('should handle only master_detail fields', () => {
115
+ const fields = {
116
+ detail1: { type: 'master_detail', reference_to: 'obj1' },
117
+ };
118
+ expect(buildExpandFields(fields)).toEqual(['detail1']);
119
+ });
120
+ });