zauberflote 1.0.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.
- package/.env +1 -0
- package/.env.example +4 -0
- package/README.md +23 -0
- package/package.json +14 -0
- package/publish_local.sh +21 -0
- package/publish_public.sh +30 -0
- package/src/ui.js +1485 -0
package/src/ui.js
ADDED
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
const ui = (() => {
|
|
2
|
+
const pendingApps = [];
|
|
3
|
+
let autoMountScheduled = false;
|
|
4
|
+
|
|
5
|
+
function app(title) {
|
|
6
|
+
const builder = new AppBuilder(title);
|
|
7
|
+
pendingApps.push(builder);
|
|
8
|
+
scheduleAutoMount();
|
|
9
|
+
return builder;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class AppBuilder {
|
|
13
|
+
constructor(title) {
|
|
14
|
+
this.title = title || 'App';
|
|
15
|
+
this.blurbText = '';
|
|
16
|
+
this.sections = [];
|
|
17
|
+
this.groups = [];
|
|
18
|
+
this.store = {};
|
|
19
|
+
this.refreshAll = null;
|
|
20
|
+
this.mounted = false;
|
|
21
|
+
}
|
|
22
|
+
blurb(text) {
|
|
23
|
+
this.blurbText = text || '';
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
group(title) {
|
|
27
|
+
const group = new GroupBuilder(this, title);
|
|
28
|
+
this.groups.push(group);
|
|
29
|
+
return group;
|
|
30
|
+
}
|
|
31
|
+
section(title) {
|
|
32
|
+
const section = new SectionBuilder(this, title, null);
|
|
33
|
+
this.sections.push(section);
|
|
34
|
+
return section;
|
|
35
|
+
}
|
|
36
|
+
mount(targetId) {
|
|
37
|
+
if (this.mounted) return;
|
|
38
|
+
renderApp(this, targetId || 'app');
|
|
39
|
+
this.mounted = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class GroupBuilder {
|
|
44
|
+
constructor(app, title) {
|
|
45
|
+
this.app = app;
|
|
46
|
+
this.title = title || '';
|
|
47
|
+
this.blurbText = '';
|
|
48
|
+
this.sections = [];
|
|
49
|
+
this.layoutConfig = null;
|
|
50
|
+
}
|
|
51
|
+
blurb(text) {
|
|
52
|
+
this.blurbText = text || '';
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
grid(cols) {
|
|
56
|
+
// Signature: layout container for side-by-side sections.
|
|
57
|
+
this.layoutConfig = { type: 'grid', cols: cols || 2 };
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
sticky(options) {
|
|
61
|
+
// Signature: sticky container (sidebars).
|
|
62
|
+
this.layoutConfig = Object.assign({}, this.layoutConfig, { sticky: true, stickyOptions: options || {} });
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
section(title) {
|
|
66
|
+
const section = new SectionBuilder(this.app, title, this);
|
|
67
|
+
this.sections.push(section);
|
|
68
|
+
return section;
|
|
69
|
+
}
|
|
70
|
+
end() {
|
|
71
|
+
return this.app;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mount(builder, targetId) {
|
|
76
|
+
if (!builder || typeof builder.mount !== 'function') return;
|
|
77
|
+
builder.mount(targetId || 'app');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class SectionBuilder {
|
|
81
|
+
constructor(app, title, parent) {
|
|
82
|
+
this.app = app;
|
|
83
|
+
this.parent = parent || null;
|
|
84
|
+
this.title = title || 'Section';
|
|
85
|
+
this.id = slugify(this.title);
|
|
86
|
+
this.readConfig = null;
|
|
87
|
+
this.listTemplate = '';
|
|
88
|
+
this.actions = [];
|
|
89
|
+
this.rowFields = [];
|
|
90
|
+
this.rowActions = [];
|
|
91
|
+
this.appStore = app.store;
|
|
92
|
+
this.storeViewConfig = null;
|
|
93
|
+
this.sectionFields = [];
|
|
94
|
+
this.queryMap = null;
|
|
95
|
+
this.metaConfig = null;
|
|
96
|
+
this.storeMap = {};
|
|
97
|
+
this.wsConfig = null;
|
|
98
|
+
this.linksList = [];
|
|
99
|
+
this.kpiConfig = null;
|
|
100
|
+
this.jsonConfig = null;
|
|
101
|
+
this.textConfig = null;
|
|
102
|
+
this.markdownConfig = null;
|
|
103
|
+
this.customRenderer = null;
|
|
104
|
+
this.autoConfig = null;
|
|
105
|
+
this.suppressOutput = false;
|
|
106
|
+
this.noAutoRefresh = false;
|
|
107
|
+
this.layoutConfig = null;
|
|
108
|
+
}
|
|
109
|
+
id(value) {
|
|
110
|
+
this.id = value;
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
read(path) {
|
|
114
|
+
if (typeof path === 'string') {
|
|
115
|
+
this.readConfig = { method: 'GET', path };
|
|
116
|
+
} else {
|
|
117
|
+
this.readConfig = path;
|
|
118
|
+
}
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
query(map) {
|
|
122
|
+
// Signature: chapter-4/4-4-pagination, example-02-http-basics.
|
|
123
|
+
this.queryMap = map || {};
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
list(template) {
|
|
127
|
+
this.listTemplate = template || '';
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
listFrom(path) {
|
|
131
|
+
// Signature: chapter-4/4-4-pagination (items list from query results).
|
|
132
|
+
this.listFromPath = path;
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
meta(template, path) {
|
|
136
|
+
// Signature: chapter-4/4-4-pagination.
|
|
137
|
+
this.metaConfig = { template, path };
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
fields(config) {
|
|
141
|
+
this.sectionFields = normalizeFields(config);
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
links(list) {
|
|
145
|
+
// Signature: chapter-5/5-7-exports download links.
|
|
146
|
+
this.linksList = list || [];
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
store(map) {
|
|
150
|
+
// Signature: chapter-4/4-4-pagination (store query results).
|
|
151
|
+
this.storeMap = Object.assign({}, this.storeMap, map);
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
154
|
+
storeView(key, template) {
|
|
155
|
+
// Signature: chapter-3 JWT/CSRF token display.
|
|
156
|
+
this.storeViewConfig = { key, template: template || `{{${key}}}` };
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
kpis(items, path) {
|
|
160
|
+
// Signature: chapter-4/4-6-caching, chapter-6/6-1-sql-basics.
|
|
161
|
+
this.kpiConfig = { items: items || [], path };
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
jsonView(path) {
|
|
165
|
+
// Signature: chapter-4/4-7-observability.
|
|
166
|
+
this.jsonConfig = { path };
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
textBlock(text, path) {
|
|
170
|
+
// Signature: chapter-0 UI language docs.
|
|
171
|
+
this.textConfig = { text: text || '', path };
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
markdown(text, path) {
|
|
175
|
+
// Signature: chapter-4/4-1-validation, chapter-0 UI language docs.
|
|
176
|
+
this.markdownConfig = { text: text || '', path };
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
customView(renderer) {
|
|
180
|
+
// Signature: custom render escape hatch (manual UI extensions).
|
|
181
|
+
this.customRenderer = renderer;
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
auto(path) {
|
|
185
|
+
// Signature: chapter-0 UI language docs (opinionated view).
|
|
186
|
+
this.autoConfig = { path };
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
noOutput() {
|
|
190
|
+
// Signature: chapter-4/4-7-observability (hide raw action output).
|
|
191
|
+
this.suppressOutput = true;
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
noRefresh() {
|
|
195
|
+
// Signature: opt-out of default refresh when section has read().
|
|
196
|
+
this.noAutoRefresh = true;
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
grid(cols, gap) {
|
|
200
|
+
// Signature: layout helper for side-by-side sections.
|
|
201
|
+
this.layoutConfig = { type: 'grid', cols: cols || 2, gap: gap || 6 };
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
sticky(options) {
|
|
205
|
+
// Signature: layout helper for sticky sections.
|
|
206
|
+
this.layoutConfig = Object.assign({}, this.layoutConfig, { sticky: true, stickyOptions: options || {} });
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
websocket(config) {
|
|
210
|
+
// Signature: chapter-5 websocket demos (server + client).
|
|
211
|
+
this.wsConfig = config || {};
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
action(label) {
|
|
215
|
+
const action = new ActionBuilder(this, label, false);
|
|
216
|
+
this.actions.push(action);
|
|
217
|
+
return action;
|
|
218
|
+
}
|
|
219
|
+
rowField(key, config) {
|
|
220
|
+
// Signature: chapter-1/6 envelope row actions, chapter-6 workflow actions.
|
|
221
|
+
this.rowFields.push(normalizeField(key, config));
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
rowAction(label) {
|
|
225
|
+
// Signature: chapter-1/6 allocate/spend, chapter-6 workflow buttons.
|
|
226
|
+
const action = new ActionBuilder(this, label, true);
|
|
227
|
+
this.rowActions.push(action);
|
|
228
|
+
return action;
|
|
229
|
+
}
|
|
230
|
+
end() {
|
|
231
|
+
return this.parent || this.app;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
class ActionBuilder {
|
|
236
|
+
constructor(section, label, isRow) {
|
|
237
|
+
this.section = section;
|
|
238
|
+
this.label = label || 'Action';
|
|
239
|
+
this.isRow = isRow;
|
|
240
|
+
this.method = 'POST';
|
|
241
|
+
this.path = '';
|
|
242
|
+
this.fieldList = [];
|
|
243
|
+
this.withCreds = false;
|
|
244
|
+
this.headerMap = {};
|
|
245
|
+
this.storeMap = {};
|
|
246
|
+
this.authMode = null;
|
|
247
|
+
this.localOnly = false;
|
|
248
|
+
this.adjustments = [];
|
|
249
|
+
this.setMap = {};
|
|
250
|
+
this.refreshAllFlag = false;
|
|
251
|
+
this.bodyMap = {};
|
|
252
|
+
this.headerStoreMap = {};
|
|
253
|
+
this.customHandler = null;
|
|
254
|
+
}
|
|
255
|
+
get(path) {
|
|
256
|
+
this.method = 'GET';
|
|
257
|
+
this.path = path;
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
post(path) {
|
|
261
|
+
this.method = 'POST';
|
|
262
|
+
this.path = path;
|
|
263
|
+
return this;
|
|
264
|
+
}
|
|
265
|
+
put(path) {
|
|
266
|
+
// Signature: example-02-http-basics update by name.
|
|
267
|
+
this.method = 'PUT';
|
|
268
|
+
this.path = path;
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
del(path) {
|
|
272
|
+
// Signature: example-02-http-basics delete by name.
|
|
273
|
+
this.method = 'DELETE';
|
|
274
|
+
this.path = path;
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
upload(path) {
|
|
278
|
+
// Signature: chapter-4/4-8-uploads.
|
|
279
|
+
this.method = 'UPLOAD';
|
|
280
|
+
this.path = path;
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
local() {
|
|
284
|
+
// Signature: chapter-4/4-4-pagination prev/next controls.
|
|
285
|
+
this.localOnly = true;
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
refreshAll() {
|
|
289
|
+
// Signature: chapter-4/4-4-pagination query triggers results refresh.
|
|
290
|
+
this.refreshAllFlag = true;
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
field(key, value, type) {
|
|
294
|
+
this.fieldList.push(normalizeField(key, { value, type }));
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
fields(config) {
|
|
298
|
+
Object.entries(config || {}).forEach(([key, value]) => {
|
|
299
|
+
this.fieldList.push(normalizeField(key, value));
|
|
300
|
+
});
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
body(map) {
|
|
304
|
+
// Signature: chapter-4/4-3-transactions transfer payload.
|
|
305
|
+
this.bodyMap = Object.assign({}, this.bodyMap, map);
|
|
306
|
+
return this;
|
|
307
|
+
}
|
|
308
|
+
set(map) {
|
|
309
|
+
this.setMap = Object.assign({}, this.setMap, map);
|
|
310
|
+
return this;
|
|
311
|
+
}
|
|
312
|
+
adjust(key, delta, minValue) {
|
|
313
|
+
this.adjustments.push({ key, delta, minValue });
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
creds() {
|
|
317
|
+
// Signature: chapter-3 cookie/jwt/auth flows.
|
|
318
|
+
this.withCreds = true;
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
headers(map) {
|
|
322
|
+
// Signature: chapter-3 CSRF, chapter-4/4-2-idempotency.
|
|
323
|
+
this.headerMap = Object.assign({}, this.headerMap, map);
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
store(map) {
|
|
327
|
+
// Signature: chapter-3 JWT/CSRF token capture.
|
|
328
|
+
this.storeMap = Object.assign({}, this.storeMap, map);
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
headersFrom(map) {
|
|
332
|
+
// Signature: chapter-4/4-7-observability.
|
|
333
|
+
this.headerStoreMap = Object.assign({}, this.headerStoreMap, map);
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
custom(handler) {
|
|
337
|
+
// Signature: chapter-5/5-4-webhooks signing flow.
|
|
338
|
+
this.customHandler = handler;
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
basic(userKey, passKey) {
|
|
342
|
+
// Signature: chapter-3 basic auth.
|
|
343
|
+
this.authMode = { type: 'basic', userKey, passKey };
|
|
344
|
+
return this;
|
|
345
|
+
}
|
|
346
|
+
bearer(tokenKey) {
|
|
347
|
+
// Signature: chapter-3 API key + JWT.
|
|
348
|
+
this.authMode = { type: 'bearer', tokenKey };
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
end() {
|
|
352
|
+
return this.section;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderApp(app, targetId) {
|
|
357
|
+
const root = ensureRoot(targetId);
|
|
358
|
+
root.innerHTML = '';
|
|
359
|
+
|
|
360
|
+
const container = document.createElement('div');
|
|
361
|
+
container.className = 'mx-auto max-w-4xl px-6 py-10 pb-24 space-y-6';
|
|
362
|
+
const header = document.createElement('header');
|
|
363
|
+
header.className = 'space-y-2';
|
|
364
|
+
const title = document.createElement('h1');
|
|
365
|
+
title.className = 'text-2xl font-semibold';
|
|
366
|
+
title.textContent = app.title;
|
|
367
|
+
header.appendChild(title);
|
|
368
|
+
if (app.blurbText) {
|
|
369
|
+
const blurb = document.createElement('p');
|
|
370
|
+
blurb.className = 'text-slate-600';
|
|
371
|
+
blurb.textContent = app.blurbText;
|
|
372
|
+
header.appendChild(blurb);
|
|
373
|
+
}
|
|
374
|
+
container.appendChild(header);
|
|
375
|
+
|
|
376
|
+
const sectionEls = [];
|
|
377
|
+
|
|
378
|
+
const renderSectionList = (sections, target, layout) => {
|
|
379
|
+
let wrap = target;
|
|
380
|
+
if (layout && layout.type === 'grid') {
|
|
381
|
+
wrap = document.createElement('div');
|
|
382
|
+
const cols = Math.max(1, layout.cols || 2);
|
|
383
|
+
wrap.className = 'grid';
|
|
384
|
+
wrap.style.gap = `${layout.gap || 6}px`;
|
|
385
|
+
wrap.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
|
386
|
+
target.appendChild(wrap);
|
|
387
|
+
}
|
|
388
|
+
sections.forEach((section) => {
|
|
389
|
+
const el = renderSection(section);
|
|
390
|
+
sectionEls.push(el);
|
|
391
|
+
wrap.appendChild(el.root);
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
if (app.groups.length > 0) {
|
|
396
|
+
app.groups.forEach((group) => {
|
|
397
|
+
const groupWrap = document.createElement('div');
|
|
398
|
+
groupWrap.className = 'space-y-4';
|
|
399
|
+
if (group.title) {
|
|
400
|
+
const gTitle = document.createElement('h2');
|
|
401
|
+
gTitle.className = 'text-lg font-semibold text-slate-800';
|
|
402
|
+
gTitle.textContent = group.title;
|
|
403
|
+
groupWrap.appendChild(gTitle);
|
|
404
|
+
}
|
|
405
|
+
if (group.blurbText) {
|
|
406
|
+
const gBlurb = document.createElement('p');
|
|
407
|
+
gBlurb.className = 'text-sm text-slate-600';
|
|
408
|
+
gBlurb.textContent = group.blurbText;
|
|
409
|
+
groupWrap.appendChild(gBlurb);
|
|
410
|
+
}
|
|
411
|
+
if (group.layoutConfig && group.layoutConfig.sticky) {
|
|
412
|
+
groupWrap.style.position = 'sticky';
|
|
413
|
+
const opts = group.layoutConfig.stickyOptions || {};
|
|
414
|
+
groupWrap.style.top = opts.top !== undefined ? (typeof opts.top === 'number' ? `${opts.top}px` : opts.top) : '16px';
|
|
415
|
+
if (opts.maxHeight !== undefined) groupWrap.style.maxHeight = typeof opts.maxHeight === 'number' ? `${opts.maxHeight}px` : opts.maxHeight;
|
|
416
|
+
if (opts.width !== undefined) groupWrap.style.width = typeof opts.width === 'number' ? `${opts.width}px` : opts.width;
|
|
417
|
+
}
|
|
418
|
+
renderSectionList(group.sections, groupWrap, group.layoutConfig);
|
|
419
|
+
container.appendChild(groupWrap);
|
|
420
|
+
});
|
|
421
|
+
} else {
|
|
422
|
+
renderSectionList(app.sections, container, null);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
root.appendChild(container);
|
|
426
|
+
root.appendChild(renderFooter());
|
|
427
|
+
|
|
428
|
+
const refreshAll = async () => {
|
|
429
|
+
for (const el of sectionEls) {
|
|
430
|
+
await el.refresh();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
app.refreshAll = refreshAll;
|
|
434
|
+
|
|
435
|
+
refreshAll();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function renderSection(section) {
|
|
439
|
+
if (section.wsConfig) {
|
|
440
|
+
return renderWebSocketSection(section);
|
|
441
|
+
}
|
|
442
|
+
const root = document.createElement('section');
|
|
443
|
+
const classes = ['rounded-xl', 'border', 'border-slate-200', 'bg-white', 'p-6', 'shadow-sm', 'space-y-4'];
|
|
444
|
+
if (section.layoutConfig && section.layoutConfig.sticky) {
|
|
445
|
+
classes.push('sticky');
|
|
446
|
+
}
|
|
447
|
+
root.className = classes.join(' ');
|
|
448
|
+
if (section.layoutConfig && section.layoutConfig.stickyOptions) {
|
|
449
|
+
const opts = section.layoutConfig.stickyOptions;
|
|
450
|
+
if (opts.top !== undefined) root.style.top = typeof opts.top === 'number' ? `${opts.top}px` : opts.top;
|
|
451
|
+
if (opts.maxHeight !== undefined) root.style.maxHeight = typeof opts.maxHeight === 'number' ? `${opts.maxHeight}px` : opts.maxHeight;
|
|
452
|
+
if (opts.height !== undefined) root.style.height = typeof opts.height === 'number' ? `${opts.height}px` : opts.height;
|
|
453
|
+
if (opts.width !== undefined) root.style.width = typeof opts.width === 'number' ? `${opts.width}px` : opts.width;
|
|
454
|
+
root.style.position = 'sticky';
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const title = document.createElement('h2');
|
|
458
|
+
title.className = 'text-lg font-semibold';
|
|
459
|
+
title.textContent = section.title;
|
|
460
|
+
root.appendChild(title);
|
|
461
|
+
|
|
462
|
+
const actionWrap = document.createElement('div');
|
|
463
|
+
actionWrap.className = 'space-y-3';
|
|
464
|
+
|
|
465
|
+
let output = null;
|
|
466
|
+
if (!section.suppressOutput) {
|
|
467
|
+
output = document.createElement('pre');
|
|
468
|
+
output.className = 'rounded bg-slate-100 p-3 text-xs text-slate-700';
|
|
469
|
+
output.textContent = '-';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (section.linksList && section.linksList.length > 0) {
|
|
473
|
+
const linkRow = document.createElement('div');
|
|
474
|
+
linkRow.className = 'flex flex-wrap gap-2';
|
|
475
|
+
section.linksList.forEach((link) => {
|
|
476
|
+
const a = document.createElement('a');
|
|
477
|
+
a.className = 'rounded border border-slate-300 px-4 py-2 text-sm';
|
|
478
|
+
a.href = link.href;
|
|
479
|
+
a.textContent = link.label;
|
|
480
|
+
if (link.target) a.target = link.target;
|
|
481
|
+
linkRow.appendChild(a);
|
|
482
|
+
});
|
|
483
|
+
root.appendChild(linkRow);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const sectionInputs = {};
|
|
487
|
+
const selectBindings = [];
|
|
488
|
+
if (section.sectionFields && section.sectionFields.length > 0) {
|
|
489
|
+
const fieldRow = document.createElement('div');
|
|
490
|
+
fieldRow.className = 'grid gap-2 sm:grid-cols-4';
|
|
491
|
+
section.sectionFields.forEach((field) => {
|
|
492
|
+
const input = renderField(field, section.appStore);
|
|
493
|
+
sectionInputs[field.key] = input;
|
|
494
|
+
if (field.optionsFrom) {
|
|
495
|
+
selectBindings.push({ field, input });
|
|
496
|
+
}
|
|
497
|
+
fieldRow.appendChild(input);
|
|
498
|
+
});
|
|
499
|
+
root.appendChild(fieldRow);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (section.actions.length > 0) {
|
|
503
|
+
section.actions.forEach((action) => {
|
|
504
|
+
actionWrap.appendChild(renderActionForm(action, output, section.appStore, sectionInputs, section));
|
|
505
|
+
});
|
|
506
|
+
root.appendChild(actionWrap);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let metaEl = null;
|
|
510
|
+
if (section.metaConfig) {
|
|
511
|
+
metaEl = document.createElement('div');
|
|
512
|
+
metaEl.className = 'text-sm text-slate-500';
|
|
513
|
+
metaEl.textContent = '-';
|
|
514
|
+
root.appendChild(metaEl);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const listWrap = document.createElement('div');
|
|
518
|
+
listWrap.className = 'space-y-2';
|
|
519
|
+
root.appendChild(listWrap);
|
|
520
|
+
|
|
521
|
+
let kpiWrap = null;
|
|
522
|
+
if (section.kpiConfig) {
|
|
523
|
+
kpiWrap = document.createElement('div');
|
|
524
|
+
kpiWrap.className = 'grid gap-3 sm:grid-cols-3';
|
|
525
|
+
root.appendChild(kpiWrap);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let jsonWrap = null;
|
|
529
|
+
if (section.jsonConfig) {
|
|
530
|
+
jsonWrap = document.createElement('pre');
|
|
531
|
+
jsonWrap.className = 'rounded bg-slate-100 p-3 text-xs text-slate-700 overflow-auto';
|
|
532
|
+
root.appendChild(jsonWrap);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let textWrap = null;
|
|
536
|
+
if (section.textConfig) {
|
|
537
|
+
textWrap = document.createElement('div');
|
|
538
|
+
textWrap.className = 'text-sm text-slate-600';
|
|
539
|
+
root.appendChild(textWrap);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let markdownWrap = null;
|
|
543
|
+
if (section.markdownConfig) {
|
|
544
|
+
markdownWrap = document.createElement('div');
|
|
545
|
+
markdownWrap.className = 'prose prose-slate max-w-none text-sm';
|
|
546
|
+
root.appendChild(markdownWrap);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let customWrap = null;
|
|
550
|
+
if (section.customRenderer) {
|
|
551
|
+
customWrap = document.createElement('div');
|
|
552
|
+
root.appendChild(customWrap);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let autoWrap = null;
|
|
556
|
+
if (section.autoConfig) {
|
|
557
|
+
autoWrap = document.createElement('div');
|
|
558
|
+
root.appendChild(autoWrap);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (section.actions.length > 0 && output) {
|
|
562
|
+
root.appendChild(output);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const renderExtras = () => {
|
|
566
|
+
const data = section.lastData ? section.lastData.data : null;
|
|
567
|
+
if (autoWrap && section.autoConfig) {
|
|
568
|
+
const payload = resolveSectionPayload(section.autoConfig.path, section.appStore, data);
|
|
569
|
+
renderAuto(autoWrap, payload);
|
|
570
|
+
}
|
|
571
|
+
if (kpiWrap && section.kpiConfig) {
|
|
572
|
+
renderKpis(kpiWrap, section.kpiConfig, section.appStore, data);
|
|
573
|
+
}
|
|
574
|
+
if (jsonWrap && section.jsonConfig) {
|
|
575
|
+
const payload = resolveSectionPayload(section.jsonConfig.path, section.appStore, data);
|
|
576
|
+
jsonWrap.innerHTML = formatJson(payload);
|
|
577
|
+
}
|
|
578
|
+
if (textWrap && section.textConfig) {
|
|
579
|
+
const text = resolveText(section.textConfig.text, section.textConfig.path, section.appStore, data);
|
|
580
|
+
textWrap.textContent = text;
|
|
581
|
+
}
|
|
582
|
+
if (markdownWrap && section.markdownConfig) {
|
|
583
|
+
const text = resolveText(section.markdownConfig.text, section.markdownConfig.path, section.appStore, data);
|
|
584
|
+
markdownWrap.innerHTML = markdownToHtml(text);
|
|
585
|
+
}
|
|
586
|
+
if (customWrap && section.customRenderer) {
|
|
587
|
+
const result = section.customRenderer({ data, store: section.appStore }) || '';
|
|
588
|
+
if (typeof result === 'string') {
|
|
589
|
+
customWrap.innerHTML = result;
|
|
590
|
+
} else if (result instanceof HTMLElement) {
|
|
591
|
+
customWrap.innerHTML = '';
|
|
592
|
+
customWrap.appendChild(result);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const refresh = async () => {
|
|
598
|
+
updateSelectBindings(selectBindings, section.appStore);
|
|
599
|
+
if (section.readConfig) {
|
|
600
|
+
const payload = buildQueryPayload(section, sectionInputs, section.appStore);
|
|
601
|
+
const path = buildQueryPath(section.readConfig.path, payload);
|
|
602
|
+
const data = await request(section.readConfig, null, false, payload, section.appStore, path);
|
|
603
|
+
section.lastData = data.json || null;
|
|
604
|
+
applyStore(section.appStore, section.storeMap, data.json);
|
|
605
|
+
const hasExplicitView = section.listFromPath || section.listTemplate || section.kpiConfig || section.jsonConfig || section.textConfig || section.markdownConfig || section.customRenderer;
|
|
606
|
+
if (!hasExplicitView && !section.autoConfig) {
|
|
607
|
+
section.autoConfig = { path: section.listFromPath || 'data' };
|
|
608
|
+
}
|
|
609
|
+
if (section.listFromPath) {
|
|
610
|
+
renderList(listWrap, section, { data: getPath(section.appStore, section.listFromPath) });
|
|
611
|
+
} else if (section.listTemplate) {
|
|
612
|
+
renderList(listWrap, section, data);
|
|
613
|
+
}
|
|
614
|
+
if (metaEl && section.metaConfig) {
|
|
615
|
+
let metaSource = section.metaConfig.path ? getPath(section.appStore, section.metaConfig.path) : (data.json ? data.json.data : null);
|
|
616
|
+
if (Array.isArray(metaSource)) {
|
|
617
|
+
metaSource = { count: metaSource.length };
|
|
618
|
+
}
|
|
619
|
+
metaEl.textContent = renderTemplate(section.metaConfig.template, metaSource || {}, section.appStore);
|
|
620
|
+
}
|
|
621
|
+
renderExtras();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (section.listFromPath) {
|
|
625
|
+
renderList(listWrap, section, { data: getPath(section.appStore, section.listFromPath) });
|
|
626
|
+
if (metaEl && section.metaConfig) {
|
|
627
|
+
let metaSource = section.metaConfig.path ? getPath(section.appStore, section.metaConfig.path) : getPath(section.appStore, section.listFromPath);
|
|
628
|
+
if (Array.isArray(metaSource)) {
|
|
629
|
+
metaSource = { count: metaSource.length };
|
|
630
|
+
}
|
|
631
|
+
metaEl.textContent = renderTemplate(section.metaConfig.template, metaSource || {}, section.appStore);
|
|
632
|
+
}
|
|
633
|
+
renderExtras();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (section.storeViewConfig) {
|
|
637
|
+
const value = section.appStore[section.storeViewConfig.key];
|
|
638
|
+
const prevTemplate = section.listTemplate;
|
|
639
|
+
if (section.storeViewConfig.template) {
|
|
640
|
+
section.listTemplate = section.storeViewConfig.template;
|
|
641
|
+
}
|
|
642
|
+
renderList(listWrap, section, { data: value ? [{ [section.storeViewConfig.key]: value }] : [] });
|
|
643
|
+
section.listTemplate = prevTemplate;
|
|
644
|
+
renderExtras();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
renderExtras();
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
section.refresh = refresh;
|
|
651
|
+
|
|
652
|
+
return { root, refresh };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function renderWebSocketSection(section) {
|
|
656
|
+
const root = document.createElement('section');
|
|
657
|
+
root.className = 'rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-4';
|
|
658
|
+
|
|
659
|
+
const title = document.createElement('h2');
|
|
660
|
+
title.className = 'text-lg font-semibold';
|
|
661
|
+
title.textContent = section.title;
|
|
662
|
+
root.appendChild(title);
|
|
663
|
+
|
|
664
|
+
const wsUrl = section.wsConfig.url || `ws://${location.host}/ws`;
|
|
665
|
+
const clients = section.wsConfig.clients || [];
|
|
666
|
+
const historyUrl = section.wsConfig.history;
|
|
667
|
+
|
|
668
|
+
const outputs = [];
|
|
669
|
+
const sockets = [];
|
|
670
|
+
|
|
671
|
+
if (clients.length > 0) {
|
|
672
|
+
const grid = document.createElement('div');
|
|
673
|
+
grid.className = 'grid gap-4 lg:grid-cols-2';
|
|
674
|
+
clients.forEach((client) => {
|
|
675
|
+
const card = document.createElement('div');
|
|
676
|
+
card.className = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
|
677
|
+
const heading = document.createElement('h3');
|
|
678
|
+
heading.className = 'text-sm font-semibold';
|
|
679
|
+
heading.textContent = client.label || 'Client';
|
|
680
|
+
card.appendChild(heading);
|
|
681
|
+
|
|
682
|
+
const row = document.createElement('div');
|
|
683
|
+
row.className = 'mt-3 flex flex-wrap gap-2';
|
|
684
|
+
const nameInput = document.createElement('input');
|
|
685
|
+
nameInput.className = 'w-28 rounded border border-slate-300 px-3 py-2';
|
|
686
|
+
nameInput.value = client.name || '';
|
|
687
|
+
const msgInput = document.createElement('input');
|
|
688
|
+
msgInput.className = 'flex-1 rounded border border-slate-300 px-3 py-2';
|
|
689
|
+
msgInput.value = client.message || '';
|
|
690
|
+
const sendBtn = document.createElement('button');
|
|
691
|
+
sendBtn.className = 'rounded bg-slate-900 px-4 py-2 text-white';
|
|
692
|
+
sendBtn.textContent = 'Send';
|
|
693
|
+
row.appendChild(nameInput);
|
|
694
|
+
row.appendChild(msgInput);
|
|
695
|
+
row.appendChild(sendBtn);
|
|
696
|
+
card.appendChild(row);
|
|
697
|
+
|
|
698
|
+
const out = document.createElement('pre');
|
|
699
|
+
out.className = 'mt-3 rounded bg-slate-100 p-3 text-xs text-slate-700';
|
|
700
|
+
out.textContent = '-';
|
|
701
|
+
card.appendChild(out);
|
|
702
|
+
outputs.push(out);
|
|
703
|
+
|
|
704
|
+
const ws = new WebSocket(wsUrl);
|
|
705
|
+
ws.onopen = () => setFooter({ ok: true, status: 200, raw: `WebSocket connected (${client.label || 'client'})` });
|
|
706
|
+
ws.onmessage = (event) => {
|
|
707
|
+
out.textContent = event.data;
|
|
708
|
+
};
|
|
709
|
+
ws.onclose = () => setFooter({ ok: false, status: 500, error: { message: `WebSocket closed (${client.label || 'client'})` } });
|
|
710
|
+
ws.onerror = () => setFooter({ ok: false, status: 500, error: { message: `WebSocket error (${client.label || 'client'})` } });
|
|
711
|
+
sockets.push(ws);
|
|
712
|
+
|
|
713
|
+
sendBtn.addEventListener('click', () => {
|
|
714
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
715
|
+
setFooter({ ok: false, status: 500, error: { message: `${client.label || 'Client'} not connected.` } });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const name = nameInput.value || client.label || 'Client';
|
|
719
|
+
ws.send(`${name}: ${msgInput.value}`);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
grid.appendChild(card);
|
|
723
|
+
});
|
|
724
|
+
root.appendChild(grid);
|
|
725
|
+
} else {
|
|
726
|
+
const row = document.createElement('div');
|
|
727
|
+
row.className = 'flex flex-wrap gap-2';
|
|
728
|
+
const msgInput = document.createElement('input');
|
|
729
|
+
msgInput.className = 'flex-1 rounded border border-slate-300 px-3 py-2';
|
|
730
|
+
msgInput.value = section.wsConfig.message || 'hello';
|
|
731
|
+
const connectBtn = document.createElement('button');
|
|
732
|
+
connectBtn.className = 'rounded border border-slate-300 px-4 py-2';
|
|
733
|
+
connectBtn.textContent = 'Connect';
|
|
734
|
+
const sendBtn = document.createElement('button');
|
|
735
|
+
sendBtn.className = 'rounded bg-slate-900 px-4 py-2 text-white';
|
|
736
|
+
sendBtn.textContent = 'Send';
|
|
737
|
+
row.appendChild(msgInput);
|
|
738
|
+
row.appendChild(connectBtn);
|
|
739
|
+
row.appendChild(sendBtn);
|
|
740
|
+
root.appendChild(row);
|
|
741
|
+
|
|
742
|
+
const out = document.createElement('pre');
|
|
743
|
+
out.className = 'mt-3 rounded bg-slate-100 p-3 text-xs text-slate-700';
|
|
744
|
+
out.textContent = '-';
|
|
745
|
+
root.appendChild(out);
|
|
746
|
+
|
|
747
|
+
const log = document.createElement('ul');
|
|
748
|
+
log.className = 'mt-3 space-y-2 text-sm';
|
|
749
|
+
root.appendChild(log);
|
|
750
|
+
|
|
751
|
+
let ws = null;
|
|
752
|
+
const connect = () => {
|
|
753
|
+
ws = new WebSocket(wsUrl);
|
|
754
|
+
ws.onopen = () => setFooter({ ok: true, status: 200, raw: 'WebSocket connected.' });
|
|
755
|
+
ws.onmessage = (event) => {
|
|
756
|
+
out.textContent = event.data;
|
|
757
|
+
const li = document.createElement('li');
|
|
758
|
+
li.className = 'rounded border border-slate-200 px-3 py-2';
|
|
759
|
+
li.textContent = `${new Date().toLocaleTimeString()} ${event.data}`;
|
|
760
|
+
log.prepend(li);
|
|
761
|
+
};
|
|
762
|
+
ws.onclose = () => setFooter({ ok: false, status: 500, error: { message: 'WebSocket closed.' } });
|
|
763
|
+
ws.onerror = () => setFooter({ ok: false, status: 500, error: { message: 'WebSocket error.' } });
|
|
764
|
+
};
|
|
765
|
+
connectBtn.addEventListener('click', connect);
|
|
766
|
+
sendBtn.addEventListener('click', () => {
|
|
767
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
768
|
+
setFooter({ ok: false, status: 500, error: { message: 'Connect first.' } });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
ws.send(msgInput.value);
|
|
772
|
+
});
|
|
773
|
+
if (section.wsConfig.autoConnect) {
|
|
774
|
+
connect();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (historyUrl) {
|
|
779
|
+
const historyWrap = document.createElement('div');
|
|
780
|
+
historyWrap.className = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
|
781
|
+
const header = document.createElement('div');
|
|
782
|
+
header.className = 'flex items-center justify-between';
|
|
783
|
+
const h = document.createElement('h3');
|
|
784
|
+
h.className = 'text-sm font-semibold';
|
|
785
|
+
h.textContent = 'Recent Messages';
|
|
786
|
+
const refresh = document.createElement('button');
|
|
787
|
+
refresh.className = 'rounded border border-slate-300 px-3 py-1 text-sm';
|
|
788
|
+
refresh.textContent = 'Refresh';
|
|
789
|
+
header.appendChild(h);
|
|
790
|
+
header.appendChild(refresh);
|
|
791
|
+
historyWrap.appendChild(header);
|
|
792
|
+
|
|
793
|
+
const list = document.createElement('ul');
|
|
794
|
+
list.className = 'mt-3 space-y-2 text-sm';
|
|
795
|
+
historyWrap.appendChild(list);
|
|
796
|
+
root.appendChild(historyWrap);
|
|
797
|
+
|
|
798
|
+
const loadHistory = async () => {
|
|
799
|
+
const res = await request({ method: 'GET', path: historyUrl }, null, false, {}, section.appStore);
|
|
800
|
+
list.innerHTML = '';
|
|
801
|
+
(res.data || []).forEach((msg) => {
|
|
802
|
+
const li = document.createElement('li');
|
|
803
|
+
li.className = 'rounded border border-slate-200 px-3 py-2';
|
|
804
|
+
li.textContent = `${msg.at || ''} ${msg.message || msg}`;
|
|
805
|
+
list.appendChild(li);
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
refresh.addEventListener('click', loadHistory);
|
|
809
|
+
loadHistory();
|
|
810
|
+
if (section.wsConfig.pollHistory) {
|
|
811
|
+
setInterval(loadHistory, section.wsConfig.pollHistory);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return { root, refresh: async () => {} };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function renderList(target, section, payload) {
|
|
819
|
+
target.innerHTML = '';
|
|
820
|
+
const data = payload && payload.data !== undefined ? payload.data : payload;
|
|
821
|
+
if (!data) {
|
|
822
|
+
target.textContent = 'No data.';
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
826
|
+
if (rows.length === 0) {
|
|
827
|
+
const empty = document.createElement('div');
|
|
828
|
+
empty.className = 'text-slate-500';
|
|
829
|
+
empty.textContent = 'No items yet.';
|
|
830
|
+
target.appendChild(empty);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
rows.forEach((row) => {
|
|
834
|
+
const card = document.createElement('div');
|
|
835
|
+
card.className = 'rounded border border-slate-200 px-3 py-2 space-y-2';
|
|
836
|
+
if (section.listTemplate) {
|
|
837
|
+
const label = document.createElement('div');
|
|
838
|
+
label.textContent = renderTemplate(section.listTemplate, row);
|
|
839
|
+
card.appendChild(label);
|
|
840
|
+
} else {
|
|
841
|
+
const label = document.createElement('div');
|
|
842
|
+
label.textContent = JSON.stringify(row);
|
|
843
|
+
card.appendChild(label);
|
|
844
|
+
}
|
|
845
|
+
if (section.rowFields.length || section.rowActions.length) {
|
|
846
|
+
const rowControls = document.createElement('div');
|
|
847
|
+
rowControls.className = 'grid gap-2 sm:grid-cols-3';
|
|
848
|
+
const rowInputs = {};
|
|
849
|
+
section.rowFields.forEach((field) => {
|
|
850
|
+
const prior = row && row[field.key] !== undefined ? row[field.key] : '';
|
|
851
|
+
const input = renderField(field, section.appStore, row, prior);
|
|
852
|
+
rowInputs[field.key] = input;
|
|
853
|
+
rowControls.appendChild(input);
|
|
854
|
+
});
|
|
855
|
+
section.rowActions.forEach((action) => {
|
|
856
|
+
const btn = document.createElement('button');
|
|
857
|
+
btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
|
|
858
|
+
btn.textContent = action.label;
|
|
859
|
+
btn.addEventListener('click', async () => {
|
|
860
|
+
const fields = action.fieldList.length > 0 ? action.fieldList : section.rowFields;
|
|
861
|
+
const body = collectFields(fields, rowInputs);
|
|
862
|
+
const path = renderTemplate(action.path, row, section.appStore);
|
|
863
|
+
const res = await request(action, body, action.withCreds, row, section.appStore, path);
|
|
864
|
+
applyStore(section.appStore, action.storeMap, res.json);
|
|
865
|
+
applySetMap(section.appStore, action.setMap, body, rowInputs);
|
|
866
|
+
setFooter(res);
|
|
867
|
+
if (action.refreshAllFlag && section.app.refreshAll) {
|
|
868
|
+
await section.app.refreshAll();
|
|
869
|
+
} else if (!section.noAutoRefresh && section.refresh && (section.readConfig || section.listFromPath || section.storeViewConfig)) {
|
|
870
|
+
await section.refresh();
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
rowControls.appendChild(btn);
|
|
874
|
+
});
|
|
875
|
+
card.appendChild(rowControls);
|
|
876
|
+
}
|
|
877
|
+
target.appendChild(card);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function renderActionForm(action, output, appStore, sectionInputs, section) {
|
|
882
|
+
const wrap = document.createElement('div');
|
|
883
|
+
wrap.className = 'grid gap-2 sm:grid-cols-4';
|
|
884
|
+
const fieldInputs = {};
|
|
885
|
+
action.fieldList.forEach((field) => {
|
|
886
|
+
const input = renderField(field, appStore);
|
|
887
|
+
fieldInputs[field.key] = input;
|
|
888
|
+
wrap.appendChild(input);
|
|
889
|
+
});
|
|
890
|
+
const btn = document.createElement('button');
|
|
891
|
+
btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
|
|
892
|
+
btn.textContent = action.label;
|
|
893
|
+
btn.addEventListener('click', async () => {
|
|
894
|
+
const body = Object.assign({}, action.bodyMap, collectFields(action.fieldList, fieldInputs, sectionInputs));
|
|
895
|
+
applySetMap(appStore, action.setMap, body, sectionInputs);
|
|
896
|
+
applyAdjustments(appStore, action.adjustments, sectionInputs);
|
|
897
|
+
const queryPayload = buildQueryPayload(section, sectionInputs, appStore);
|
|
898
|
+
if (action.customHandler) {
|
|
899
|
+
await action.customHandler({
|
|
900
|
+
body,
|
|
901
|
+
request: (path, opts) => request({ method: opts?.method || 'GET', path, headerMap: opts?.headers || {} }, opts?.body, action.withCreds, {}, appStore, path),
|
|
902
|
+
store: appStore,
|
|
903
|
+
output,
|
|
904
|
+
setFooter
|
|
905
|
+
});
|
|
906
|
+
if (action.refreshAllFlag && section.app.refreshAll) {
|
|
907
|
+
await section.app.refreshAll();
|
|
908
|
+
} else if (!section.noAutoRefresh && (section.readConfig || section.listFromPath || section.storeViewConfig)) {
|
|
909
|
+
await section.refresh();
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (action.localOnly) {
|
|
914
|
+
if (output) output.textContent = 'ok';
|
|
915
|
+
setFooter({ ok: true, status: 200, raw: 'ok' });
|
|
916
|
+
if (action.refreshAllFlag && section.app.refreshAll) {
|
|
917
|
+
await section.app.refreshAll();
|
|
918
|
+
} else if (!section.noAutoRefresh && (section.readConfig || section.listFromPath || section.storeViewConfig)) {
|
|
919
|
+
await section.refresh();
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const overridePath = action.method === 'GET' && section.queryMap
|
|
924
|
+
? buildQueryPath(action.path, queryPayload)
|
|
925
|
+
: null;
|
|
926
|
+
const res = await request(action, body, action.withCreds, queryPayload, appStore, overridePath);
|
|
927
|
+
if (output) output.textContent = res.raw || '-';
|
|
928
|
+
applyStore(appStore, action.storeMap, res.json);
|
|
929
|
+
applyHeaderStore(appStore, action.headerStoreMap, res.headers || {});
|
|
930
|
+
syncInputs(appStore, fieldInputs);
|
|
931
|
+
syncInputs(appStore, sectionInputs);
|
|
932
|
+
setFooter(res);
|
|
933
|
+
if (action.refreshAllFlag && section.app.refreshAll) {
|
|
934
|
+
await section.app.refreshAll();
|
|
935
|
+
} else if (!section.noAutoRefresh && (section.readConfig || section.listFromPath || section.storeViewConfig)) {
|
|
936
|
+
await section.refresh();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
wrap.appendChild(btn);
|
|
940
|
+
return wrap;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function renderField(field, store, row, keepValue) {
|
|
944
|
+
const resolvedValue = resolveTemplateValue(field.value, store, row);
|
|
945
|
+
if (field.type === 'select') {
|
|
946
|
+
// Signature: chapter-6/6-2-sql-filters.
|
|
947
|
+
// Signature: chapter-6/6-4-sql-joins (dynamic options).
|
|
948
|
+
const select = document.createElement('select');
|
|
949
|
+
select.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
|
|
950
|
+
select.name = field.key;
|
|
951
|
+
if (field.options && field.options.length > 0) {
|
|
952
|
+
(field.options || []).forEach((opt) => {
|
|
953
|
+
const option = document.createElement('option');
|
|
954
|
+
if (typeof opt === 'object') {
|
|
955
|
+
option.value = opt.value;
|
|
956
|
+
option.textContent = opt.label || opt.value;
|
|
957
|
+
} else {
|
|
958
|
+
option.value = opt;
|
|
959
|
+
option.textContent = opt;
|
|
960
|
+
}
|
|
961
|
+
select.appendChild(option);
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
if (keepValue && keepValue !== '') {
|
|
965
|
+
select.value = keepValue;
|
|
966
|
+
} else if (resolvedValue !== undefined) {
|
|
967
|
+
select.value = resolvedValue;
|
|
968
|
+
}
|
|
969
|
+
return select;
|
|
970
|
+
}
|
|
971
|
+
if (field.type === 'textarea') {
|
|
972
|
+
// Signature: chapter-5/5-4-webhooks payload editor.
|
|
973
|
+
const area = document.createElement('textarea');
|
|
974
|
+
area.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
|
|
975
|
+
area.name = field.key;
|
|
976
|
+
area.rows = field.rows || 3;
|
|
977
|
+
if (field.placeholder) area.placeholder = field.placeholder;
|
|
978
|
+
if (keepValue && keepValue !== '') {
|
|
979
|
+
area.value = keepValue;
|
|
980
|
+
} else if (resolvedValue !== undefined) {
|
|
981
|
+
area.value = resolvedValue;
|
|
982
|
+
}
|
|
983
|
+
return area;
|
|
984
|
+
}
|
|
985
|
+
const input = document.createElement('input');
|
|
986
|
+
input.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
|
|
987
|
+
input.name = field.key;
|
|
988
|
+
input.type = field.type || 'text';
|
|
989
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
990
|
+
if (keepValue && keepValue !== '' && field.type !== 'file') {
|
|
991
|
+
input.value = keepValue;
|
|
992
|
+
} else if (resolvedValue !== undefined && field.type !== 'file') {
|
|
993
|
+
input.value = resolvedValue;
|
|
994
|
+
}
|
|
995
|
+
return input;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function resolveTemplateValue(value, store, row) {
|
|
999
|
+
if (typeof value === 'string' && value.includes('{{')) {
|
|
1000
|
+
const rendered = renderTemplate(value, row || {}, store || {});
|
|
1001
|
+
return rendered === '' ? undefined : rendered;
|
|
1002
|
+
}
|
|
1003
|
+
return value;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function collectFields(fields, inputs, sectionInputs) {
|
|
1007
|
+
const out = {};
|
|
1008
|
+
fields.forEach((field) => {
|
|
1009
|
+
const input = inputs[field.key];
|
|
1010
|
+
if (!input) return;
|
|
1011
|
+
if (field.type === 'file') {
|
|
1012
|
+
out[field.key] = input.files && input.files[0];
|
|
1013
|
+
} else if (field.type === 'number') {
|
|
1014
|
+
out[field.key] = Number(input.value || 0);
|
|
1015
|
+
} else {
|
|
1016
|
+
out[field.key] = input.value;
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
Object.entries(sectionInputs || {}).forEach(([key, input]) => {
|
|
1020
|
+
if (out[key] !== undefined) return;
|
|
1021
|
+
if (input.type === 'number') {
|
|
1022
|
+
out[key] = Number(input.value || 0);
|
|
1023
|
+
} else {
|
|
1024
|
+
out[key] = input.value;
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function request(action, body, withCreds, row, appStore, overridePath) {
|
|
1031
|
+
const method = (action.method || 'GET').toUpperCase();
|
|
1032
|
+
const headers = {};
|
|
1033
|
+
const opts = { method };
|
|
1034
|
+
if (withCreds) opts.credentials = 'include';
|
|
1035
|
+
|
|
1036
|
+
if (method === 'UPLOAD') {
|
|
1037
|
+
const form = new FormData();
|
|
1038
|
+
Object.entries(body || {}).forEach(([key, value]) => {
|
|
1039
|
+
if (value) form.append(key, value);
|
|
1040
|
+
});
|
|
1041
|
+
opts.method = 'POST';
|
|
1042
|
+
opts.body = form;
|
|
1043
|
+
} else if (method !== 'GET') {
|
|
1044
|
+
headers['Content-Type'] = 'application/json';
|
|
1045
|
+
opts.body = JSON.stringify(body || {});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const path = overridePath || action.path;
|
|
1049
|
+
applyAuth(headers, action, body, appStore);
|
|
1050
|
+
Object.entries(action.headerMap || {}).forEach(([key, value]) => {
|
|
1051
|
+
headers[key] = renderTemplate(String(value), body || row || {}, appStore);
|
|
1052
|
+
});
|
|
1053
|
+
if (Object.keys(headers).length > 0) {
|
|
1054
|
+
opts.headers = Object.assign({}, opts.headers || {}, headers);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const res = await fetch(path, opts);
|
|
1058
|
+
const raw = await res.text();
|
|
1059
|
+
let json = null;
|
|
1060
|
+
try {
|
|
1061
|
+
json = JSON.parse(raw);
|
|
1062
|
+
} catch {
|
|
1063
|
+
json = { error: { message: raw } };
|
|
1064
|
+
}
|
|
1065
|
+
const headerMap = {};
|
|
1066
|
+
res.headers.forEach((value, key) => {
|
|
1067
|
+
headerMap[key.toLowerCase()] = value;
|
|
1068
|
+
});
|
|
1069
|
+
return {
|
|
1070
|
+
ok: res.ok,
|
|
1071
|
+
status: res.status,
|
|
1072
|
+
data: json.data,
|
|
1073
|
+
error: json.error,
|
|
1074
|
+
raw,
|
|
1075
|
+
json,
|
|
1076
|
+
headers: headerMap
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function setFooter(result) {
|
|
1081
|
+
const msg = result.ok ? 'ok' : `error: ${result.error ? result.error.message : result.status}`;
|
|
1082
|
+
let hint = result.ok ? '' : 'Check required fields and rules.';
|
|
1083
|
+
if (result.headers && result.headers['x-request-id']) {
|
|
1084
|
+
hint = `request ${result.headers['x-request-id']}`;
|
|
1085
|
+
}
|
|
1086
|
+
const msgEl = document.getElementById('footerMsg');
|
|
1087
|
+
const hintEl = document.getElementById('footerHint');
|
|
1088
|
+
if (msgEl) msgEl.textContent = msg;
|
|
1089
|
+
if (hintEl) hintEl.textContent = hint;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function renderFooter() {
|
|
1093
|
+
const footer = document.createElement('footer');
|
|
1094
|
+
footer.className = 'fixed bottom-0 left-0 right-0 border-t border-slate-200 bg-white';
|
|
1095
|
+
footer.innerHTML = `
|
|
1096
|
+
<div class="mx-auto flex max-w-4xl items-start gap-4 px-6 py-3 text-sm">
|
|
1097
|
+
<div class="font-semibold text-slate-700">Status</div>
|
|
1098
|
+
<div class="flex-1">
|
|
1099
|
+
<div id="footerMsg" class="text-slate-700">Ready.</div>
|
|
1100
|
+
<div id="footerHint" class="text-slate-500"></div>
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
`;
|
|
1104
|
+
return footer;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function ensureRoot(targetId) {
|
|
1108
|
+
let root = document.getElementById(targetId);
|
|
1109
|
+
if (!root) {
|
|
1110
|
+
root = document.createElement('div');
|
|
1111
|
+
root.id = targetId;
|
|
1112
|
+
document.body.appendChild(root);
|
|
1113
|
+
}
|
|
1114
|
+
document.body.className = 'min-h-screen bg-slate-50 text-slate-900';
|
|
1115
|
+
return root;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function slugify(value) {
|
|
1119
|
+
return (value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function renderTemplate(template, data, store) {
|
|
1123
|
+
return template.replace(/{{\s*([\w.]+)\s*}}/g, (_, key) => {
|
|
1124
|
+
if (key === 'json') {
|
|
1125
|
+
return JSON.stringify(data);
|
|
1126
|
+
}
|
|
1127
|
+
const value = key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), data);
|
|
1128
|
+
if (value !== undefined && value !== null) {
|
|
1129
|
+
if (typeof value === 'object') {
|
|
1130
|
+
return JSON.stringify(value);
|
|
1131
|
+
}
|
|
1132
|
+
return String(value);
|
|
1133
|
+
}
|
|
1134
|
+
const fromStore = key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), store || {});
|
|
1135
|
+
if (fromStore !== undefined && fromStore !== null) {
|
|
1136
|
+
return String(fromStore);
|
|
1137
|
+
}
|
|
1138
|
+
return value === undefined || value === null ? '' : String(value);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function normalizeField(key, value) {
|
|
1143
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1144
|
+
const hasValue = Object.prototype.hasOwnProperty.call(value, 'value');
|
|
1145
|
+
return {
|
|
1146
|
+
key,
|
|
1147
|
+
label: value.label,
|
|
1148
|
+
placeholder: value.placeholder,
|
|
1149
|
+
type: value.type || 'text',
|
|
1150
|
+
value: hasValue ? value.value : undefined,
|
|
1151
|
+
options: value.options,
|
|
1152
|
+
optionsFrom: value.optionsFrom,
|
|
1153
|
+
optionLabel: value.optionLabel,
|
|
1154
|
+
optionValue: value.optionValue
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
key,
|
|
1159
|
+
type: typeof value === 'number' ? 'number' : 'text',
|
|
1160
|
+
value
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function normalizeFields(config) {
|
|
1165
|
+
return Object.entries(config || {}).map(([key, value]) => normalizeField(key, value));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function applyAuth(headers, action, body, store) {
|
|
1169
|
+
if (!action.authMode) return;
|
|
1170
|
+
if (action.authMode.type === 'basic') {
|
|
1171
|
+
// Used by chapter-3 auth basic.
|
|
1172
|
+
const user = lookupValue(action.authMode.userKey, body, store);
|
|
1173
|
+
const pass = lookupValue(action.authMode.passKey, body, store);
|
|
1174
|
+
const token = btoa(`${user || ''}:${pass || ''}`);
|
|
1175
|
+
headers['Authorization'] = `Basic ${token}`;
|
|
1176
|
+
} else if (action.authMode.type === 'bearer') {
|
|
1177
|
+
// Used by chapter-3 auth apikey/jwt/combined.
|
|
1178
|
+
const token = lookupValue(action.authMode.tokenKey, body, store);
|
|
1179
|
+
headers['Authorization'] = `Bearer ${token || ''}`;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function applyStore(store, map, json) {
|
|
1184
|
+
if (!store || !map) return;
|
|
1185
|
+
Object.entries(map).forEach(([key, path]) => {
|
|
1186
|
+
const value = path.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), json || {});
|
|
1187
|
+
if (value !== undefined) {
|
|
1188
|
+
store[key] = value;
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function applyHeaderStore(store, map, headers) {
|
|
1194
|
+
if (!store || !map) return;
|
|
1195
|
+
Object.entries(map).forEach(([key, headerName]) => {
|
|
1196
|
+
const value = headers[headerName.toLowerCase()];
|
|
1197
|
+
if (value !== undefined) {
|
|
1198
|
+
store[key] = value;
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function syncInputs(store, inputs) {
|
|
1204
|
+
if (!store || !inputs) return;
|
|
1205
|
+
Object.entries(inputs).forEach(([key, input]) => {
|
|
1206
|
+
if (store[key] === undefined) return;
|
|
1207
|
+
if (input.type === 'file') return;
|
|
1208
|
+
input.value = store[key];
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function applySetMap(store, map, body, inputs) {
|
|
1213
|
+
if (!store || !map) return;
|
|
1214
|
+
Object.entries(map).forEach(([key, value]) => {
|
|
1215
|
+
if (typeof value === 'string') {
|
|
1216
|
+
store[key] = renderTemplate(value, body || {}, store);
|
|
1217
|
+
} else {
|
|
1218
|
+
store[key] = value;
|
|
1219
|
+
}
|
|
1220
|
+
if (inputs && inputs[key]) {
|
|
1221
|
+
inputs[key].value = store[key];
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function applyAdjustments(store, adjustments, inputs) {
|
|
1227
|
+
if (!store || !adjustments) return;
|
|
1228
|
+
adjustments.forEach((adj) => {
|
|
1229
|
+
const current = Number(store[adj.key] || 0);
|
|
1230
|
+
let delta = adj.delta;
|
|
1231
|
+
if (typeof delta === 'string') {
|
|
1232
|
+
delta = Number(renderTemplate(delta, {}, store));
|
|
1233
|
+
}
|
|
1234
|
+
let next = current + (Number(delta) || 0);
|
|
1235
|
+
if (adj.minValue !== undefined) {
|
|
1236
|
+
next = Math.max(adj.minValue, next);
|
|
1237
|
+
}
|
|
1238
|
+
store[adj.key] = next;
|
|
1239
|
+
if (inputs && inputs[adj.key]) {
|
|
1240
|
+
inputs[adj.key].value = next;
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function buildQueryPayload(section, sectionInputs, store) {
|
|
1246
|
+
if (!section || !section.queryMap) return {};
|
|
1247
|
+
const payload = {};
|
|
1248
|
+
Object.entries(section.queryMap).forEach(([key, value]) => {
|
|
1249
|
+
if (typeof value === 'string') {
|
|
1250
|
+
payload[key] = renderTemplate(value, collectFields([], {}, sectionInputs), store);
|
|
1251
|
+
} else {
|
|
1252
|
+
payload[key] = value;
|
|
1253
|
+
}
|
|
1254
|
+
store[key] = payload[key];
|
|
1255
|
+
});
|
|
1256
|
+
return payload;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function buildQueryPath(base, payload) {
|
|
1260
|
+
if (!payload || Object.keys(payload).length === 0) return base;
|
|
1261
|
+
const params = new URLSearchParams();
|
|
1262
|
+
Object.entries(payload).forEach(([key, value]) => {
|
|
1263
|
+
if (value === '' || value === null || value === undefined) return;
|
|
1264
|
+
params.set(key, String(value));
|
|
1265
|
+
});
|
|
1266
|
+
const qs = params.toString();
|
|
1267
|
+
return qs ? `${base}?${qs}` : base;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function getPath(obj, path) {
|
|
1271
|
+
if (!obj || !path) return undefined;
|
|
1272
|
+
return path.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), obj);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function updateSelectBindings(bindings, store) {
|
|
1276
|
+
if (!bindings || bindings.length === 0) return;
|
|
1277
|
+
bindings.forEach(({ field, input }) => {
|
|
1278
|
+
if (!field.optionsFrom) return;
|
|
1279
|
+
const list = getPath(store, field.optionsFrom);
|
|
1280
|
+
if (!Array.isArray(list)) return;
|
|
1281
|
+
const current = input.value;
|
|
1282
|
+
input.innerHTML = '';
|
|
1283
|
+
list.forEach((row) => {
|
|
1284
|
+
const option = document.createElement('option');
|
|
1285
|
+
const value = field.optionValue ? getPath(row, field.optionValue) : row.id ?? row.name ?? row.value ?? '';
|
|
1286
|
+
option.value = value;
|
|
1287
|
+
const label = field.optionLabel ? renderTemplate(field.optionLabel, row, store) : String(value);
|
|
1288
|
+
option.textContent = label;
|
|
1289
|
+
input.appendChild(option);
|
|
1290
|
+
});
|
|
1291
|
+
if (current) {
|
|
1292
|
+
input.value = current;
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function lookupValue(key, body, store) {
|
|
1298
|
+
if (body && body[key] !== undefined) return body[key];
|
|
1299
|
+
if (store && store[key] !== undefined) return store[key];
|
|
1300
|
+
return '';
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function renderKpis(container, config, store, data) {
|
|
1304
|
+
// Signature: chapter-4/4-6-caching, chapter-6/6-1-sql-basics.
|
|
1305
|
+
container.innerHTML = '';
|
|
1306
|
+
const items = config.items || [];
|
|
1307
|
+
items.forEach((item) => {
|
|
1308
|
+
const card = document.createElement('div');
|
|
1309
|
+
card.className = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
|
1310
|
+
const label = document.createElement('div');
|
|
1311
|
+
label.className = 'text-xs uppercase tracking-wide text-slate-500';
|
|
1312
|
+
label.textContent = item.label || '';
|
|
1313
|
+
const value = document.createElement('div');
|
|
1314
|
+
value.className = 'mt-2 text-2xl font-semibold text-slate-900';
|
|
1315
|
+
let resolved = '';
|
|
1316
|
+
if (typeof item.compute === 'function') {
|
|
1317
|
+
const computed = item.compute(data, store);
|
|
1318
|
+
resolved = computed === undefined || computed === null ? '' : String(computed);
|
|
1319
|
+
} else {
|
|
1320
|
+
resolved = resolveText(item.value || '', item.path, store, data);
|
|
1321
|
+
}
|
|
1322
|
+
value.textContent = resolved;
|
|
1323
|
+
card.appendChild(label);
|
|
1324
|
+
card.appendChild(value);
|
|
1325
|
+
container.appendChild(card);
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function resolveSectionPayload(path, store, data) {
|
|
1330
|
+
if (path) {
|
|
1331
|
+
return getPath(store, path) ?? getPath({ data }, path);
|
|
1332
|
+
}
|
|
1333
|
+
return data;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function resolveText(text, path, store, data) {
|
|
1337
|
+
if (path) {
|
|
1338
|
+
const payload = resolveSectionPayload(path, store, data);
|
|
1339
|
+
return typeof payload === 'string' ? payload : JSON.stringify(payload ?? '');
|
|
1340
|
+
}
|
|
1341
|
+
if (typeof text === 'string' && text.includes('{{')) {
|
|
1342
|
+
return renderTemplate(text, data || {}, store || {});
|
|
1343
|
+
}
|
|
1344
|
+
return text || '';
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function formatJson(value) {
|
|
1348
|
+
// Signature: chapter-4/4-7-observability colored JSON.
|
|
1349
|
+
const json = JSON.stringify(value ?? {}, null, 2);
|
|
1350
|
+
const escaped = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1351
|
+
return escaped.replace(/(\".*?\"|\btrue\b|\bfalse\b|\bnull\b|\b-?\d+(\.\d+)?\b)/g, (match) => {
|
|
1352
|
+
if (match === 'true' || match === 'false') return `<span class="text-emerald-700">${match}</span>`;
|
|
1353
|
+
if (match === 'null') return `<span class="text-slate-500">${match}</span>`;
|
|
1354
|
+
if (match.startsWith('"')) return `<span class="text-sky-700">${match}</span>`;
|
|
1355
|
+
return `<span class="text-amber-700">${match}</span>`;
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function markdownToHtml(text) {
|
|
1360
|
+
// Signature: chapter-4/4-1-validation rules block.
|
|
1361
|
+
const lines = (text || '').split('\n');
|
|
1362
|
+
const out = [];
|
|
1363
|
+
let inList = false;
|
|
1364
|
+
lines.forEach((line) => {
|
|
1365
|
+
if (/^\s*-\s+/.test(line)) {
|
|
1366
|
+
if (!inList) {
|
|
1367
|
+
out.push('<ul class="list-disc pl-5 space-y-1">');
|
|
1368
|
+
inList = true;
|
|
1369
|
+
}
|
|
1370
|
+
const item = line.replace(/^\s*-\s+/, '');
|
|
1371
|
+
out.push(`<li>${inlineMarkdown(item)}</li>`);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (inList) {
|
|
1375
|
+
out.push('</ul>');
|
|
1376
|
+
inList = false;
|
|
1377
|
+
}
|
|
1378
|
+
if (/^###\s+/.test(line)) {
|
|
1379
|
+
out.push(`<h3 class="text-sm font-semibold text-slate-700">${inlineMarkdown(line.replace(/^###\s+/, ''))}</h3>`);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (/^##\s+/.test(line)) {
|
|
1383
|
+
out.push(`<h2 class="text-base font-semibold text-slate-800">${inlineMarkdown(line.replace(/^##\s+/, ''))}</h2>`);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (/^#\s+/.test(line)) {
|
|
1387
|
+
out.push(`<h1 class="text-lg font-semibold text-slate-900">${inlineMarkdown(line.replace(/^#\s+/, ''))}</h1>`);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (line.trim() === '') {
|
|
1391
|
+
out.push('<div class="h-2"></div>');
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
out.push(`<p class="text-sm text-slate-600">${inlineMarkdown(line)}</p>`);
|
|
1395
|
+
});
|
|
1396
|
+
if (inList) out.push('</ul>');
|
|
1397
|
+
return out.join('');
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function inlineMarkdown(text) {
|
|
1401
|
+
let escaped = (text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1402
|
+
escaped = escaped.replace(/`([^`]+)`/g, '<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">$1</code>');
|
|
1403
|
+
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
1404
|
+
escaped = escaped.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
1405
|
+
return escaped;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function renderAuto(container, payload) {
|
|
1409
|
+
container.innerHTML = '';
|
|
1410
|
+
if (payload === undefined || payload === null) {
|
|
1411
|
+
container.textContent = 'No data.';
|
|
1412
|
+
container.className = 'text-sm text-slate-500';
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (Array.isArray(payload)) {
|
|
1416
|
+
const list = document.createElement('div');
|
|
1417
|
+
list.className = 'space-y-2';
|
|
1418
|
+
payload.forEach((row) => {
|
|
1419
|
+
const card = document.createElement('div');
|
|
1420
|
+
card.className = 'rounded border border-slate-200 bg-white p-3 text-sm';
|
|
1421
|
+
if (typeof row === 'object') {
|
|
1422
|
+
card.innerHTML = formatJson(row);
|
|
1423
|
+
} else {
|
|
1424
|
+
card.textContent = String(row);
|
|
1425
|
+
}
|
|
1426
|
+
list.appendChild(card);
|
|
1427
|
+
});
|
|
1428
|
+
container.appendChild(list);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (typeof payload === 'object') {
|
|
1432
|
+
const grid = document.createElement('div');
|
|
1433
|
+
grid.className = 'grid gap-3 sm:grid-cols-3';
|
|
1434
|
+
Object.entries(payload).forEach(([key, value]) => {
|
|
1435
|
+
const card = document.createElement('div');
|
|
1436
|
+
card.className = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
|
1437
|
+
const label = document.createElement('div');
|
|
1438
|
+
label.className = 'text-xs uppercase tracking-wide text-slate-500';
|
|
1439
|
+
label.textContent = key;
|
|
1440
|
+
const val = document.createElement('div');
|
|
1441
|
+
val.className = 'mt-2 text-2xl font-semibold text-slate-900';
|
|
1442
|
+
val.textContent = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
1443
|
+
card.appendChild(label);
|
|
1444
|
+
card.appendChild(val);
|
|
1445
|
+
grid.appendChild(card);
|
|
1446
|
+
});
|
|
1447
|
+
container.appendChild(grid);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const kpi = document.createElement('div');
|
|
1451
|
+
kpi.className = 'rounded-xl border border-slate-200 bg-white p-6 text-center shadow-sm';
|
|
1452
|
+
kpi.innerHTML = `<div class=\"text-xs uppercase tracking-wide text-slate-500\">value</div><div class=\"mt-2 text-3xl font-semibold text-slate-900\">${payload}</div>`;
|
|
1453
|
+
container.appendChild(kpi);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function autoMountPending() {
|
|
1457
|
+
if (document.readyState === 'loading') {
|
|
1458
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1459
|
+
pendingApps.forEach((builder) => builder.mount('app'));
|
|
1460
|
+
pendingApps.length = 0;
|
|
1461
|
+
autoMountScheduled = false;
|
|
1462
|
+
});
|
|
1463
|
+
} else {
|
|
1464
|
+
pendingApps.forEach((builder) => builder.mount('app'));
|
|
1465
|
+
pendingApps.length = 0;
|
|
1466
|
+
autoMountScheduled = false;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function scheduleAutoMount() {
|
|
1471
|
+
if (autoMountScheduled) return;
|
|
1472
|
+
autoMountScheduled = true;
|
|
1473
|
+
if (document.readyState === 'loading') {
|
|
1474
|
+
autoMountPending();
|
|
1475
|
+
} else {
|
|
1476
|
+
queueMicrotask(autoMountPending);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
autoMountPending();
|
|
1481
|
+
|
|
1482
|
+
return { app, mount };
|
|
1483
|
+
})();
|
|
1484
|
+
|
|
1485
|
+
export default ui;
|