webspresso 0.0.85 → 0.0.87

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/README.md CHANGED
@@ -808,6 +808,33 @@ Options:
808
808
 
809
809
  See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.agents/skills/webspresso-usage/REFERENCE-framework.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
810
810
 
811
+ **3-State Table Column Sorting & Configuration:**
812
+
813
+ Admin Panel table headers support interactive 3-state column sorting:
814
+ - **State 1 (`Unsorted`)**: Default state (`↕` icon).
815
+ - **State 2 (`ASC`)**: 1st click sorts ascending (`▲` icon, `?sort=col&order=asc`).
816
+ - **State 3 (`DESC`)**: 2nd click sorts descending (`▼` icon, `?sort=col&order=desc`).
817
+ - **State 4 (`Unsorted`)**: 3rd click clears sorting and reverts to default primary key ordering.
818
+
819
+ Configuration options:
820
+ - **Column-level**: Standard scalar columns (`string`, `number`, `boolean`, `timestamp`) are sortable by default. Disable or enable per column:
821
+ ```javascript
822
+ schema: zdb.schema({
823
+ name: zdb.string({ sortable: true }),
824
+ secret_notes: zdb.text().config({ sortable: false }), // or zdb.text({ sortable: false })
825
+ })
826
+ ```
827
+ - **Model-level**: Restrict sortable columns or disable sorting completely on a model in `defineModel`:
828
+ ```javascript
829
+ admin: {
830
+ enabled: true,
831
+ sortableColumns: ['name', 'price'], // Only name and price are sortable
832
+ // Or disable all sorting:
833
+ // sortable: false,
834
+ }
835
+ ```
836
+
837
+
811
838
 
812
839
  **Custom Admin Pages (registerModule):**
813
840
 
@@ -858,7 +885,7 @@ if (adminApi) {
858
885
  | `scripts` | External script URLs, script objects, or inline scripts injected into SSR Admin Panel (`registerScript`) |
859
886
  | `styles` | External stylesheet URLs or inline CSS injected into SSR Admin Panel (`registerStyle`) |
860
887
 
861
- Pages support JavaScript client components (`componentFile`, `pagesDir`, `component`) as well as **custom HTML & external URL pages** (`url` or `iframeUrl` for external web apps, and `html` or `htmlFile` for local HTML files like EUIX Engine apps). By default (`layout: true`), custom page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
888
+ Pages support JavaScript client components (`componentFile`, `pagesDir`, `component`), external URLs (`url` or `iframeUrl` inside `<iframe>`), as well as **native custom HTML pages** (`html` or `htmlFile` for local HTML files like EUIX Engine apps, Vue, or Alpine). HTML pages render directly in the page DOM with full Tailwind CSS styling, dark mode, and script execution without iframe overhead. Set `iframe: true` to isolate in an iframe if needed. By default (`layout: true`), custom page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
862
889
 
863
890
  **Example component file (`admin/reports.js`):**
864
891
 
package/core/orm/model.js CHANGED
@@ -91,6 +91,9 @@ function defineModel(options) {
91
91
  icon: admin.icon || null,
92
92
  customFields: admin.customFields || {},
93
93
  queries: admin.queries || {},
94
+ sortable: admin.sortable,
95
+ sortableColumns: admin.sortableColumns,
96
+ columns: admin.columns,
94
97
  },
95
98
  rest: {
96
99
  enabled: rest.enabled === true,
@@ -231,6 +231,7 @@ class SchemaBuilder {
231
231
  if (options.readonly !== undefined) this._ui.readonly = options.readonly;
232
232
  if (options.width !== undefined) this._ui.width = options.width;
233
233
  if (options.rows !== undefined) this._ui.rows = options.rows;
234
+ if (options.sortable !== undefined) this._ui.sortable = options.sortable;
234
235
  return this;
235
236
  }
236
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.85",
3
+ "version": "0.0.87",
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",
@@ -268,6 +268,28 @@ function createApiHandlers(options) {
268
268
  }
269
269
  }
270
270
 
271
+ /**
272
+ * Helper to determine if a column is sortable based on model and column configs
273
+ */
274
+ function isColumnSortable(name, meta, model) {
275
+ if (!meta) return false;
276
+ if (model.admin && model.admin.sortable === false) return false;
277
+ if (model.admin && Array.isArray(model.admin.sortableColumns)) {
278
+ return model.admin.sortableColumns.includes(name);
279
+ }
280
+ if (model.admin?.columns?.[name]?.sortable !== undefined) {
281
+ return Boolean(model.admin.columns[name].sortable);
282
+ }
283
+ if (meta.sortable !== undefined) {
284
+ return Boolean(meta.sortable);
285
+ }
286
+ if (meta.ui?.sortable !== undefined) {
287
+ return Boolean(meta.ui.sortable);
288
+ }
289
+ const nonSortableTypes = ['json', 'array', 'file'];
290
+ return !nonSortableTypes.includes(meta.type);
291
+ }
292
+
271
293
  /**
272
294
  * Get model metadata
273
295
  */
@@ -304,6 +326,7 @@ function createApiHandlers(options) {
304
326
  validations: meta.validations || null,
305
327
  ui: meta.ui ? { ...meta.ui, hidden: isHidden || meta.ui.hidden } : (isHidden ? { hidden: true } : null),
306
328
  hidden: isHidden, // Excluded from list display and API responses
329
+ sortable: isColumnSortable(name, meta, model),
307
330
  });
308
331
  }
309
332
 
@@ -486,9 +509,22 @@ function createApiHandlers(options) {
486
509
  // Apply pagination
487
510
  query = query.offset(offset).limit(perPage);
488
511
 
489
- // Apply sorting (newest first by default)
512
+ // Apply sorting
490
513
  const primaryKey = model.primaryKey || 'id';
491
- query = query.orderBy(primaryKey, 'desc');
514
+ const sortParam = req.query.sort;
515
+ const orderParam = (req.query.order || 'desc').toLowerCase();
516
+ const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
517
+
518
+ if (sortParam && model.columns.has(sortParam)) {
519
+ const colMeta = model.columns.get(sortParam);
520
+ if (isColumnSortable(sortParam, colMeta, model)) {
521
+ query = query.orderBy(sortParam, sortOrder);
522
+ } else {
523
+ query = query.orderBy(primaryKey, 'desc');
524
+ }
525
+ } else {
526
+ query = query.orderBy(primaryKey, 'desc');
527
+ }
492
528
 
493
529
  // Get records
494
530
  const records = await query.list();
@@ -96,6 +96,8 @@ const state = {
96
96
  formData: {}, // Form field values
97
97
  editing: false,
98
98
  filters: {}, // Active filters { column: { op, value, from, to } }
99
+ sortColumn: null, // Currently sorted column name
100
+ sortDirection: null, // Sort direction: 'asc' | 'desc' | null
99
101
  filterPanelOpen: false, // Filter panel visibility (deprecated)
100
102
  filterDrawerOpen: false, // Filter drawer visibility
101
103
  bulkFields: [], // Bulk-updatable fields (enum/boolean/date/datetime/timestamp)
@@ -300,7 +300,15 @@ const FieldRenderers = {
300
300
  const label = ui.label || formatColumnLabel(col.name);
301
301
  const placeholder = ui.placeholder || '';
302
302
  const hint = ui.hint || '';
303
- const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
303
+ let dateValue = '';
304
+ if (value) {
305
+ try {
306
+ const d = new Date(value);
307
+ if (!isNaN(d.getTime())) {
308
+ dateValue = d.toISOString().split('T')[0];
309
+ }
310
+ } catch {}
311
+ }
304
312
 
305
313
  return m('.mb-4', [
306
314
  m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
@@ -329,7 +337,15 @@ const FieldRenderers = {
329
337
  const label = ui.label || formatColumnLabel(col.name);
330
338
  const placeholder = ui.placeholder || '';
331
339
  const hint = ui.hint || '';
332
- const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
340
+ let dateTimeValue = '';
341
+ if (value) {
342
+ try {
343
+ const d = new Date(value);
344
+ if (!isNaN(d.getTime())) {
345
+ dateTimeValue = d.toISOString().slice(0, 16);
346
+ }
347
+ } catch {}
348
+ }
333
349
 
334
350
  return m('.mb-4', [
335
351
  m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
@@ -90,13 +90,13 @@ function formatCellValue(value, col, record = null) {
90
90
  case 'timestamp':
91
91
  try {
92
92
  const date = new Date(value);
93
- return date.toLocaleString();
93
+ return isNaN(date.getTime()) ? String(value) : date.toLocaleString();
94
94
  } catch { return String(value); }
95
95
 
96
96
  case 'date':
97
97
  try {
98
98
  const date = new Date(value);
99
- return date.toLocaleDateString();
99
+ return isNaN(date.getTime()) ? String(value) : date.toLocaleDateString();
100
100
  } catch { return String(value); }
101
101
 
102
102
  case 'json':
@@ -523,21 +523,31 @@ function parseFilterQuery(queryString) {
523
523
  return filters;
524
524
  }
525
525
 
526
- // Load records with pagination and filters
527
- function loadRecords(modelName, page = 1, filters = null) {
526
+ // Load records with pagination, filters, and sorting
527
+ function loadRecords(modelName, page = 1, filters = null, sortColumn = undefined, sortDirection = undefined) {
528
528
  state.loading = true;
529
529
  state.error = null;
530
530
 
531
531
  const perPage = state.pagination.perPage || 20;
532
532
  const activeFilters = filters !== null ? filters : state.filters;
533
+ const activeSortCol = sortColumn !== undefined ? sortColumn : state.sortColumn;
534
+ const activeSortDir = sortDirection !== undefined ? sortDirection : state.sortDirection;
535
+
536
+ state.sortColumn = activeSortCol;
537
+ state.sortDirection = activeSortDir;
538
+
533
539
  const filterQuery = buildFilterQuery(activeFilters);
534
540
 
535
- // Update URL with filters
541
+ // Update URL with filters and sort params
536
542
  const queryParams = new URLSearchParams();
537
543
  queryParams.set('page', page);
538
544
  if (state.trashedView) {
539
545
  queryParams.set('trashed', 'only');
540
546
  }
547
+ if (activeSortCol && activeSortDir) {
548
+ queryParams.set('sort', activeSortCol);
549
+ queryParams.set('order', activeSortDir);
550
+ }
541
551
  if (Object.keys(activeFilters).length > 0) {
542
552
  for (const [col, filter] of Object.entries(activeFilters)) {
543
553
  if (!filter || !filterPayloadIsActive(filter)) continue;
@@ -566,7 +576,8 @@ function loadRecords(modelName, page = 1, filters = null) {
566
576
  window.history.replaceState({}, '', newUrl);
567
577
 
568
578
  const trashedParam = state.trashedView ? '&trashed=only' : '';
569
- return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
579
+ const sortParam = activeSortCol && activeSortDir ? '&sort=' + encodeURIComponent(activeSortCol) + '&order=' + encodeURIComponent(activeSortDir) : '';
580
+ return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery + sortParam)
570
581
  .then(result => {
571
582
  state.records = result.data || [];
572
583
  state.pagination = {
@@ -601,11 +612,16 @@ function initializeModelView(modelName) {
601
612
  state.selectedBulkField = null;
602
613
  state._currentModelName = modelName;
603
614
 
604
- // Parse filters from URL query string
615
+ // Parse filters and sorting from URL query string
605
616
  const urlParams = new URLSearchParams(window.location.search);
606
617
  const page = parseInt(urlParams.get('page')) || 1;
607
618
  state.filters = parseFilterQuery(window.location.search);
608
619
 
620
+ const rawSort = urlParams.get('sort');
621
+ const rawOrder = (urlParams.get('order') || '').toLowerCase();
622
+ state.sortColumn = rawSort || null;
623
+ state.sortDirection = rawOrder === 'asc' ? 'asc' : rawOrder === 'desc' ? 'desc' : null;
624
+
609
625
  // Load model metadata first, then records
610
626
  state.loading = true;
611
627
  api.get('/models/' + modelName)
@@ -614,7 +630,7 @@ function initializeModelView(modelName) {
614
630
  state.currentModel = modelMeta;
615
631
  // Load bulk-updatable fields for this model
616
632
  loadBulkFields(modelName);
617
- return loadRecords(modelName, page, state.filters);
633
+ return loadRecords(modelName, page, state.filters, state.sortColumn, state.sortDirection);
618
634
  })
619
635
  .catch(err => {
620
636
  state.error = err.message;
@@ -441,12 +441,54 @@ const RecordList = {
441
441
  }),
442
442
  ]),
443
443
  // Dynamic column headers (first column sticky left with box-shadow)
444
- ...displayColumns.map((col, i) =>
445
- m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700',
446
- i === 0 ? { style: 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
447
- formatColumnLabel(col.name)
448
- )
449
- ),
444
+ ...displayColumns.map((col, i) => {
445
+ const isSortable = col.sortable !== false;
446
+ const isSorted = isSortable && state.sortColumn === col.name;
447
+ const dir = isSorted ? state.sortDirection : null;
448
+ const stickyStyle = i === 0 ? 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' : '';
449
+
450
+ if (!isSortable) {
451
+ return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700',
452
+ stickyStyle ? { style: stickyStyle } : {},
453
+ formatColumnLabel(col.name)
454
+ );
455
+ }
456
+
457
+ return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group',
458
+ {
459
+ style: stickyStyle || undefined,
460
+ onclick: () => {
461
+ let nextSort = col.name;
462
+ let nextDir = 'asc';
463
+ if (state.sortColumn === col.name) {
464
+ if (state.sortDirection === 'asc') {
465
+ nextDir = 'desc';
466
+ } else if (state.sortDirection === 'desc') {
467
+ nextSort = null;
468
+ nextDir = null;
469
+ }
470
+ }
471
+ state.sortColumn = nextSort;
472
+ state.sortDirection = nextDir;
473
+ loadRecords(modelName, 1, state.filters, nextSort, nextDir);
474
+ },
475
+ },
476
+ m('.flex.items-center.gap-1.5', [
477
+ m('span', formatColumnLabel(col.name)),
478
+ dir === 'asc'
479
+ ? m('svg.w-3.5.h-3.5.text-indigo-600.dark:text-indigo-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
480
+ m('path', { 'fill-rule': 'evenodd', d: 'M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z', 'clip-rule': 'evenodd' })
481
+ )
482
+ : dir === 'desc'
483
+ ? m('svg.w-3.5.h-3.5.text-indigo-600.dark:text-indigo-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
484
+ m('path', { 'fill-rule': 'evenodd', d: 'M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z', 'clip-rule': 'evenodd' })
485
+ )
486
+ : m('svg.w-3.5.h-3.5.text-gray-300.dark:text-slate-600.group-hover:text-gray-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
487
+ m('path', { d: 'M5 12a1 1 0 100 2h10a1 1 0 100-2H5zM5 6a1 1 0 100 2h10a1 1 0 100-2H5z' })
488
+ )
489
+ ])
490
+ );
491
+ }),
450
492
  // Sticky actions header (sticky right, box-shadow on left)
451
493
  m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', {
452
494
  style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
@@ -272,6 +272,7 @@ function registerPages(moduleId, pages, deps) {
272
272
  description: page.description,
273
273
  permission: page.permission,
274
274
  layout: page.layout !== undefined ? page.layout : true,
275
+ iframe: page.iframe,
275
276
  url: page.url || page.iframeUrl || undefined,
276
277
  html: htmlContent || undefined,
277
278
  });
@@ -479,6 +479,7 @@ class AdminRegistry {
479
479
  description: p.description,
480
480
  permission: p.permission,
481
481
  layout: p.layout !== undefined ? p.layout : true,
482
+ iframe: p.iframe,
482
483
  url: p.url || p.iframeUrl,
483
484
  html: p.html,
484
485
  hasClientComponent: this.clientComponents.has(p.id),
@@ -121,7 +121,15 @@ module.exports = {
121
121
  DateField: {
122
122
  view: (vnode) => {
123
123
  const { name, value = '', meta = {}, required = false } = vnode.attrs;
124
- const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
124
+ let dateValue = '';
125
+ if (value) {
126
+ try {
127
+ const d = new Date(value);
128
+ if (!isNaN(d.getTime())) {
129
+ dateValue = d.toISOString().split('T')[0];
130
+ }
131
+ } catch {}
132
+ }
125
133
  return m('.mb-4', [
126
134
  m('label.block.text-sm.font-medium.mb-2', { for: name },
127
135
  meta.label || name,
@@ -149,7 +157,15 @@ module.exports = {
149
157
  DateTimeField: {
150
158
  view: (vnode) => {
151
159
  const { name, value = '', meta = {}, required = false } = vnode.attrs;
152
- const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
160
+ let dateTimeValue = '';
161
+ if (value) {
162
+ try {
163
+ const d = new Date(value);
164
+ if (!isNaN(d.getTime())) {
165
+ dateTimeValue = d.toISOString().slice(0, 16);
166
+ }
167
+ } catch {}
168
+ }
153
169
  return m('.mb-4', [
154
170
  m('label.block.text-sm.font-medium.mb-2', { for: name },
155
171
  meta.label || name,
@@ -156,7 +156,7 @@ function generateCustomPageComponent() {
156
156
  return `
157
157
  // Custom Page Component Factory
158
158
  function createCustomPage(pageConfig) {
159
- if (pageConfig && (pageConfig.url || pageConfig.html)) {
159
+ if (pageConfig && (pageConfig.url || (pageConfig.html && pageConfig.iframe))) {
160
160
  var iframeAttrs = {
161
161
  src: pageConfig.url || undefined,
162
162
  srcdoc: pageConfig.html || undefined,
@@ -187,6 +187,46 @@ function createCustomPage(pageConfig) {
187
187
  };
188
188
  }
189
189
 
190
+ if (pageConfig && pageConfig.html) {
191
+ var executeScripts = function(vnode) {
192
+ if (!vnode.dom) return;
193
+ var scripts = vnode.dom.querySelectorAll('script');
194
+ scripts.forEach(function(oldScript) {
195
+ if (oldScript.dataset && oldScript.dataset.executed) return;
196
+ var newScript = document.createElement('script');
197
+ Array.from(oldScript.attributes).forEach(function(attr) {
198
+ newScript.setAttribute(attr.name, attr.value);
199
+ });
200
+ newScript.dataset.executed = 'true';
201
+ newScript.appendChild(document.createTextNode(oldScript.innerHTML));
202
+ oldScript.parentNode.replaceChild(newScript, oldScript);
203
+ });
204
+ };
205
+ if (pageConfig.layout === false) {
206
+ return {
207
+ oncreate: executeScripts,
208
+ view: function() {
209
+ return m('div.custom-html-container', m.trust(pageConfig.html));
210
+ }
211
+ };
212
+ }
213
+ return {
214
+ oncreate: executeScripts,
215
+ view: function() {
216
+ return m(Layout, [
217
+ m(Breadcrumb, { items: [{ label: pageConfig.title, href: pageConfig.path }] }),
218
+ m('div.mb-4.flex.items-start.justify-between.gap-4', [
219
+ m('div', [
220
+ m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', pageConfig.title),
221
+ pageConfig.description && m('p.text-gray-500.dark:text-slate-400.mt-1', pageConfig.description)
222
+ ])
223
+ ]),
224
+ m('div.custom-html-container', m.trust(pageConfig.html))
225
+ ]);
226
+ }
227
+ };
228
+ }
229
+
190
230
  return {
191
231
  oninit(vnode) {
192
232
  vnode.state.data = null;