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.
- package/package.json +65 -0
- package/src/client/ui-configs/sample-2.js +207 -0
- package/src/client/ui-loader.js +618 -0
- package/src/client/ui.js +990 -0
- package/src/client/ws.js +352 -0
- package/src/client.js +9 -0
- package/src/database/migrations/001_schema.sql +59 -0
- package/src/database/migrations/002_functions.sql +742 -0
- package/src/database/migrations/003_operations.sql +725 -0
- package/src/database/migrations/004_search.sql +505 -0
- package/src/database/migrations/005_entities.sql +511 -0
- package/src/database/migrations/006_auth.sql +83 -0
- package/src/database/migrations/007_events.sql +136 -0
- package/src/database/migrations/008_hello.sql +18 -0
- package/src/database/migrations/008a_meta.sql +165 -0
- package/src/index.js +19 -0
- package/src/server/api.js +9 -0
- package/src/server/db.js +261 -0
- package/src/server/index.js +141 -0
- package/src/server/logger.js +246 -0
- package/src/server/mcp.js +594 -0
- package/src/server/meta-route.js +251 -0
- package/src/server/ws.js +464 -0
package/src/client/ui.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DZQL Declarative UI Framework
|
|
3
|
+
*
|
|
4
|
+
* Renders adaptive UI components from JSON descriptions.
|
|
5
|
+
* Handles all DZQL operations declaratively with automatic state management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Component Registry - Maps component types to render functions
|
|
9
|
+
const componentRegistry = new Map();
|
|
10
|
+
|
|
11
|
+
// Global state management
|
|
12
|
+
class StateManager {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.state = {};
|
|
15
|
+
this.listeners = new Map();
|
|
16
|
+
this.componentStates = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get(path) {
|
|
20
|
+
const keys = path.split('.');
|
|
21
|
+
let value = this.state;
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
value = value?.[key];
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set(path, value) {
|
|
29
|
+
const keys = path.split('.');
|
|
30
|
+
const lastKey = keys.pop();
|
|
31
|
+
let target = this.state;
|
|
32
|
+
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if (!target[key]) target[key] = {};
|
|
35
|
+
target = target[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
target[lastKey] = value;
|
|
39
|
+
this.notify(path, value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
subscribe(path, callback) {
|
|
43
|
+
if (!this.listeners.has(path)) {
|
|
44
|
+
this.listeners.set(path, new Set());
|
|
45
|
+
}
|
|
46
|
+
this.listeners.get(path).add(callback);
|
|
47
|
+
return () => this.listeners.get(path)?.delete(callback);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
notify(path, value) {
|
|
51
|
+
// Notify exact path listeners
|
|
52
|
+
this.listeners.get(path)?.forEach(cb => cb(value));
|
|
53
|
+
|
|
54
|
+
// Notify parent path listeners
|
|
55
|
+
const parts = path.split('.');
|
|
56
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
57
|
+
const parentPath = parts.slice(0, i).join('.');
|
|
58
|
+
this.listeners.get(parentPath)?.forEach(cb => cb(this.get(parentPath)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Component-specific state
|
|
63
|
+
getComponentState(componentId) {
|
|
64
|
+
if (!this.componentStates.has(componentId)) {
|
|
65
|
+
this.componentStates.set(componentId, {});
|
|
66
|
+
}
|
|
67
|
+
return this.componentStates.get(componentId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setComponentState(componentId, state) {
|
|
71
|
+
this.componentStates.set(componentId, state);
|
|
72
|
+
this.notify(`component.${componentId}`, state);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const state = new StateManager();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Base Component Class
|
|
80
|
+
*/
|
|
81
|
+
class Component {
|
|
82
|
+
constructor(config, ws) {
|
|
83
|
+
this.config = config;
|
|
84
|
+
this.ws = ws;
|
|
85
|
+
this.id = config.id || `comp_${Math.random().toString(36).substr(2, 9)}`;
|
|
86
|
+
this.element = null;
|
|
87
|
+
this.children = [];
|
|
88
|
+
this.subscriptions = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Evaluate value - handles static values, state bindings, and expressions
|
|
92
|
+
evaluate(value) {
|
|
93
|
+
if (typeof value !== 'string') return value;
|
|
94
|
+
|
|
95
|
+
// State binding: ${state.path}
|
|
96
|
+
if (value.startsWith('${') && value.endsWith('}')) {
|
|
97
|
+
const path = value.slice(2, -1).trim();
|
|
98
|
+
if (path.startsWith('state.')) {
|
|
99
|
+
return state.get(path.substring(6));
|
|
100
|
+
}
|
|
101
|
+
if (path.startsWith('component.')) {
|
|
102
|
+
const componentState = state.getComponentState(this.id);
|
|
103
|
+
const componentPath = path.substring(10);
|
|
104
|
+
return componentPath.split('.').reduce((obj, key) => obj?.[key], componentState);
|
|
105
|
+
}
|
|
106
|
+
// Evaluate as expression
|
|
107
|
+
try {
|
|
108
|
+
return new Function('state', 'component', `return ${path}`)(
|
|
109
|
+
state.state,
|
|
110
|
+
state.getComponentState(this.id)
|
|
111
|
+
);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.warn(`Failed to evaluate expression: ${path}`, e);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bind to state changes
|
|
122
|
+
bind(path, callback) {
|
|
123
|
+
const unsubscribe = state.subscribe(path, callback);
|
|
124
|
+
this.subscriptions.push(unsubscribe);
|
|
125
|
+
return unsubscribe;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Process event handlers
|
|
129
|
+
handleEvent(eventConfig) {
|
|
130
|
+
return async (event) => {
|
|
131
|
+
event.preventDefault?.();
|
|
132
|
+
|
|
133
|
+
for (const action of (eventConfig.actions || [])) {
|
|
134
|
+
await this.executeAction(action, event);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Execute an action
|
|
140
|
+
async executeAction(action, event) {
|
|
141
|
+
switch (action.type) {
|
|
142
|
+
case 'setState':
|
|
143
|
+
state.set(action.path, this.evaluate(action.value));
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
case 'setComponentState':
|
|
147
|
+
const currentState = state.getComponentState(this.id);
|
|
148
|
+
const newState = { ...currentState, [action.key]: this.evaluate(action.value) };
|
|
149
|
+
state.setComponentState(this.id, newState);
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case 'call':
|
|
153
|
+
await this.callDZQL(action);
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case 'emit':
|
|
157
|
+
this.emit(action.event, this.evaluate(action.data));
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case 'navigate':
|
|
161
|
+
window.location.href = this.evaluate(action.url);
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'alert':
|
|
165
|
+
alert(this.evaluate(action.message));
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
case 'console':
|
|
169
|
+
console[action.level || 'log'](this.evaluate(action.message));
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
console.warn(`Unknown action type: ${action.type}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Call DZQL API
|
|
178
|
+
async callDZQL(action) {
|
|
179
|
+
try {
|
|
180
|
+
const { operation, entity, params, onSuccess, onError, resultPath } = action;
|
|
181
|
+
|
|
182
|
+
// Evaluate params
|
|
183
|
+
const evaluatedParams = {};
|
|
184
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
185
|
+
evaluatedParams[key] = this.evaluate(value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Make the call
|
|
189
|
+
let result;
|
|
190
|
+
if (operation && entity) {
|
|
191
|
+
// DZQL operation: ws.api.{operation}.{entity}(params)
|
|
192
|
+
result = await this.ws.api[operation][entity](evaluatedParams);
|
|
193
|
+
} else if (action.method) {
|
|
194
|
+
// Direct method call: ws.call(method, params)
|
|
195
|
+
result = await this.ws.call(action.method, evaluatedParams);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Store result if path provided
|
|
199
|
+
if (resultPath) {
|
|
200
|
+
state.set(resultPath, result);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Execute success actions
|
|
204
|
+
if (onSuccess) {
|
|
205
|
+
for (const successAction of onSuccess) {
|
|
206
|
+
await this.executeAction(successAction);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('DZQL call failed:', error);
|
|
211
|
+
|
|
212
|
+
// Store error if path provided
|
|
213
|
+
if (action.errorPath) {
|
|
214
|
+
state.set(action.errorPath, error.message);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Execute error actions
|
|
218
|
+
if (action.onError) {
|
|
219
|
+
for (const errorAction of action.onError) {
|
|
220
|
+
await this.executeAction(errorAction);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Emit custom event
|
|
227
|
+
emit(eventName, data) {
|
|
228
|
+
const event = new CustomEvent(eventName, { detail: data, bubbles: true });
|
|
229
|
+
this.element?.dispatchEvent(event);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Cleanup
|
|
233
|
+
destroy() {
|
|
234
|
+
this.subscriptions.forEach(unsub => unsub());
|
|
235
|
+
this.children.forEach(child => child.destroy());
|
|
236
|
+
this.element?.remove();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Base render method (override in subclasses)
|
|
240
|
+
render() {
|
|
241
|
+
throw new Error('Component must implement render()');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Container Component - Renders child components
|
|
247
|
+
*/
|
|
248
|
+
class ContainerComponent extends Component {
|
|
249
|
+
render() {
|
|
250
|
+
const el = document.createElement(this.config.tag || 'div');
|
|
251
|
+
this.element = el;
|
|
252
|
+
|
|
253
|
+
// Apply attributes
|
|
254
|
+
if (this.config.attributes) {
|
|
255
|
+
for (const [key, value] of Object.entries(this.config.attributes)) {
|
|
256
|
+
el.setAttribute(key, this.evaluate(value));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Apply styles
|
|
261
|
+
if (this.config.style) {
|
|
262
|
+
Object.assign(el.style, this.config.style);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Apply classes
|
|
266
|
+
if (this.config.class) {
|
|
267
|
+
el.className = this.evaluate(this.config.class);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Render children
|
|
271
|
+
if (this.config.children) {
|
|
272
|
+
for (const childConfig of this.config.children) {
|
|
273
|
+
const child = renderComponent(childConfig, this.ws);
|
|
274
|
+
if (child) {
|
|
275
|
+
this.children.push(child);
|
|
276
|
+
el.appendChild(child.element);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Bind to visibility
|
|
282
|
+
if (this.config.visible) {
|
|
283
|
+
const updateVisibility = () => {
|
|
284
|
+
el.style.display = this.evaluate(this.config.visible) ? '' : 'none';
|
|
285
|
+
};
|
|
286
|
+
updateVisibility();
|
|
287
|
+
|
|
288
|
+
if (this.config.visible.includes('${')) {
|
|
289
|
+
const path = this.config.visible.slice(2, -1).split('.').slice(1).join('.');
|
|
290
|
+
this.bind(path, updateVisibility);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return el;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Text Component - Renders text content
|
|
300
|
+
*/
|
|
301
|
+
class TextComponent extends Component {
|
|
302
|
+
render() {
|
|
303
|
+
const el = document.createElement(this.config.tag || 'span');
|
|
304
|
+
this.element = el;
|
|
305
|
+
|
|
306
|
+
const updateText = () => {
|
|
307
|
+
el.textContent = this.evaluate(this.config.text || this.config.content || '');
|
|
308
|
+
};
|
|
309
|
+
updateText();
|
|
310
|
+
|
|
311
|
+
// Bind to state changes if needed
|
|
312
|
+
const content = this.config.text || this.config.content || '';
|
|
313
|
+
if (typeof content === 'string' && content.includes('${')) {
|
|
314
|
+
const match = content.match(/\${state\.([^}]+)}/);
|
|
315
|
+
if (match) {
|
|
316
|
+
this.bind(match[1], updateText);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Apply attributes
|
|
321
|
+
if (this.config.attributes) {
|
|
322
|
+
for (const [key, value] of Object.entries(this.config.attributes)) {
|
|
323
|
+
el.setAttribute(key, this.evaluate(value));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Apply styles
|
|
328
|
+
if (this.config.style) {
|
|
329
|
+
Object.assign(el.style, this.config.style);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return el;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Input Component - Form input with two-way binding
|
|
338
|
+
*/
|
|
339
|
+
class InputComponent extends Component {
|
|
340
|
+
render() {
|
|
341
|
+
const el = document.createElement('input');
|
|
342
|
+
this.element = el;
|
|
343
|
+
|
|
344
|
+
// Set type
|
|
345
|
+
el.type = this.config.inputType || 'text';
|
|
346
|
+
|
|
347
|
+
// Set initial value and bind
|
|
348
|
+
if (this.config.bind) {
|
|
349
|
+
const path = this.config.bind.replace('${state.', '').replace('}', '');
|
|
350
|
+
el.value = state.get(path) || '';
|
|
351
|
+
|
|
352
|
+
// Two-way binding
|
|
353
|
+
el.addEventListener('input', () => {
|
|
354
|
+
state.set(path, el.value);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
this.bind(path, (value) => {
|
|
358
|
+
if (el.value !== value) {
|
|
359
|
+
el.value = value || '';
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Apply attributes
|
|
365
|
+
if (this.config.attributes) {
|
|
366
|
+
for (const [key, value] of Object.entries(this.config.attributes)) {
|
|
367
|
+
el.setAttribute(key, this.evaluate(value));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Handle events
|
|
372
|
+
if (this.config.events) {
|
|
373
|
+
for (const [eventName, eventConfig] of Object.entries(this.config.events)) {
|
|
374
|
+
el.addEventListener(eventName, this.handleEvent(eventConfig));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return el;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Button Component
|
|
384
|
+
*/
|
|
385
|
+
class ButtonComponent extends Component {
|
|
386
|
+
render() {
|
|
387
|
+
const el = document.createElement('button');
|
|
388
|
+
this.element = el;
|
|
389
|
+
|
|
390
|
+
// Set text
|
|
391
|
+
const updateText = () => {
|
|
392
|
+
el.textContent = this.evaluate(this.config.text || 'Button');
|
|
393
|
+
};
|
|
394
|
+
updateText();
|
|
395
|
+
|
|
396
|
+
// Bind text to state if needed
|
|
397
|
+
if (this.config.text?.includes('${')) {
|
|
398
|
+
const match = this.config.text.match(/\${state\.([^}]+)}/);
|
|
399
|
+
if (match) {
|
|
400
|
+
this.bind(match[1], updateText);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Handle click event
|
|
405
|
+
if (this.config.onClick) {
|
|
406
|
+
el.addEventListener('click', this.handleEvent(this.config.onClick));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Apply attributes
|
|
410
|
+
if (this.config.attributes) {
|
|
411
|
+
for (const [key, value] of Object.entries(this.config.attributes)) {
|
|
412
|
+
el.setAttribute(key, this.evaluate(value));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Apply styles
|
|
417
|
+
if (this.config.style) {
|
|
418
|
+
Object.assign(el.style, this.config.style);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Handle disabled state
|
|
422
|
+
if (this.config.disabled !== undefined) {
|
|
423
|
+
const updateDisabled = () => {
|
|
424
|
+
el.disabled = this.evaluate(this.config.disabled);
|
|
425
|
+
};
|
|
426
|
+
updateDisabled();
|
|
427
|
+
|
|
428
|
+
if (typeof this.config.disabled === 'string' && this.config.disabled.includes('${')) {
|
|
429
|
+
const match = this.config.disabled.match(/\${state\.([^}]+)}/);
|
|
430
|
+
if (match) {
|
|
431
|
+
this.bind(match[1], updateDisabled);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return el;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Table Component - Renders data tables with DZQL integration
|
|
442
|
+
*/
|
|
443
|
+
class TableComponent extends Component {
|
|
444
|
+
render() {
|
|
445
|
+
const container = document.createElement('div');
|
|
446
|
+
this.element = container;
|
|
447
|
+
container.className = 'table-container';
|
|
448
|
+
|
|
449
|
+
const renderTable = (data) => {
|
|
450
|
+
container.innerHTML = '';
|
|
451
|
+
|
|
452
|
+
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
453
|
+
container.innerHTML = '<p>No data available</p>';
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const table = document.createElement('table');
|
|
458
|
+
table.className = this.config.class || '';
|
|
459
|
+
|
|
460
|
+
// Header
|
|
461
|
+
const thead = document.createElement('thead');
|
|
462
|
+
const headerRow = document.createElement('tr');
|
|
463
|
+
const columns = this.config.columns || Object.keys(data[0]);
|
|
464
|
+
|
|
465
|
+
columns.forEach(col => {
|
|
466
|
+
const th = document.createElement('th');
|
|
467
|
+
th.textContent = typeof col === 'object' ? col.label : col;
|
|
468
|
+
headerRow.appendChild(th);
|
|
469
|
+
});
|
|
470
|
+
thead.appendChild(headerRow);
|
|
471
|
+
table.appendChild(thead);
|
|
472
|
+
|
|
473
|
+
// Body
|
|
474
|
+
const tbody = document.createElement('tbody');
|
|
475
|
+
data.forEach(row => {
|
|
476
|
+
const tr = document.createElement('tr');
|
|
477
|
+
columns.forEach(col => {
|
|
478
|
+
const td = document.createElement('td');
|
|
479
|
+
const field = typeof col === 'object' ? col.field : col;
|
|
480
|
+
const value = row[field];
|
|
481
|
+
|
|
482
|
+
// Handle nested values
|
|
483
|
+
if (field.includes('.')) {
|
|
484
|
+
const parts = field.split('.');
|
|
485
|
+
let nestedValue = row;
|
|
486
|
+
for (const part of parts) {
|
|
487
|
+
nestedValue = nestedValue?.[part];
|
|
488
|
+
}
|
|
489
|
+
td.textContent = nestedValue ?? '';
|
|
490
|
+
} else {
|
|
491
|
+
td.textContent = value ?? '';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
tr.appendChild(td);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Row click handler
|
|
498
|
+
if (this.config.onRowClick) {
|
|
499
|
+
tr.style.cursor = 'pointer';
|
|
500
|
+
tr.addEventListener('click', () => {
|
|
501
|
+
this.executeAction({
|
|
502
|
+
...this.config.onRowClick,
|
|
503
|
+
value: row
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
tbody.appendChild(tr);
|
|
509
|
+
});
|
|
510
|
+
table.appendChild(tbody);
|
|
511
|
+
|
|
512
|
+
container.appendChild(table);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Initial render
|
|
516
|
+
if (this.config.data) {
|
|
517
|
+
const data = this.evaluate(this.config.data);
|
|
518
|
+
renderTable(data);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Auto-fetch if configured
|
|
522
|
+
if (this.config.fetch) {
|
|
523
|
+
this.callDZQL({
|
|
524
|
+
...this.config.fetch,
|
|
525
|
+
onSuccess: [{
|
|
526
|
+
type: 'setState',
|
|
527
|
+
path: this.config.dataPath || `tables.${this.id}`,
|
|
528
|
+
value: '${result}'
|
|
529
|
+
}]
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Bind to data changes
|
|
534
|
+
if (this.config.dataPath || this.config.data?.includes('${')) {
|
|
535
|
+
const path = this.config.dataPath || this.config.data.slice(9, -1); // Remove ${state. and }
|
|
536
|
+
this.bind(path, renderTable);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return container;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Form Component - Handles form submission with validation
|
|
545
|
+
*/
|
|
546
|
+
class FormComponent extends Component {
|
|
547
|
+
render() {
|
|
548
|
+
const form = document.createElement('form');
|
|
549
|
+
this.element = form;
|
|
550
|
+
|
|
551
|
+
// Render fields
|
|
552
|
+
if (this.config.fields) {
|
|
553
|
+
this.config.fields.forEach(fieldConfig => {
|
|
554
|
+
const fieldContainer = document.createElement('div');
|
|
555
|
+
fieldContainer.className = 'form-field';
|
|
556
|
+
|
|
557
|
+
// Label
|
|
558
|
+
if (fieldConfig.label) {
|
|
559
|
+
const label = document.createElement('label');
|
|
560
|
+
label.textContent = fieldConfig.label;
|
|
561
|
+
if (fieldConfig.id) {
|
|
562
|
+
label.setAttribute('for', fieldConfig.id);
|
|
563
|
+
}
|
|
564
|
+
fieldContainer.appendChild(label);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Input
|
|
568
|
+
const input = renderComponent({
|
|
569
|
+
type: 'input',
|
|
570
|
+
...fieldConfig
|
|
571
|
+
}, this.ws);
|
|
572
|
+
|
|
573
|
+
this.children.push(input);
|
|
574
|
+
fieldContainer.appendChild(input.element);
|
|
575
|
+
|
|
576
|
+
form.appendChild(fieldContainer);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Submit button
|
|
581
|
+
if (this.config.submitButton) {
|
|
582
|
+
const submitBtn = renderComponent({
|
|
583
|
+
type: 'button',
|
|
584
|
+
text: 'Submit',
|
|
585
|
+
...this.config.submitButton
|
|
586
|
+
}, this.ws);
|
|
587
|
+
this.children.push(submitBtn);
|
|
588
|
+
form.appendChild(submitBtn.element);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Handle form submission
|
|
592
|
+
if (this.config.onSubmit) {
|
|
593
|
+
form.addEventListener('submit', async (e) => {
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
|
|
596
|
+
// Collect form data
|
|
597
|
+
const formData = {};
|
|
598
|
+
const inputs = form.querySelectorAll('input, select, textarea');
|
|
599
|
+
inputs.forEach(input => {
|
|
600
|
+
if (input.name) {
|
|
601
|
+
formData[input.name] = input.value;
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Store form data in state
|
|
606
|
+
if (this.config.dataPath) {
|
|
607
|
+
state.set(this.config.dataPath, formData);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Execute submit actions
|
|
611
|
+
await this.handleEvent(this.config.onSubmit)(e);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return form;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Select Component - Dropdown with DZQL lookup integration
|
|
621
|
+
*/
|
|
622
|
+
class SelectComponent extends Component {
|
|
623
|
+
render() {
|
|
624
|
+
const select = document.createElement('select');
|
|
625
|
+
this.element = select;
|
|
626
|
+
|
|
627
|
+
// Set name
|
|
628
|
+
if (this.config.name) {
|
|
629
|
+
select.name = this.config.name;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Render options
|
|
633
|
+
const renderOptions = (options) => {
|
|
634
|
+
select.innerHTML = '';
|
|
635
|
+
|
|
636
|
+
// Add placeholder
|
|
637
|
+
if (this.config.placeholder) {
|
|
638
|
+
const option = document.createElement('option');
|
|
639
|
+
option.value = '';
|
|
640
|
+
option.textContent = this.config.placeholder;
|
|
641
|
+
option.disabled = true;
|
|
642
|
+
option.selected = true;
|
|
643
|
+
select.appendChild(option);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Add options
|
|
647
|
+
if (options && Array.isArray(options)) {
|
|
648
|
+
options.forEach(opt => {
|
|
649
|
+
const option = document.createElement('option');
|
|
650
|
+
if (typeof opt === 'object') {
|
|
651
|
+
option.value = opt.value;
|
|
652
|
+
option.textContent = opt.label || opt.text;
|
|
653
|
+
} else {
|
|
654
|
+
option.value = opt;
|
|
655
|
+
option.textContent = opt;
|
|
656
|
+
}
|
|
657
|
+
select.appendChild(option);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Initial options
|
|
663
|
+
if (this.config.options) {
|
|
664
|
+
renderOptions(this.evaluate(this.config.options));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Auto-fetch options via lookup
|
|
668
|
+
if (this.config.lookup) {
|
|
669
|
+
this.callDZQL({
|
|
670
|
+
operation: 'lookup',
|
|
671
|
+
entity: this.config.lookup.entity,
|
|
672
|
+
params: this.config.lookup.params || {},
|
|
673
|
+
resultPath: `selects.${this.id}.options`
|
|
674
|
+
}).then(() => {
|
|
675
|
+
const options = state.get(`selects.${this.id}.options`);
|
|
676
|
+
renderOptions(options);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Two-way binding
|
|
681
|
+
if (this.config.bind) {
|
|
682
|
+
const path = this.config.bind.replace('${state.', '').replace('}', '');
|
|
683
|
+
select.value = state.get(path) || '';
|
|
684
|
+
|
|
685
|
+
select.addEventListener('change', () => {
|
|
686
|
+
state.set(path, select.value);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
this.bind(path, (value) => {
|
|
690
|
+
if (select.value !== value) {
|
|
691
|
+
select.value = value || '';
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Handle change event
|
|
697
|
+
if (this.config.onChange) {
|
|
698
|
+
select.addEventListener('change', this.handleEvent(this.config.onChange));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return select;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* List Component - Renders lists with templates
|
|
707
|
+
*/
|
|
708
|
+
class ListComponent extends Component {
|
|
709
|
+
render() {
|
|
710
|
+
const container = document.createElement(this.config.tag || 'ul');
|
|
711
|
+
this.element = container;
|
|
712
|
+
|
|
713
|
+
const renderList = (items) => {
|
|
714
|
+
container.innerHTML = '';
|
|
715
|
+
this.children.forEach(child => child.destroy());
|
|
716
|
+
this.children = [];
|
|
717
|
+
|
|
718
|
+
if (!items || !Array.isArray(items)) return;
|
|
719
|
+
|
|
720
|
+
items.forEach((item, index) => {
|
|
721
|
+
const itemElement = document.createElement(this.config.itemTag || 'li');
|
|
722
|
+
|
|
723
|
+
// Render item template
|
|
724
|
+
if (this.config.template) {
|
|
725
|
+
// Create a temporary state context for the item
|
|
726
|
+
const itemComponent = renderComponent({
|
|
727
|
+
...this.config.template,
|
|
728
|
+
// Inject item data into template
|
|
729
|
+
context: { item, index }
|
|
730
|
+
}, this.ws);
|
|
731
|
+
|
|
732
|
+
this.children.push(itemComponent);
|
|
733
|
+
itemElement.appendChild(itemComponent.element);
|
|
734
|
+
} else {
|
|
735
|
+
// Simple text rendering
|
|
736
|
+
itemElement.textContent = typeof item === 'object' ? JSON.stringify(item) : item;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
container.appendChild(itemElement);
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// Initial render
|
|
744
|
+
if (this.config.items) {
|
|
745
|
+
renderList(this.evaluate(this.config.items));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Auto-fetch if configured
|
|
749
|
+
if (this.config.fetch) {
|
|
750
|
+
this.callDZQL({
|
|
751
|
+
...this.config.fetch,
|
|
752
|
+
resultPath: `lists.${this.id}`
|
|
753
|
+
}).then(() => {
|
|
754
|
+
const items = state.get(`lists.${this.id}`);
|
|
755
|
+
renderList(items);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Bind to data changes
|
|
760
|
+
if (this.config.itemsPath || this.config.items?.includes('${')) {
|
|
761
|
+
const path = this.config.itemsPath || this.config.items.slice(9, -1);
|
|
762
|
+
this.bind(path, renderList);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return container;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Conditional Component - Renders based on condition
|
|
771
|
+
*/
|
|
772
|
+
class ConditionalComponent extends Component {
|
|
773
|
+
render() {
|
|
774
|
+
const container = document.createElement('div');
|
|
775
|
+
this.element = container;
|
|
776
|
+
container.style.display = 'contents'; // Invisible wrapper
|
|
777
|
+
|
|
778
|
+
const updateContent = () => {
|
|
779
|
+
// Clear previous content
|
|
780
|
+
container.innerHTML = '';
|
|
781
|
+
this.children.forEach(child => child.destroy());
|
|
782
|
+
this.children = [];
|
|
783
|
+
|
|
784
|
+
// Evaluate condition
|
|
785
|
+
const condition = this.evaluate(this.config.condition);
|
|
786
|
+
|
|
787
|
+
// Render appropriate branch
|
|
788
|
+
const branch = condition ? this.config.then : this.config.else;
|
|
789
|
+
if (branch) {
|
|
790
|
+
const child = renderComponent(branch, this.ws);
|
|
791
|
+
if (child) {
|
|
792
|
+
this.children.push(child);
|
|
793
|
+
container.appendChild(child.element);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
updateContent();
|
|
799
|
+
|
|
800
|
+
// Bind to condition changes
|
|
801
|
+
if (typeof this.config.condition === 'string' && this.config.condition.includes('${')) {
|
|
802
|
+
const match = this.config.condition.match(/\${state\.([^}]+)}/);
|
|
803
|
+
if (match) {
|
|
804
|
+
this.bind(match[1], updateContent);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return container;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Register built-in components
|
|
813
|
+
componentRegistry.set('container', ContainerComponent);
|
|
814
|
+
componentRegistry.set('div', ContainerComponent);
|
|
815
|
+
componentRegistry.set('section', ContainerComponent);
|
|
816
|
+
componentRegistry.set('text', TextComponent);
|
|
817
|
+
componentRegistry.set('span', TextComponent);
|
|
818
|
+
componentRegistry.set('p', TextComponent);
|
|
819
|
+
componentRegistry.set('h1', TextComponent);
|
|
820
|
+
componentRegistry.set('h2', TextComponent);
|
|
821
|
+
componentRegistry.set('h3', TextComponent);
|
|
822
|
+
componentRegistry.set('input', InputComponent);
|
|
823
|
+
componentRegistry.set('button', ButtonComponent);
|
|
824
|
+
componentRegistry.set('table', TableComponent);
|
|
825
|
+
componentRegistry.set('form', FormComponent);
|
|
826
|
+
componentRegistry.set('select', SelectComponent);
|
|
827
|
+
componentRegistry.set('list', ListComponent);
|
|
828
|
+
componentRegistry.set('if', ConditionalComponent);
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Main render function
|
|
832
|
+
*/
|
|
833
|
+
function renderComponent(config, ws) {
|
|
834
|
+
if (!config) return null;
|
|
835
|
+
|
|
836
|
+
const ComponentClass = componentRegistry.get(config.type) || ContainerComponent;
|
|
837
|
+
const component = new ComponentClass(config, ws);
|
|
838
|
+
component.render();
|
|
839
|
+
return component;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Mount a UI configuration to a DOM element
|
|
844
|
+
*/
|
|
845
|
+
export function mount(config, element, ws) {
|
|
846
|
+
// Clear existing content
|
|
847
|
+
element.innerHTML = '';
|
|
848
|
+
|
|
849
|
+
// Render root component
|
|
850
|
+
const root = renderComponent(config, ws);
|
|
851
|
+
if (root) {
|
|
852
|
+
element.appendChild(root.element);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
root,
|
|
857
|
+
state,
|
|
858
|
+
destroy: () => root?.destroy()
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Register a custom component
|
|
864
|
+
*/
|
|
865
|
+
export function registerComponent(name, ComponentClass) {
|
|
866
|
+
componentRegistry.set(name, ComponentClass);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Export state manager for external access
|
|
871
|
+
*/
|
|
872
|
+
export { state, Component };
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Example UI configuration
|
|
876
|
+
*/
|
|
877
|
+
export const exampleUI = {
|
|
878
|
+
type: 'container',
|
|
879
|
+
class: 'app',
|
|
880
|
+
children: [
|
|
881
|
+
{
|
|
882
|
+
type: 'h1',
|
|
883
|
+
text: 'DZQL Declarative UI'
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
type: 'section',
|
|
887
|
+
class: 'search-section',
|
|
888
|
+
children: [
|
|
889
|
+
{
|
|
890
|
+
type: 'h2',
|
|
891
|
+
text: 'Search Venues'
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
type: 'form',
|
|
895
|
+
dataPath: 'searchForm',
|
|
896
|
+
fields: [
|
|
897
|
+
{
|
|
898
|
+
name: 'search',
|
|
899
|
+
label: 'Search',
|
|
900
|
+
inputType: 'text',
|
|
901
|
+
bind: '${state.searchQuery}',
|
|
902
|
+
attributes: { placeholder: 'Enter search term...' }
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
name: 'city',
|
|
906
|
+
label: 'City',
|
|
907
|
+
inputType: 'text',
|
|
908
|
+
bind: '${state.searchCity}'
|
|
909
|
+
}
|
|
910
|
+
],
|
|
911
|
+
submitButton: {
|
|
912
|
+
text: 'Search',
|
|
913
|
+
onClick: {
|
|
914
|
+
actions: [
|
|
915
|
+
{
|
|
916
|
+
type: 'call',
|
|
917
|
+
operation: 'search',
|
|
918
|
+
entity: 'venues',
|
|
919
|
+
params: {
|
|
920
|
+
filters: {
|
|
921
|
+
_search: '${state.searchQuery}',
|
|
922
|
+
city: '${state.searchCity}'
|
|
923
|
+
},
|
|
924
|
+
limit: 10
|
|
925
|
+
},
|
|
926
|
+
resultPath: 'searchResults'
|
|
927
|
+
}
|
|
928
|
+
]
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
type: 'if',
|
|
934
|
+
condition: '${state.searchResults}',
|
|
935
|
+
then: {
|
|
936
|
+
type: 'div',
|
|
937
|
+
children: [
|
|
938
|
+
{
|
|
939
|
+
type: 'p',
|
|
940
|
+
text: 'Found ${state.searchResults.total} results'
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
type: 'table',
|
|
944
|
+
data: '${state.searchResults.data}',
|
|
945
|
+
columns: [
|
|
946
|
+
{ field: 'id', label: 'ID' },
|
|
947
|
+
{ field: 'name', label: 'Name' },
|
|
948
|
+
{ field: 'city', label: 'City' },
|
|
949
|
+
{ field: 'capacity', label: 'Capacity' }
|
|
950
|
+
],
|
|
951
|
+
onRowClick: {
|
|
952
|
+
type: 'setState',
|
|
953
|
+
path: 'selectedVenue',
|
|
954
|
+
value: '${row}'
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
]
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
]
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
type: 'if',
|
|
964
|
+
condition: '${state.selectedVenue}',
|
|
965
|
+
then: {
|
|
966
|
+
type: 'section',
|
|
967
|
+
class: 'detail-section',
|
|
968
|
+
children: [
|
|
969
|
+
{
|
|
970
|
+
type: 'h2',
|
|
971
|
+
text: 'Selected Venue: ${state.selectedVenue.name}'
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
type: 'button',
|
|
975
|
+
text: 'Edit',
|
|
976
|
+
onClick: {
|
|
977
|
+
actions: [
|
|
978
|
+
{
|
|
979
|
+
type: 'setState',
|
|
980
|
+
path: 'editMode',
|
|
981
|
+
value: true
|
|
982
|
+
}
|
|
983
|
+
]
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
]
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
]
|
|
990
|
+
};
|