dzql 0.1.0-alpha.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.
@@ -0,0 +1,618 @@
1
+ /**
2
+ * DZQL UI Configuration Loader
3
+ *
4
+ * Loads UI configurations from JSON files or API endpoints
5
+ * and renders them using the declarative UI framework.
6
+ */
7
+
8
+ import { mount, state, Component, registerComponent } from './ui.js';
9
+
10
+ /**
11
+ * UI Configuration Cache
12
+ */
13
+ const configCache = new Map();
14
+
15
+ /**
16
+ * Load a UI configuration from a URL or object
17
+ */
18
+ export async function loadUI(source, container, ws) {
19
+ let config;
20
+
21
+ if (typeof source === 'string') {
22
+ // Load from URL
23
+ config = await fetchConfig(source);
24
+ } else if (typeof source === 'object') {
25
+ // Direct configuration object
26
+ config = source;
27
+ } else {
28
+ throw new Error('Invalid UI source: must be a URL string or configuration object');
29
+ }
30
+
31
+ // Validate configuration
32
+ validateConfig(config);
33
+
34
+ // Register any custom components defined in the config
35
+ if (config.components) {
36
+ registerCustomComponents(config.components);
37
+ }
38
+
39
+ // Set initial state if provided
40
+ if (config.initialState) {
41
+ initializeState(config.initialState);
42
+ }
43
+
44
+ // Mount the UI
45
+ const instance = mount(config.ui || config, container, ws);
46
+
47
+ // Set up data fetching if configured
48
+ if (config.onMount) {
49
+ await executeLifecycleHook(config.onMount, ws);
50
+ }
51
+
52
+ // Set up periodic updates if configured
53
+ if (config.refreshInterval) {
54
+ setupRefresh(config, instance, ws);
55
+ }
56
+
57
+ return instance;
58
+ }
59
+
60
+ /**
61
+ * Fetch configuration from URL
62
+ */
63
+ async function fetchConfig(url) {
64
+ // Check cache first
65
+ if (configCache.has(url)) {
66
+ return configCache.get(url);
67
+ }
68
+
69
+ try {
70
+ const response = await fetch(url);
71
+ if (!response.ok) {
72
+ throw new Error(`Failed to load UI config: ${response.statusText}`);
73
+ }
74
+
75
+ const config = await response.json();
76
+
77
+ // Cache the configuration
78
+ configCache.set(url, config);
79
+
80
+ return config;
81
+ } catch (error) {
82
+ console.error('Error loading UI configuration:', error);
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Validate UI configuration
89
+ */
90
+ function validateConfig(config) {
91
+ if (!config) {
92
+ throw new Error('UI configuration is required');
93
+ }
94
+
95
+ // Check for required fields based on config type
96
+ if (config.version && config.version !== '1.0') {
97
+ console.warn(`Unknown UI config version: ${config.version}`);
98
+ }
99
+
100
+ // Validate component structure if it's a direct component config
101
+ if (config.type && !config.ui) {
102
+ validateComponent(config);
103
+ } else if (config.ui) {
104
+ validateComponent(config.ui);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate component structure
110
+ */
111
+ function validateComponent(component) {
112
+ if (!component.type) {
113
+ throw new Error('Component must have a type');
114
+ }
115
+
116
+ // Recursively validate children
117
+ if (component.children && Array.isArray(component.children)) {
118
+ component.children.forEach(validateComponent);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Initialize state from configuration
124
+ */
125
+ function initializeState(initialState) {
126
+ for (const [key, value] of Object.entries(initialState)) {
127
+ if (typeof value === 'function') {
128
+ // Skip functions in initial state
129
+ continue;
130
+ }
131
+ state.set(key, value);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Register custom components from configuration
137
+ */
138
+ function registerCustomComponents(components) {
139
+ for (const [name, componentDef] of Object.entries(components)) {
140
+ // Create a component class from the definition
141
+ class CustomComponent extends Component {
142
+ render() {
143
+ // Use the template defined in the component
144
+ const template = typeof componentDef.template === 'function'
145
+ ? componentDef.template(this.config.props || {})
146
+ : componentDef.template;
147
+
148
+ // Merge props into the template
149
+ const mergedConfig = {
150
+ ...template,
151
+ ...this.config,
152
+ type: template.type
153
+ };
154
+
155
+ // Render using the base component
156
+ const element = document.createElement('div');
157
+ this.element = element;
158
+
159
+ // Render the template
160
+ const child = renderComponent(mergedConfig, this.ws);
161
+ if (child) {
162
+ this.children.push(child);
163
+ element.appendChild(child.element);
164
+ }
165
+
166
+ return element;
167
+ }
168
+ }
169
+
170
+ registerComponent(name, CustomComponent);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Execute lifecycle hook
176
+ */
177
+ async function executeLifecycleHook(hook, ws) {
178
+ if (!hook) return;
179
+
180
+ if (Array.isArray(hook)) {
181
+ // Array of actions
182
+ for (const action of hook) {
183
+ await executeAction(action, ws);
184
+ }
185
+ } else if (typeof hook === 'object') {
186
+ // Single action
187
+ await executeAction(hook, ws);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Execute an action
193
+ */
194
+ async function executeAction(action, ws) {
195
+ switch (action.type) {
196
+ case 'fetch':
197
+ await fetchData(action, ws);
198
+ break;
199
+ case 'setState':
200
+ state.set(action.path, action.value);
201
+ break;
202
+ case 'call':
203
+ await callAPI(action, ws);
204
+ break;
205
+ default:
206
+ console.warn(`Unknown action type: ${action.type}`);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Fetch data using DZQL
212
+ */
213
+ async function fetchData(action, ws) {
214
+ try {
215
+ const { entity, operation, params, resultPath } = action;
216
+
217
+ let result;
218
+ if (operation && entity) {
219
+ result = await ws.api[operation][entity](params || {});
220
+ } else if (action.method) {
221
+ result = await ws.call(action.method, params || {});
222
+ }
223
+
224
+ if (resultPath) {
225
+ state.set(resultPath, result);
226
+ }
227
+ } catch (error) {
228
+ console.error('Error fetching data:', error);
229
+ if (action.errorPath) {
230
+ state.set(action.errorPath, error.message);
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Call API endpoint
237
+ */
238
+ async function callAPI(action, ws) {
239
+ try {
240
+ const result = await ws.call(action.method, action.params || {});
241
+ if (action.resultPath) {
242
+ state.set(action.resultPath, result);
243
+ }
244
+ } catch (error) {
245
+ console.error('API call error:', error);
246
+ if (action.errorPath) {
247
+ state.set(action.errorPath, error.message);
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Set up automatic refresh
254
+ */
255
+ function setupRefresh(config, instance, ws) {
256
+ const interval = setInterval(async () => {
257
+ if (config.onRefresh) {
258
+ await executeLifecycleHook(config.onRefresh, ws);
259
+ }
260
+ }, config.refreshInterval);
261
+
262
+ // Store interval ID for cleanup
263
+ instance.refreshInterval = interval;
264
+
265
+ // Override destroy to clear interval
266
+ const originalDestroy = instance.destroy;
267
+ instance.destroy = () => {
268
+ clearInterval(interval);
269
+ originalDestroy?.();
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Load UI from entity metadata
275
+ */
276
+ export async function loadEntityUI(entityName, viewType, container, ws) {
277
+ try {
278
+ // Fetch entity metadata from DZQL
279
+ const metadata = await ws.call('dzql.get_entity_metadata', { entity: entityName });
280
+
281
+ // Generate UI based on metadata and view type
282
+ const config = generateEntityUI(metadata, viewType);
283
+
284
+ // Load the generated UI
285
+ return await loadUI(config, container, ws);
286
+ } catch (error) {
287
+ console.error(`Error loading entity UI for ${entityName}:`, error);
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Generate UI configuration from entity metadata
294
+ */
295
+ function generateEntityUI(metadata, viewType = 'list') {
296
+ const { table_name, columns, label_field, searchable_fields } = metadata;
297
+
298
+ switch (viewType) {
299
+ case 'list':
300
+ return generateListView(table_name, columns, searchable_fields);
301
+ case 'detail':
302
+ return generateDetailView(table_name, columns);
303
+ case 'form':
304
+ return generateFormView(table_name, columns);
305
+ case 'search':
306
+ return generateSearchView(table_name, columns, searchable_fields);
307
+ default:
308
+ throw new Error(`Unknown view type: ${viewType}`);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Generate list view configuration
314
+ */
315
+ function generateListView(tableName, columns, searchableFields) {
316
+ return {
317
+ type: 'container',
318
+ class: 'entity-list',
319
+ children: [
320
+ {
321
+ type: 'h2',
322
+ text: `${tableName} List`
323
+ },
324
+ {
325
+ type: 'div',
326
+ class: 'controls',
327
+ children: [
328
+ {
329
+ type: 'input',
330
+ bind: `\${state.${tableName}.searchText}`,
331
+ attributes: {
332
+ placeholder: `Search ${tableName}...`
333
+ }
334
+ },
335
+ {
336
+ type: 'button',
337
+ text: 'Search',
338
+ onClick: {
339
+ actions: [
340
+ {
341
+ type: 'call',
342
+ operation: 'search',
343
+ entity: tableName,
344
+ params: {
345
+ filters: {
346
+ _search: `\${state.${tableName}.searchText}`
347
+ },
348
+ limit: 25
349
+ },
350
+ resultPath: `${tableName}.results`
351
+ }
352
+ ]
353
+ }
354
+ },
355
+ {
356
+ type: 'button',
357
+ text: 'New',
358
+ onClick: {
359
+ actions: [
360
+ {
361
+ type: 'setState',
362
+ path: `${tableName}.showForm`,
363
+ value: true
364
+ }
365
+ ]
366
+ }
367
+ }
368
+ ]
369
+ },
370
+ {
371
+ type: 'table',
372
+ data: `\${state.${tableName}.results.data}`,
373
+ columns: columns.map(col => ({
374
+ field: col.name,
375
+ label: col.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
376
+ })),
377
+ onRowClick: {
378
+ type: 'setState',
379
+ path: `${tableName}.selected`,
380
+ value: '\${row}'
381
+ }
382
+ }
383
+ ]
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Generate detail view configuration
389
+ */
390
+ function generateDetailView(tableName, columns) {
391
+ return {
392
+ type: 'container',
393
+ class: 'entity-detail',
394
+ children: [
395
+ {
396
+ type: 'h2',
397
+ text: `${tableName} Details`
398
+ },
399
+ {
400
+ type: 'if',
401
+ condition: `\${state.${tableName}.selected}`,
402
+ then: {
403
+ type: 'div',
404
+ class: 'detail-grid',
405
+ children: columns.map(col => ({
406
+ type: 'div',
407
+ class: 'detail-field',
408
+ children: [
409
+ {
410
+ type: 'label',
411
+ text: col.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
412
+ },
413
+ {
414
+ type: 'div',
415
+ class: 'detail-value',
416
+ text: `\${state.${tableName}.selected.${col.name}}`
417
+ }
418
+ ]
419
+ }))
420
+ },
421
+ else: {
422
+ type: 'p',
423
+ text: 'No item selected'
424
+ }
425
+ }
426
+ ]
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Generate form view configuration
432
+ */
433
+ function generateFormView(tableName, columns) {
434
+ // Filter out auto-generated columns
435
+ const editableColumns = columns.filter(col =>
436
+ !col.is_primary && !col.is_generated && col.name !== 'created_at' && col.name !== 'updated_at'
437
+ );
438
+
439
+ return {
440
+ type: 'form',
441
+ dataPath: `${tableName}.formData`,
442
+ fields: editableColumns.map(col => ({
443
+ name: col.name,
444
+ label: col.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
445
+ inputType: getInputType(col.type),
446
+ bind: `\${state.${tableName}.formData.${col.name}}`,
447
+ required: !col.is_nullable
448
+ })),
449
+ submitButton: {
450
+ text: 'Save',
451
+ onClick: {
452
+ actions: [
453
+ {
454
+ type: 'call',
455
+ operation: 'save',
456
+ entity: tableName,
457
+ params: `\${state.${tableName}.formData}`,
458
+ resultPath: `${tableName}.saveResult`,
459
+ onSuccess: [
460
+ {
461
+ type: 'setState',
462
+ path: `${tableName}.showForm`,
463
+ value: false
464
+ },
465
+ {
466
+ type: 'setState',
467
+ path: `${tableName}.formData`,
468
+ value: {}
469
+ }
470
+ ]
471
+ }
472
+ ]
473
+ }
474
+ }
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Generate search view configuration
480
+ */
481
+ function generateSearchView(tableName, columns, searchableFields) {
482
+ return {
483
+ type: 'container',
484
+ class: 'entity-search',
485
+ children: [
486
+ {
487
+ type: 'h2',
488
+ text: `Search ${tableName}`
489
+ },
490
+ {
491
+ type: 'div',
492
+ class: 'search-filters',
493
+ children: [
494
+ // Text search across searchable fields
495
+ {
496
+ type: 'div',
497
+ class: 'filter-group',
498
+ children: [
499
+ {
500
+ type: 'label',
501
+ text: 'Search Text'
502
+ },
503
+ {
504
+ type: 'input',
505
+ bind: `\${state.${tableName}.search._search}`,
506
+ attributes: {
507
+ placeholder: `Search in ${searchableFields.join(', ')}`
508
+ }
509
+ }
510
+ ]
511
+ },
512
+ // Generate filter inputs for key columns
513
+ ...columns.filter(col => !col.is_generated).slice(0, 5).map(col => ({
514
+ type: 'div',
515
+ class: 'filter-group',
516
+ children: [
517
+ {
518
+ type: 'label',
519
+ text: col.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
520
+ },
521
+ {
522
+ type: 'input',
523
+ inputType: getInputType(col.type),
524
+ bind: `\${state.${tableName}.search.${col.name}}`
525
+ }
526
+ ]
527
+ }))
528
+ ]
529
+ },
530
+ {
531
+ type: 'button',
532
+ text: 'Search',
533
+ onClick: {
534
+ actions: [
535
+ {
536
+ type: 'call',
537
+ operation: 'search',
538
+ entity: tableName,
539
+ params: {
540
+ filters: `\${state.${tableName}.search}`,
541
+ limit: 50
542
+ },
543
+ resultPath: `${tableName}.searchResults`
544
+ }
545
+ ]
546
+ }
547
+ },
548
+ {
549
+ type: 'if',
550
+ condition: `\${state.${tableName}.searchResults}`,
551
+ then: {
552
+ type: 'div',
553
+ children: [
554
+ {
555
+ type: 'p',
556
+ text: `Found \${state.${tableName}.searchResults.total} results`
557
+ },
558
+ {
559
+ type: 'table',
560
+ data: `\${state.${tableName}.searchResults.data}`,
561
+ columns: columns.slice(0, 6).map(col => ({
562
+ field: col.name,
563
+ label: col.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
564
+ }))
565
+ }
566
+ ]
567
+ }
568
+ }
569
+ ]
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Get appropriate input type for column type
575
+ */
576
+ function getInputType(columnType) {
577
+ const type = columnType.toLowerCase();
578
+ if (type.includes('int') || type.includes('numeric') || type.includes('decimal')) {
579
+ return 'number';
580
+ } else if (type.includes('date') && !type.includes('time')) {
581
+ return 'date';
582
+ } else if (type.includes('datetime') || type.includes('timestamp')) {
583
+ return 'datetime-local';
584
+ } else if (type.includes('time')) {
585
+ return 'time';
586
+ } else if (type.includes('bool')) {
587
+ return 'checkbox';
588
+ } else if (type.includes('email')) {
589
+ return 'email';
590
+ } else if (type.includes('url')) {
591
+ return 'url';
592
+ } else if (type.includes('text') || type.includes('json')) {
593
+ return 'textarea';
594
+ }
595
+ return 'text';
596
+ }
597
+
598
+ /**
599
+ * Clear configuration cache
600
+ */
601
+ export function clearConfigCache(url = null) {
602
+ if (url) {
603
+ configCache.delete(url);
604
+ } else {
605
+ configCache.clear();
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Preload configurations
611
+ */
612
+ export async function preloadConfigs(urls) {
613
+ const promises = urls.map(url => fetchConfig(url));
614
+ return await Promise.all(promises);
615
+ }
616
+
617
+ // Export for use in other modules
618
+ export { state };