webspresso 0.0.78 → 0.0.79

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.78",
3
+ "version": "0.0.79",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,4 +1,160 @@
1
1
  // Field Renderers - render appropriate input based on column type
2
+
3
+ // BelongsTo relation dropdown select component
4
+ const BelongsToField = {
5
+ oninit: (vnode) => {
6
+ const { col } = vnode.attrs;
7
+ const customField = col.customField || {};
8
+ const relationName = customField.relation || col.name.replace(/_id$/, '');
9
+ const modelName = m.route.param('model');
10
+ vnode.state.loading = true;
11
+ vnode.state.options = [];
12
+
13
+ api.get('/models/' + modelName + '/relations/' + relationName)
14
+ .then(result => {
15
+ vnode.state.options = result.data || [];
16
+ })
17
+ .catch(err => {
18
+ console.error('Failed to load relation data:', err);
19
+ })
20
+ .finally(() => {
21
+ vnode.state.loading = false;
22
+ m.redraw();
23
+ });
24
+ },
25
+ view: (vnode) => {
26
+ const { col, value, onChange, readonly } = vnode.attrs;
27
+ const customField = col.customField || {};
28
+ const label = col.ui?.label || formatColumnLabel(col.name);
29
+ const hint = col.ui?.hint || '';
30
+ const valueKey = customField.valueKey || 'id';
31
+ const displayKey = customField.displayKey ||
32
+ (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
33
+ 'id';
34
+
35
+ if (vnode.state.loading) {
36
+ return m('.mb-4', [
37
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
38
+ m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
39
+ ]);
40
+ }
41
+
42
+ return m('.mb-4', [
43
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, [
44
+ label,
45
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
46
+ ]),
47
+ m('select.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
48
+ id: col.name,
49
+ name: col.name,
50
+ value: value !== null && value !== undefined ? String(value) : '',
51
+ disabled: readonly,
52
+ class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
53
+ onchange: (e) => {
54
+ const selectedValue = e.target.value === '' ? null : e.target.value;
55
+ const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
56
+ ? Number(selectedValue)
57
+ : selectedValue;
58
+ onChange(typedValue);
59
+ },
60
+ }, [
61
+ col.nullable ? m('option', { value: '' }, '-- Select --') : m('option', { value: '' }, '-- Select --'),
62
+ vnode.state.options.map(item => {
63
+ const itemValue = item[valueKey];
64
+ const itemDisplay = item[displayKey] || String(itemValue);
65
+ return m('option', {
66
+ value: String(itemValue),
67
+ selected: value !== null && value !== undefined && String(value) === String(itemValue)
68
+ }, itemDisplay);
69
+ }),
70
+ ]),
71
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
72
+ ]);
73
+ }
74
+ };
75
+
76
+ // HasMany relation multi-select checkbox component
77
+ const HasManyField = {
78
+ oninit: (vnode) => {
79
+ const { col } = vnode.attrs;
80
+ const customField = col.customField || {};
81
+ const relationName = customField.relation || col.name.replace(/_ids$/, '').replace(/_id$/, '');
82
+ const modelName = m.route.param('model');
83
+ vnode.state.loading = true;
84
+ vnode.state.options = [];
85
+
86
+ api.get('/models/' + modelName + '/relations/' + relationName)
87
+ .then(result => {
88
+ vnode.state.options = result.data || [];
89
+ })
90
+ .catch(err => {
91
+ console.error('Failed to load relation data:', err);
92
+ })
93
+ .finally(() => {
94
+ vnode.state.loading = false;
95
+ m.redraw();
96
+ });
97
+ },
98
+ view: (vnode) => {
99
+ const { col, value = [], onChange, readonly } = vnode.attrs;
100
+ const customField = col.customField || {};
101
+ const label = col.ui?.label || formatColumnLabel(col.name);
102
+ const hint = col.ui?.hint || '';
103
+ const valueKey = customField.valueKey || 'id';
104
+ const displayKey = customField.displayKey ||
105
+ (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
106
+ 'id';
107
+ const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
108
+
109
+ if (vnode.state.loading) {
110
+ return m('.mb-4', [
111
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
112
+ m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
113
+ ]);
114
+ }
115
+
116
+ return m('.mb-4', [
117
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
118
+ label,
119
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
120
+ ]),
121
+ m('.border.border-gray-300.dark:border-slate-600.rounded-md.p-3.max-h-48.overflow-y-auto.bg-white.dark:bg-slate-900/70', [
122
+ vnode.state.options.map(item => {
123
+ const itemValue = String(item[valueKey]);
124
+ const itemDisplay = item[displayKey] || itemValue;
125
+ const isSelected = selectedIds.includes(itemValue);
126
+
127
+ return m('label.flex.items-center.mb-2.cursor-pointer.text-sm.text-gray-700.dark:text-slate-300', [
128
+ m('input.mr-2.rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
129
+ type: 'checkbox',
130
+ checked: isSelected,
131
+ disabled: readonly,
132
+ onchange: (e) => {
133
+ let newSelected = [...selectedIds];
134
+ if (e.target.checked) {
135
+ if (!newSelected.includes(itemValue)) {
136
+ newSelected.push(itemValue);
137
+ }
138
+ } else {
139
+ newSelected = newSelected.filter(id => id !== itemValue);
140
+ }
141
+
142
+ // Convert type if needed
143
+ const typedSelected = col.type === 'array' || typeof value[0] === 'string'
144
+ ? newSelected
145
+ : newSelected.map(Number);
146
+ onChange(typedSelected);
147
+ }
148
+ }),
149
+ m('span', itemDisplay),
150
+ ]);
151
+ }),
152
+ ]),
153
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
154
+ ]);
155
+ }
156
+ };
157
+
2
158
  const FieldRenderers = {
3
159
  // Text input (string)
4
160
  string: (col, value, onChange, readonly) => {
@@ -284,4 +440,14 @@ const FieldRenderers = {
284
440
  hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
285
441
  ]);
286
442
  },
443
+
444
+ // belongsTo relation dropdown
445
+ belongsTo: (col, value, onChange, readonly) => {
446
+ return m(BelongsToField, { col, value, onChange, readonly });
447
+ },
448
+
449
+ // hasMany relation checklist
450
+ hasMany: (col, value, onChange, readonly) => {
451
+ return m(HasManyField, { col, value, onChange, readonly });
452
+ },
287
453
  };
@@ -372,7 +372,36 @@ function getFieldRenderer(col, modelMeta) {
372
372
  });
373
373
  };
374
374
  }
375
- // Add other custom field types here if needed
375
+ if (col.customField.type === 'belongsTo') {
376
+ return FieldRenderers.belongsTo;
377
+ }
378
+ if (col.customField.type === 'hasMany') {
379
+ return FieldRenderers.hasMany;
380
+ }
381
+
382
+ // Check registered custom field renderers in browser
383
+ const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
384
+ if (custom) {
385
+ return (col, value, onChange, readonly) => {
386
+ if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
387
+ return m(custom.edit, { col, value, onChange, readonly });
388
+ } else if (typeof custom.edit === 'function') {
389
+ return custom.edit(value, onChange, col);
390
+ }
391
+ };
392
+ }
393
+ }
394
+
395
+ // Check schema type custom field renderers
396
+ if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
397
+ const custom = window.__customFieldRenderers[col.type];
398
+ return (col, value, onChange, readonly) => {
399
+ if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
400
+ return m(custom.edit, { col, value, onChange, readonly });
401
+ } else if (typeof custom.edit === 'function') {
402
+ return custom.edit(value, onChange, col);
403
+ }
404
+ };
376
405
  }
377
406
 
378
407
  if (col.type === 'file') {
@@ -403,6 +432,8 @@ function getFieldRenderer(col, modelMeta) {
403
432
  array: 'array',
404
433
  uuid: 'string',
405
434
  nanoid: 'string',
435
+ belongsTo: 'belongsTo',
436
+ hasMany: 'hasMany',
406
437
  };
407
438
  return FieldRenderers[typeMap[col.type] || 'string'];
408
439
  }
@@ -65,10 +65,20 @@ const ModelList = {
65
65
  };
66
66
 
67
67
  // Format cell value based on column type
68
- function formatCellValue(value, col) {
68
+ function formatCellValue(value, col, record = null) {
69
69
  if (value === null || value === undefined) {
70
70
  return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
71
71
  }
72
+
73
+ const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
74
+ const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
75
+ if (custom && custom.display) {
76
+ if (typeof custom.display === 'object' && custom.display.view) {
77
+ return m(custom.display, { value, col, record });
78
+ } else if (typeof custom.display === 'function') {
79
+ return custom.display(value, record);
80
+ }
81
+ }
72
82
 
73
83
  switch (col?.type) {
74
84
  case 'boolean':
@@ -478,7 +478,7 @@ const RecordList = {
478
478
  ...displayColumns.map((col, i) =>
479
479
  m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
480
480
  i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
481
- formatCellValue(record[col.name], col)
481
+ formatCellValue(record[col.name], col, record)
482
482
  )
483
483
  ),
484
484
  // Sticky actions cell (sticky right, box-shadow on left)
@@ -329,6 +329,25 @@ function adminPanelPlugin(options = {}) {
329
329
  };
330
330
  }
331
331
 
332
+ /**
333
+ * Helper to serialize arbitrary JS structures, including functions
334
+ */
335
+ function serializeJs(obj) {
336
+ if (typeof obj === 'function') {
337
+ return obj.toString();
338
+ }
339
+ if (Array.isArray(obj)) {
340
+ return '[' + obj.map(serializeJs).join(', ') + ']';
341
+ }
342
+ if (obj && typeof obj === 'object') {
343
+ const entries = Object.entries(obj).map(([key, val]) => {
344
+ return `${JSON.stringify(key)}: ${serializeJs(val)}`;
345
+ });
346
+ return '{' + entries.join(',\n') + '}';
347
+ }
348
+ return JSON.stringify(obj);
349
+ }
350
+
332
351
  /**
333
352
  * Generate admin panel HTML
334
353
  * @param {string} adminPath - Admin panel path
@@ -343,6 +362,12 @@ function generateAdminPanelHtml(adminPath, registry) {
343
362
  const customPageComponent = generateCustomPageComponent();
344
363
 
345
364
  const settings = registry.settings;
365
+ const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
366
+ return `window.__customFieldRenderers['${type}'] = {
367
+ display: ${r.display ? serializeJs(r.display) : 'null'},
368
+ edit: ${r.edit ? serializeJs(r.edit) : 'null'}
369
+ };`;
370
+ }).join('\n');
346
371
 
347
372
  return `<!DOCTYPE html>
348
373
  <html lang="en">
@@ -418,6 +443,10 @@ function generateAdminPanelHtml(adminPath, registry) {
418
443
  // Custom page components container
419
444
  window.__customPages = window.__customPages || {};
420
445
  ${registry.getClientComponents()}
446
+
447
+ // Custom field renderers container
448
+ window.__customFieldRenderers = window.__customFieldRenderers || {};
449
+ ${fieldRenderersCode}
421
450
  </script>
422
451
  <script>${appScript}</script>
423
452
  </body>