zauberflote 1.0.1 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/ui.js +132 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zauberflote",
3
- "version": "1.0.1",
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 {
@@ -100,6 +166,7 @@ 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;
104
171
  this.renderHook = null;
105
172
  this.autoConfig = null;
@@ -169,6 +236,11 @@ const ui = (() => {
169
236
  this.storeMap = Object.assign({}, this.storeMap, map);
170
237
  return this;
171
238
  }
239
+ hidden() {
240
+ // Signature: hide section output (useful for store-only reads).
241
+ this.suppressOutput = true;
242
+ return this;
243
+ }
172
244
  storeView(key, template) {
173
245
  // Signature: chapter-3 JWT/CSRF token display.
174
246
  this.storeViewConfig = { key, template: template || `{{${key}}}` };
@@ -189,6 +261,11 @@ const ui = (() => {
189
261
  this.textConfig = { text: text || '', path };
190
262
  return this;
191
263
  }
264
+ html(content) {
265
+ // Signature: static HTML content block.
266
+ this.htmlContent = content || '';
267
+ return this;
268
+ }
192
269
  markdown(text, path) {
193
270
  // Signature: chapter-4/4-1-validation, chapter-0 UI language docs.
194
271
  this.markdownConfig = { text: text || '', path };
@@ -292,6 +369,15 @@ const ui = (() => {
292
369
  this.path = path;
293
370
  return this;
294
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
+ }
295
381
  upload(path) {
296
382
  // Signature: chapter-4/4-8-uploads.
297
383
  this.method = 'UPLOAD';
@@ -564,6 +650,13 @@ const ui = (() => {
564
650
  root.appendChild(markdownWrap);
565
651
  }
566
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
+
567
660
  let customWrap = null;
568
661
  if (section.customRenderer) {
569
662
  customWrap = document.createElement('div');
@@ -954,6 +1047,10 @@ const ui = (() => {
954
1047
  btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
955
1048
  btn.textContent = action.label;
956
1049
  btn.addEventListener('click', async () => {
1050
+ // Confirmation dialog if configured
1051
+ if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
1052
+ return;
1053
+ }
957
1054
  const fields = action.fieldList.length > 0 ? action.fieldList : section.rowFields;
958
1055
  const body = collectFields(fields, rowInputs);
959
1056
  const path = renderTemplate(action.path, row, section.appStore);
@@ -991,6 +1088,10 @@ const ui = (() => {
991
1088
  btn.className = 'rounded border border-slate-300 px-3 py-2 text-sm';
992
1089
  btn.textContent = action.label;
993
1090
  btn.addEventListener('click', async () => {
1091
+ // Confirmation dialog if configured
1092
+ if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
1093
+ return;
1094
+ }
994
1095
  const body = Object.assign({}, action.bodyMap, collectFields(action.fieldList, fieldInputs, sectionInputs));
995
1096
  applySetMap(appStore, action.setMap, body, sectionInputs);
996
1097
  applyAdjustments(appStore, action.adjustments, sectionInputs);
@@ -1369,6 +1470,10 @@ const ui = (() => {
1369
1470
 
1370
1471
  function getPath(obj, path) {
1371
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
+ }
1372
1477
  return path.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), obj);
1373
1478
  }
1374
1479
 
@@ -1376,15 +1481,37 @@ const ui = (() => {
1376
1481
  if (!bindings || bindings.length === 0) return;
1377
1482
  bindings.forEach(({ field, input }) => {
1378
1483
  if (!field.optionsFrom) return;
1379
- const list = getPath(store, field.optionsFrom);
1380
- 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
+
1381
1508
  const current = input.value;
1382
1509
  input.innerHTML = '';
1383
1510
  list.forEach((row) => {
1384
1511
  const option = document.createElement('option');
1385
- 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 ?? '';
1386
1513
  option.value = value;
1387
- const label = field.optionLabel ? renderTemplate(field.optionLabel, row, store) : String(value);
1514
+ const label = getPath(row, labelKey) ?? String(value);
1388
1515
  option.textContent = label;
1389
1516
  input.appendChild(option);
1390
1517
  });