zauberflote 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/ui.js +268 -14
  3. package/.env +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zauberflote",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Shared UI components for the working examples book.",
5
5
  "type": "module",
6
6
  "main": "src/ui.js",
package/src/ui.js CHANGED
@@ -2,11 +2,77 @@ const ui = (() => {
2
2
  const pendingApps = [];
3
3
  let autoMountScheduled = false;
4
4
 
5
+ // Helper to wrap builders with Proxy for better error messages
6
+ function wrapBuilder(instance, className, chainHistory = []) {
7
+ const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(instance))
8
+ .filter(m => m !== 'constructor' && typeof instance[m] === 'function');
9
+
10
+ return new Proxy(instance, {
11
+ get(target, prop) {
12
+ // Track chain for error messages
13
+ target._chainHistory = chainHistory;
14
+
15
+ if (prop in target) {
16
+ const value = target[prop];
17
+ if (typeof value === 'function') {
18
+ return function(...args) {
19
+ const result = value.apply(target, args);
20
+ // If result is a builder object, wrap it too
21
+ if (result && typeof result === 'object' && result.constructor &&
22
+ result.constructor.name.includes('Builder')) {
23
+ // Format arguments nicely for the chain
24
+ const formatArg = (a) => {
25
+ if (typeof a === 'string') {
26
+ const short = a.length > 20 ? a.slice(0, 20) + '...' : a;
27
+ return `"${short.replace(/\n/g, ' ')}"`;
28
+ }
29
+ if (Array.isArray(a)) return `[${a.length} items]`;
30
+ if (typeof a === 'object' && a !== null) return '{...}';
31
+ return String(a);
32
+ };
33
+ const argStr = args.length > 0 ? `(${args.map(formatArg).join(', ')})` : '()';
34
+ const newChain = [...chainHistory, `.${prop}${argStr}`];
35
+ return wrapBuilder(result, result.constructor.name, newChain);
36
+ }
37
+ return result;
38
+ };
39
+ }
40
+ return value;
41
+ }
42
+
43
+ // Property doesn't exist - provide helpful error
44
+ if (typeof prop === 'string' && !prop.startsWith('_')) {
45
+ const suggestion = methods.find(m => m.toLowerCase().includes(prop.toLowerCase()));
46
+
47
+ // Format chain nicely - show last 5 calls max
48
+ const recentChain = chainHistory.slice(-5);
49
+ const chainStr = recentChain.length > 0
50
+ ? (chainHistory.length > 5 ? ' ...\n' : '') + recentChain.map(c => ` ${c}`).join('\n')
51
+ : ' (start of chain)';
52
+
53
+ let errorMsg = `\n[ui.js] ❌ ${className} has no method "${prop}"\n\n`;
54
+ errorMsg += `Available methods:\n ${methods.join(', ')}\n\n`;
55
+ errorMsg += `Chain (last ${Math.min(chainHistory.length, 5)} calls):\n${chainStr}\n .${prop}() ← ERROR HERE\n`;
56
+ if (suggestion) {
57
+ errorMsg += `\n💡 Did you mean: "${suggestion}"?\n`;
58
+ }
59
+ console.error(errorMsg);
60
+
61
+ // Return a function that throws to give a cleaner stack trace
62
+ return function() {
63
+ throw new Error(`[ui.js] ${className}.${prop} is not a function. See console for available methods.`);
64
+ };
65
+ }
66
+ return undefined;
67
+ }
68
+ });
69
+ }
70
+
5
71
  function app(title) {
6
72
  const builder = new AppBuilder(title);
7
73
  pendingApps.push(builder);
8
74
  scheduleAutoMount();
9
- return builder;
75
+ return wrapBuilder(builder, 'AppBuilder', [`ui.app("${title || 'App'}")`]);
10
76
  }
11
77
 
12
78
  class AppBuilder {
@@ -82,7 +148,7 @@ const ui = (() => {
82
148
  this.app = app;
83
149
  this.parent = parent || null;
84
150
  this.title = title || 'Section';
85
- this.id = slugify(this.title);
151
+ this.sectionId = slugify(this.title);
86
152
  this.readConfig = null;
87
153
  this.listTemplate = '';
88
154
  this.actions = [];
@@ -100,14 +166,28 @@ const ui = (() => {
100
166
  this.jsonConfig = null;
101
167
  this.textConfig = null;
102
168
  this.markdownConfig = null;
169
+ this.htmlContent = null;
103
170
  this.customRenderer = null;
171
+ this.renderHook = null;
104
172
  this.autoConfig = null;
173
+ this.mockConfig = null;
105
174
  this.suppressOutput = false;
106
175
  this.noAutoRefresh = false;
107
176
  this.layoutConfig = null;
177
+ this.templateMode = null;
108
178
  }
109
179
  id(value) {
110
- this.id = value;
180
+ this.sectionId = value;
181
+ return this;
182
+ }
183
+ mock(generator) {
184
+ // Signature: Smart Mocking System - show placeholder data when backend is offline.
185
+ this.mockConfig = generator;
186
+ return this;
187
+ }
188
+ onRender(callback) {
189
+ // Signature: Lifecycle hook for custom library initialization (Charts, Maps).
190
+ this.renderHook = callback;
111
191
  return this;
112
192
  }
113
193
  read(path) {
@@ -127,6 +207,11 @@ const ui = (() => {
127
207
  this.listTemplate = template || '';
128
208
  return this;
129
209
  }
210
+ template(name) {
211
+ // Signature: chapter-9 geo-search, live-poll (table display mode).
212
+ this.templateMode = name || 'default';
213
+ return this;
214
+ }
130
215
  listFrom(path) {
131
216
  // Signature: chapter-4/4-4-pagination (items list from query results).
132
217
  this.listFromPath = path;
@@ -151,6 +236,11 @@ const ui = (() => {
151
236
  this.storeMap = Object.assign({}, this.storeMap, map);
152
237
  return this;
153
238
  }
239
+ hidden() {
240
+ // Signature: hide section output (useful for store-only reads).
241
+ this.suppressOutput = true;
242
+ return this;
243
+ }
154
244
  storeView(key, template) {
155
245
  // Signature: chapter-3 JWT/CSRF token display.
156
246
  this.storeViewConfig = { key, template: template || `{{${key}}}` };
@@ -171,6 +261,11 @@ const ui = (() => {
171
261
  this.textConfig = { text: text || '', path };
172
262
  return this;
173
263
  }
264
+ html(content) {
265
+ // Signature: static HTML content block.
266
+ this.htmlContent = content || '';
267
+ return this;
268
+ }
174
269
  markdown(text, path) {
175
270
  // Signature: chapter-4/4-1-validation, chapter-0 UI language docs.
176
271
  this.markdownConfig = { text: text || '', path };
@@ -274,6 +369,15 @@ const ui = (() => {
274
369
  this.path = path;
275
370
  return this;
276
371
  }
372
+ delete(path) {
373
+ // Alias for del() - more intuitive name.
374
+ return this.del(path);
375
+ }
376
+ confirm(message) {
377
+ // Signature: show confirmation dialog before action.
378
+ this.confirmMessage = message || 'Are you sure?';
379
+ return this;
380
+ }
277
381
  upload(path) {
278
382
  // Signature: chapter-4/4-8-uploads.
279
383
  this.method = 'UPLOAD';
@@ -501,7 +605,7 @@ const ui = (() => {
501
605
 
502
606
  if (section.actions.length > 0) {
503
607
  section.actions.forEach((action) => {
504
- actionWrap.appendChild(renderActionForm(action, output, section.appStore, sectionInputs, section));
608
+ actionWrap.appendChild(renderActionForm(action, output, section.appStore, sectionInputs, section, selectBindings));
505
609
  });
506
610
  root.appendChild(actionWrap);
507
611
  }
@@ -546,6 +650,13 @@ const ui = (() => {
546
650
  root.appendChild(markdownWrap);
547
651
  }
548
652
 
653
+ // Static HTML content
654
+ if (section.htmlContent) {
655
+ const htmlWrap = document.createElement('div');
656
+ htmlWrap.innerHTML = section.htmlContent;
657
+ root.appendChild(htmlWrap);
658
+ }
659
+
549
660
  let customWrap = null;
550
661
  if (section.customRenderer) {
551
662
  customWrap = document.createElement('div');
@@ -592,6 +703,9 @@ const ui = (() => {
592
703
  customWrap.appendChild(result);
593
704
  }
594
705
  }
706
+ if (section.renderHook) {
707
+ section.renderHook({ data, store: section.appStore, element: root });
708
+ }
595
709
  };
596
710
 
597
711
  const refresh = async () => {
@@ -599,16 +713,65 @@ const ui = (() => {
599
713
  if (section.readConfig) {
600
714
  const payload = buildQueryPayload(section, sectionInputs, section.appStore);
601
715
  const path = buildQueryPath(section.readConfig.path, payload);
602
- const data = await request(section.readConfig, null, false, payload, section.appStore, path);
716
+ let data;
717
+ try {
718
+ data = await request(section.readConfig, null, false, payload, section.appStore, path);
719
+ if (!data.ok && section.mockConfig) {
720
+ throw new Error("Backend error");
721
+ }
722
+ root.classList.remove('is-mocked');
723
+ root.style.borderStyle = '';
724
+ } catch (e) {
725
+ if (section.mockConfig || section.readConfig) {
726
+ console.warn(`⚠️ Mocking data for section "${section.title}"`);
727
+ let mockData;
728
+ if (section.mockConfig) {
729
+ mockData = typeof section.mockConfig === 'function' ? section.mockConfig(section.appStore) : section.mockConfig;
730
+ } else {
731
+ // Auto-inference logic
732
+ if (section.listTemplate) {
733
+ mockData = [
734
+ { id: 1, name: "Sample Item 1", amount: 100, description: "Mocked data" },
735
+ { id: 2, name: "Sample Item 2", amount: 200, description: "Mocked data" }
736
+ ];
737
+ } else if (section.kpiConfig) {
738
+ mockData = {};
739
+ section.kpiConfig.items.forEach(item => { if (item.key) mockData[item.key] = 0; });
740
+ } else {
741
+ mockData = { message: "Mocked response" };
742
+ }
743
+ }
744
+ data = { ok: true, status: 200, json: { data: mockData }, data: mockData };
745
+ root.classList.add('is-mocked');
746
+ root.style.borderStyle = 'dashed';
747
+ root.style.borderColor = '#cbd5e1'; // slate-300
748
+ if (!root.querySelector('.mock-badge')) {
749
+ const badge = document.createElement('span');
750
+ badge.className = 'mock-badge ml-2 inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20';
751
+ badge.textContent = 'MOCKED';
752
+ root.querySelector('h2').appendChild(badge);
753
+ }
754
+ } else {
755
+ root.classList.remove('is-mocked');
756
+ root.style.borderStyle = '';
757
+ const badge = root.querySelector('.mock-badge');
758
+ if (badge) badge.remove();
759
+ throw e;
760
+ }
761
+ }
762
+ if (!root.classList.contains('is-mocked')) {
763
+ const badge = root.querySelector('.mock-badge');
764
+ if (badge) badge.remove();
765
+ }
603
766
  section.lastData = data.json || null;
604
767
  applyStore(section.appStore, section.storeMap, data.json);
605
- const hasExplicitView = section.listFromPath || section.listTemplate || section.kpiConfig || section.jsonConfig || section.textConfig || section.markdownConfig || section.customRenderer;
768
+ const hasExplicitView = section.listFromPath || section.listTemplate || section.templateMode || section.kpiConfig || section.jsonConfig || section.textConfig || section.markdownConfig || section.customRenderer;
606
769
  if (!hasExplicitView && !section.autoConfig) {
607
770
  section.autoConfig = { path: section.listFromPath || 'data' };
608
771
  }
609
772
  if (section.listFromPath) {
610
773
  renderList(listWrap, section, { data: getPath(section.appStore, section.listFromPath) });
611
- } else if (section.listTemplate) {
774
+ } else if (section.listTemplate || section.templateMode) {
612
775
  renderList(listWrap, section, data);
613
776
  }
614
777
  if (metaEl && section.metaConfig) {
@@ -649,6 +812,8 @@ const ui = (() => {
649
812
 
650
813
  section.refresh = refresh;
651
814
 
815
+ window.addEventListener(`ui:refresh:${section.sectionId}`, refresh);
816
+
652
817
  return { root, refresh };
653
818
  }
654
819
 
@@ -830,12 +995,37 @@ const ui = (() => {
830
995
  target.appendChild(empty);
831
996
  return;
832
997
  }
998
+ // Table template mode
999
+ if (section.templateMode === 'table' && rows.length > 0) {
1000
+ const table = document.createElement('table');
1001
+ table.className = 'w-full text-sm border-collapse';
1002
+ const keys = Object.keys(rows[0]);
1003
+ const thead = document.createElement('thead');
1004
+ thead.innerHTML = '<tr class="border-b border-slate-200">' + keys.map(k => `<th class="text-left py-2 px-3 font-medium text-slate-600">${k}</th>`).join('') + '</tr>';
1005
+ table.appendChild(thead);
1006
+ const tbody = document.createElement('tbody');
1007
+ rows.forEach(row => {
1008
+ const tr = document.createElement('tr');
1009
+ tr.className = 'border-b border-slate-100 hover:bg-slate-50';
1010
+ tr.innerHTML = keys.map(k => `<td class="py-2 px-3 text-slate-700">${row[k] ?? ''}</td>`).join('');
1011
+ tbody.appendChild(tr);
1012
+ });
1013
+ table.appendChild(tbody);
1014
+ target.appendChild(table);
1015
+ return;
1016
+ }
833
1017
  rows.forEach((row) => {
834
1018
  const card = document.createElement('div');
835
1019
  card.className = 'rounded border border-slate-200 px-3 py-2 space-y-2';
836
1020
  if (section.listTemplate) {
837
1021
  const label = document.createElement('div');
838
- label.textContent = renderTemplate(section.listTemplate, row);
1022
+ const rendered = renderTemplate(section.listTemplate, row);
1023
+ // Use innerHTML if template contains HTML tags, otherwise textContent
1024
+ if (/<[^>]+>/.test(rendered)) {
1025
+ label.innerHTML = rendered;
1026
+ } else {
1027
+ label.textContent = rendered;
1028
+ }
839
1029
  card.appendChild(label);
840
1030
  } else {
841
1031
  const label = document.createElement('div');
@@ -857,6 +1047,10 @@ const ui = (() => {
857
1047
  btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
858
1048
  btn.textContent = action.label;
859
1049
  btn.addEventListener('click', async () => {
1050
+ // Confirmation dialog if configured
1051
+ if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
1052
+ return;
1053
+ }
860
1054
  const fields = action.fieldList.length > 0 ? action.fieldList : section.rowFields;
861
1055
  const body = collectFields(fields, rowInputs);
862
1056
  const path = renderTemplate(action.path, row, section.appStore);
@@ -878,19 +1072,26 @@ const ui = (() => {
878
1072
  });
879
1073
  }
880
1074
 
881
- function renderActionForm(action, output, appStore, sectionInputs, section) {
1075
+ function renderActionForm(action, output, appStore, sectionInputs, section, selectBindings) {
882
1076
  const wrap = document.createElement('div');
883
1077
  wrap.className = 'grid gap-2 sm:grid-cols-4';
884
1078
  const fieldInputs = {};
885
1079
  action.fieldList.forEach((field) => {
886
1080
  const input = renderField(field, appStore);
887
1081
  fieldInputs[field.key] = input;
1082
+ if (field.optionsFrom && selectBindings) {
1083
+ selectBindings.push({ field, input });
1084
+ }
888
1085
  wrap.appendChild(input);
889
1086
  });
890
1087
  const btn = document.createElement('button');
891
1088
  btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
892
1089
  btn.textContent = action.label;
893
1090
  btn.addEventListener('click', async () => {
1091
+ // Confirmation dialog if configured
1092
+ if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
1093
+ return;
1094
+ }
894
1095
  const body = Object.assign({}, action.bodyMap, collectFields(action.fieldList, fieldInputs, sectionInputs));
895
1096
  applySetMap(appStore, action.setMap, body, sectionInputs);
896
1097
  applyAdjustments(appStore, action.adjustments, sectionInputs);
@@ -1269,6 +1470,10 @@ const ui = (() => {
1269
1470
 
1270
1471
  function getPath(obj, path) {
1271
1472
  if (!obj || !path) return undefined;
1473
+ if (typeof path !== 'string') {
1474
+ console.error(`[ui.js] getPath expected a string path, got ${typeof path}:`, path);
1475
+ return undefined;
1476
+ }
1272
1477
  return path.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), obj);
1273
1478
  }
1274
1479
 
@@ -1276,15 +1481,37 @@ const ui = (() => {
1276
1481
  if (!bindings || bindings.length === 0) return;
1277
1482
  bindings.forEach(({ field, input }) => {
1278
1483
  if (!field.optionsFrom) return;
1279
- const list = getPath(store, field.optionsFrom);
1280
- if (!Array.isArray(list)) return;
1484
+
1485
+ // Handle optionsFrom as object { store: "key", value: "id", label: "name" } or string "key"
1486
+ let storePath, valueKey, labelKey;
1487
+ if (typeof field.optionsFrom === 'string') {
1488
+ storePath = field.optionsFrom;
1489
+ valueKey = field.optionValue || 'id';
1490
+ labelKey = field.optionLabel || 'name';
1491
+ } else if (typeof field.optionsFrom === 'object') {
1492
+ storePath = field.optionsFrom.store;
1493
+ valueKey = field.optionsFrom.value || field.optionValue || 'id';
1494
+ labelKey = field.optionsFrom.label || field.optionLabel || 'name';
1495
+ } else {
1496
+ console.error(`[ui.js] optionsFrom must be a string or object, got:`, field.optionsFrom);
1497
+ return;
1498
+ }
1499
+
1500
+ const list = getPath(store, storePath);
1501
+ if (!Array.isArray(list)) {
1502
+ if (list !== undefined) {
1503
+ console.warn(`[ui.js] optionsFrom "${storePath}" is not an array:`, list);
1504
+ }
1505
+ return;
1506
+ }
1507
+
1281
1508
  const current = input.value;
1282
1509
  input.innerHTML = '';
1283
1510
  list.forEach((row) => {
1284
1511
  const option = document.createElement('option');
1285
- const value = field.optionValue ? getPath(row, field.optionValue) : row.id ?? row.name ?? row.value ?? '';
1512
+ const value = getPath(row, valueKey) ?? row.id ?? row.name ?? row.value ?? '';
1286
1513
  option.value = value;
1287
- const label = field.optionLabel ? renderTemplate(field.optionLabel, row, store) : String(value);
1514
+ const label = getPath(row, labelKey) ?? String(value);
1288
1515
  option.textContent = label;
1289
1516
  input.appendChild(option);
1290
1517
  });
@@ -1477,9 +1704,36 @@ const ui = (() => {
1477
1704
  }
1478
1705
  }
1479
1706
 
1707
+ function loadScript(url) {
1708
+ return new Promise((resolve, reject) => {
1709
+ if (document.querySelector(`script[src="${url}"]`)) return resolve();
1710
+ const s = document.createElement('script');
1711
+ s.src = url;
1712
+ s.onload = resolve;
1713
+ s.onerror = reject;
1714
+ document.head.appendChild(s);
1715
+ });
1716
+ }
1717
+
1718
+ function loadCSS(url) {
1719
+ if (document.querySelector(`link[href="${url}"]`)) return;
1720
+ const l = document.createElement('link');
1721
+ l.rel = 'stylesheet';
1722
+ l.href = url;
1723
+ document.head.appendChild(l);
1724
+ }
1725
+
1480
1726
  autoMountPending();
1481
1727
 
1482
- return { app, mount };
1728
+ // Global listener to bridge ui-refresh events to section-specific events
1729
+ window.addEventListener('ui-refresh', (e) => {
1730
+ const id = e.detail && e.detail.id;
1731
+ if (id) {
1732
+ window.dispatchEvent(new CustomEvent(`ui:refresh:${id}`));
1733
+ }
1734
+ });
1735
+
1736
+ return { app, mount, loadScript, loadCSS };
1483
1737
  })();
1484
1738
 
1485
1739
  export default ui;
package/.env DELETED
@@ -1 +0,0 @@
1
- TOKEN=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