webspresso 0.0.23 → 0.0.26

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.23",
3
+ "version": "0.0.26",
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
  "bin": {
@@ -14,7 +14,10 @@
14
14
  "test:e2e:ui": "playwright test --ui",
15
15
  "test:e2e:debug": "playwright test --debug",
16
16
  "test:e2e:headed": "playwright test --headed",
17
- "release": "release-it"
17
+ "release": "release-it",
18
+ "docs:dev": "cd docs && npm run start",
19
+ "docs:build": "cd docs && npm run build",
20
+ "docs:serve": "cd docs && npm run serve"
18
21
  },
19
22
  "keywords": [
20
23
  "ssr",
@@ -7,6 +7,19 @@
7
7
  const { getAllModels, getModel } = require('../../core/orm/model');
8
8
  const { checkAdminExists, setupAdmin, login, logout, requireAuth } = require('./auth');
9
9
 
10
+ /**
11
+ * Check if rich-text content is empty
12
+ * @param {string} value - Rich-text HTML value
13
+ * @returns {boolean} True if empty
14
+ */
15
+ function isRichTextEmpty(value) {
16
+ if (!value) return true;
17
+ // Remove all HTML tags and check if only whitespace remains
18
+ const stripped = value.replace(/<[^>]*>/g, '').trim();
19
+ // Check for common empty Quill outputs
20
+ return stripped === '' || value === '<p><br></p>' || value === '<p></p>';
21
+ }
22
+
10
23
  /**
11
24
  * Create API route handlers
12
25
  * @param {Object} options - Options
@@ -239,10 +252,96 @@ function createApiHandlers(options) {
239
252
 
240
253
  // Build query
241
254
  let query = repo.query();
255
+ let countQuery = repo.query();
256
+
257
+ // Parse filter parameters from query string
258
+ // Express's qs library automatically parses filter[column][prop] into nested objects
259
+ // So req.query.filter is already { column: { op: '...', value: '...' } }
260
+ const filterParams = req.query.filter || {};
261
+
262
+ // Apply filters
263
+ for (const [colName, filter] of Object.entries(filterParams)) {
264
+ const colMeta = model.columns.get(colName);
265
+ const colType = colMeta?.type || 'string';
266
+ const op = filter.op || (colType === 'boolean' ? 'eq' : 'contains');
267
+ const value = filter.value;
268
+ const from = filter.from;
269
+ const to = filter.to;
270
+
271
+ // Handle boolean values - convert string 'true'/'false' to actual boolean
272
+ if (colType === 'boolean' && value !== undefined && value !== null) {
273
+ const boolValue = value === 'true' || value === true ? 1 : 0;
274
+ query = query.where(colName, '=', boolValue);
275
+ countQuery = countQuery.where(colName, '=', boolValue);
276
+ continue;
277
+ }
242
278
 
243
- // Apply filters if provided
279
+ if (op === 'between' && (from || to)) {
280
+ if (from && to) {
281
+ query = query.whereBetween(colName, [from, to]);
282
+ countQuery = countQuery.whereBetween(colName, [from, to]);
283
+ } else if (from) {
284
+ query = query.where(colName, '>=', from);
285
+ countQuery = countQuery.where(colName, '>=', from);
286
+ } else if (to) {
287
+ query = query.where(colName, '<=', to);
288
+ countQuery = countQuery.where(colName, '<=', to);
289
+ }
290
+ } else if (op === 'in' && Array.isArray(value) && value.length > 0) {
291
+ query = query.whereIn(colName, value);
292
+ countQuery = countQuery.whereIn(colName, value);
293
+ } else if (value !== undefined && value !== null && value !== '') {
294
+ switch (op) {
295
+ case 'contains':
296
+ // Apply LIKE for string/text types, or if type is unknown
297
+ if (colType === 'string' || colType === 'text' || !colMeta) {
298
+ query = query.where(colName, 'like', `%${value}%`);
299
+ countQuery = countQuery.where(colName, 'like', `%${value}%`);
300
+ }
301
+ break;
302
+ case 'equals':
303
+ query = query.where(colName, '=', value);
304
+ countQuery = countQuery.where(colName, '=', value);
305
+ break;
306
+ case 'starts_with':
307
+ if (colType === 'string' || colType === 'text' || !colMeta) {
308
+ query = query.where(colName, 'like', `${value}%`);
309
+ countQuery = countQuery.where(colName, 'like', `${value}%`);
310
+ }
311
+ break;
312
+ case 'ends_with':
313
+ if (colType === 'string' || colType === 'text' || !colMeta) {
314
+ query = query.where(colName, 'like', `%${value}`);
315
+ countQuery = countQuery.where(colName, 'like', `%${value}`);
316
+ }
317
+ break;
318
+ case 'gt':
319
+ query = query.where(colName, '>', value);
320
+ countQuery = countQuery.where(colName, '>', value);
321
+ break;
322
+ case 'gte':
323
+ query = query.where(colName, '>=', value);
324
+ countQuery = countQuery.where(colName, '>=', value);
325
+ break;
326
+ case 'lt':
327
+ query = query.where(colName, '<', value);
328
+ countQuery = countQuery.where(colName, '<', value);
329
+ break;
330
+ case 'lte':
331
+ query = query.where(colName, '<=', value);
332
+ countQuery = countQuery.where(colName, '<=', value);
333
+ break;
334
+ case 'eq':
335
+ default:
336
+ query = query.where(colName, '=', value);
337
+ countQuery = countQuery.where(colName, '=', value);
338
+ break;
339
+ }
340
+ }
341
+ }
342
+
343
+ // Legacy search support (backward compatibility)
244
344
  if (req.query.search) {
245
- // Simple search on string columns
246
345
  const searchTerm = `%${req.query.search}%`;
247
346
  const stringColumns = Array.from(model.columns.entries())
248
347
  .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
@@ -258,11 +357,20 @@ function createApiHandlers(options) {
258
357
  }
259
358
  }
260
359
  });
360
+ countQuery = countQuery.where(function() {
361
+ for (let i = 0; i < stringColumns.length; i++) {
362
+ if (i === 0) {
363
+ this.where(stringColumns[i], 'like', searchTerm);
364
+ } else {
365
+ this.orWhere(stringColumns[i], 'like', searchTerm);
366
+ }
367
+ }
368
+ });
261
369
  }
262
370
  }
263
371
 
264
- // Get total count
265
- const total = await repo.count();
372
+ // Get total count with filters applied
373
+ const total = await countQuery.count();
266
374
 
267
375
  // Apply pagination
268
376
  query = query.offset(offset).limit(perPage);
@@ -325,6 +433,18 @@ function createApiHandlers(options) {
325
433
  return res.status(404).json({ error: 'Model not found or not enabled' });
326
434
  }
327
435
 
436
+ // Validate rich-text fields
437
+ for (const [colName, colMeta] of model.columns) {
438
+ if (model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
439
+ const value = req.body[colName];
440
+ if (isRichTextEmpty(value)) {
441
+ return res.status(400).json({
442
+ error: `Field "${colName}" is required`
443
+ });
444
+ }
445
+ }
446
+ }
447
+
328
448
  const repo = db.getRepository(model.name);
329
449
  const record = await repo.create(req.body);
330
450
 
@@ -346,6 +466,18 @@ function createApiHandlers(options) {
346
466
  return res.status(404).json({ error: 'Model not found or not enabled' });
347
467
  }
348
468
 
469
+ // Validate rich-text fields (only if field is being updated)
470
+ for (const [colName, colMeta] of model.columns) {
471
+ if (colName in req.body && model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
472
+ const value = req.body[colName];
473
+ if (isRichTextEmpty(value)) {
474
+ return res.status(400).json({
475
+ error: `Field "${colName}" is required`
476
+ });
477
+ }
478
+ }
479
+ }
480
+
349
481
  const repo = db.getRepository(model.name);
350
482
  const record = await repo.update(id, req.body);
351
483
 
@@ -32,6 +32,20 @@ const api = {
32
32
  delete(path) { return this.request(path, { method: 'DELETE' }); },
33
33
  };
34
34
 
35
+ // Helper: Capitalize first letter of each word
36
+ function capitalizeWords(str) {
37
+ if (!str) return '';
38
+ return str.split(' ').map(function(word) {
39
+ return word.charAt(0).toUpperCase() + word.slice(1);
40
+ }).join(' ');
41
+ }
42
+
43
+ // Helper: Format column name to label
44
+ function formatColumnLabel(name) {
45
+ if (!name) return '';
46
+ return capitalizeWords(name.replace(/_/g, ' '));
47
+ }
48
+
35
49
  // State
36
50
  const state = {
37
51
  user: null,
@@ -51,6 +65,8 @@ const state = {
51
65
  currentRecord: null,
52
66
  formData: {}, // Form field values
53
67
  editing: false,
68
+ filters: {}, // Active filters { column: { op, value, from, to } }
69
+ filterPanelOpen: false, // Filter panel visibility
54
70
  };
55
71
 
56
72
  // Breadcrumb Component
@@ -97,6 +113,281 @@ const Breadcrumb = {
97
113
  },
98
114
  };
99
115
 
116
+ // Filter Badges Component - shows active filters
117
+ const FilterBadges = {
118
+ view: (vnode) => {
119
+ const { filters, modelMeta, onRemove } = vnode.attrs;
120
+ if (!filters || Object.keys(filters).length === 0) return null;
121
+
122
+ const badges = [];
123
+ for (const [colName, filter] of Object.entries(filters)) {
124
+ const col = modelMeta?.columns?.find(c => c.name === colName);
125
+ const label = col?.ui?.label || col?.name?.replace(/_/g, ' ') || colName;
126
+ let text = '';
127
+
128
+ if (filter.op === 'between') {
129
+ text = label + ': ' + filter.from + ' to ' + filter.to;
130
+ } else if (filter.op === 'in') {
131
+ text = label + ': ' + (Array.isArray(filter.value) ? filter.value.join(', ') : filter.value);
132
+ } else if (filter.op) {
133
+ text = label + ' ' + filter.op + ': ' + filter.value;
134
+ } else {
135
+ text = label + ': ' + filter.value;
136
+ }
137
+
138
+ badges.push(
139
+ m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.bg-blue-50.text-blue-700.mr-1.mb-1', [
140
+ text,
141
+ m('button.ml-1.hover:text-blue-900.text-blue-400', {
142
+ onclick: () => onRemove(colName),
143
+ type: 'button',
144
+ }, '×'),
145
+ ])
146
+ );
147
+ }
148
+
149
+ return badges.length > 0 ? m('.mb-2.flex.flex-wrap', badges) : null;
150
+ },
151
+ };
152
+
153
+ // Filter Panel Component
154
+ const FilterPanel = {
155
+ view: (vnode) => {
156
+ const { modelMeta, filters, onFilterChange, onApply, onClear } = vnode.attrs;
157
+ const isOpen = vnode.attrs.isOpen || false;
158
+
159
+ if (!isOpen) return null;
160
+ if (!modelMeta || !modelMeta.columns) return null;
161
+
162
+ // Get filterable columns (exclude auto columns, json, and relations)
163
+ const filterableColumns = modelMeta.columns.filter(col => {
164
+ if (col.primary || col.autoIncrement) return false;
165
+ if (col.auto === 'create' || col.auto === 'update') return false;
166
+ if (col.type === 'json') return false;
167
+ return true;
168
+ });
169
+
170
+ const filterInputs = filterableColumns.map(col => {
171
+ const currentFilter = filters[col.name] || {};
172
+ const colLabel = col.ui?.label || formatColumnLabel(col.name);
173
+
174
+ if (col.type === 'boolean') {
175
+ return m('.mb-2', [
176
+ m('label.block.text-xs.font-medium.text-gray-600.mb-1', colLabel),
177
+ m('.flex.items-center.gap-3', [
178
+ m('label.flex.items-center.cursor-pointer', [
179
+ m('input.mr-1.w-3.h-3', {
180
+ type: 'radio',
181
+ name: 'filter_' + col.name,
182
+ checked: currentFilter.value === 'true',
183
+ onchange: () => onFilterChange(col.name, { value: 'true' }),
184
+ }),
185
+ m('span.text-xs', 'Yes'),
186
+ ]),
187
+ m('label.flex.items-center.cursor-pointer', [
188
+ m('input.mr-1.w-3.h-3', {
189
+ type: 'radio',
190
+ name: 'filter_' + col.name,
191
+ checked: currentFilter.value === 'false',
192
+ onchange: () => onFilterChange(col.name, { value: 'false' }),
193
+ }),
194
+ m('span.text-xs', 'No'),
195
+ ]),
196
+ m('label.flex.items-center.cursor-pointer', [
197
+ m('input.mr-1.w-3.h-3', {
198
+ type: 'radio',
199
+ name: 'filter_' + col.name,
200
+ checked: !currentFilter.value,
201
+ onchange: () => onFilterChange(col.name, null),
202
+ }),
203
+ m('span.text-xs', 'All'),
204
+ ]),
205
+ ]),
206
+ ]);
207
+ } else if (col.type === 'date' || col.type === 'datetime' || col.type === 'timestamp') {
208
+ return m('.mb-2', [
209
+ m('label.block.text-xs.font-medium.text-gray-600.mb-1', colLabel),
210
+ m('.flex.items-center.gap-1', [
211
+ m('select.px-1.py-0.5.border.border-gray-300.rounded.text-xs', {
212
+ value: currentFilter.op || 'eq',
213
+ onchange: (e) => {
214
+ const op = e.target.value;
215
+ const existing = currentFilter.value || {};
216
+ onFilterChange(col.name, { ...existing, op });
217
+ },
218
+ }, [
219
+ m('option', { value: 'eq' }, '='),
220
+ m('option', { value: 'gt' }, '>'),
221
+ m('option', { value: 'gte' }, '≥'),
222
+ m('option', { value: 'lt' }, '<'),
223
+ m('option', { value: 'lte' }, '≤'),
224
+ m('option', { value: 'between' }, '↔'),
225
+ ]),
226
+ currentFilter.op === 'between' ? [
227
+ m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.w-24', {
228
+ type: col.type === 'date' ? 'date' : 'datetime-local',
229
+ value: currentFilter.from || '',
230
+ oninput: (e) => {
231
+ onFilterChange(col.name, {
232
+ op: 'between',
233
+ from: e.target.value,
234
+ to: currentFilter.to || ''
235
+ });
236
+ },
237
+ }),
238
+ m('span.text-xs.text-gray-400', '-'),
239
+ m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.w-24', {
240
+ type: col.type === 'date' ? 'date' : 'datetime-local',
241
+ value: currentFilter.to || '',
242
+ oninput: (e) => {
243
+ onFilterChange(col.name, {
244
+ op: 'between',
245
+ from: currentFilter.from || '',
246
+ to: e.target.value
247
+ });
248
+ },
249
+ }),
250
+ ] : m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.flex-1', {
251
+ type: col.type === 'date' ? 'date' : 'datetime-local',
252
+ value: currentFilter.value || '',
253
+ oninput: (e) => {
254
+ onFilterChange(col.name, {
255
+ op: currentFilter.op || 'eq',
256
+ value: e.target.value
257
+ });
258
+ },
259
+ }),
260
+ ]),
261
+ ]);
262
+ } else if (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') {
263
+ return m('.mb-2', [
264
+ m('label.block.text-xs.font-medium.text-gray-600.mb-1', colLabel),
265
+ m('.flex.items-center.gap-1', [
266
+ m('select.px-1.py-0.5.border.border-gray-300.rounded.text-xs', {
267
+ value: currentFilter.op || 'eq',
268
+ onchange: (e) => {
269
+ const op = e.target.value;
270
+ const existing = currentFilter.value || {};
271
+ onFilterChange(col.name, { ...existing, op });
272
+ },
273
+ }, [
274
+ m('option', { value: 'eq' }, '='),
275
+ m('option', { value: 'gt' }, '>'),
276
+ m('option', { value: 'gte' }, '≥'),
277
+ m('option', { value: 'lt' }, '<'),
278
+ m('option', { value: 'lte' }, '≤'),
279
+ m('option', { value: 'between' }, '↔'),
280
+ ]),
281
+ currentFilter.op === 'between' ? [
282
+ m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.w-16', {
283
+ type: 'number',
284
+ value: currentFilter.from || '',
285
+ placeholder: 'Min',
286
+ oninput: (e) => {
287
+ onFilterChange(col.name, {
288
+ op: 'between',
289
+ from: e.target.value,
290
+ to: currentFilter.to || ''
291
+ });
292
+ },
293
+ }),
294
+ m('span.text-xs.text-gray-400', '-'),
295
+ m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.w-16', {
296
+ type: 'number',
297
+ value: currentFilter.to || '',
298
+ placeholder: 'Max',
299
+ oninput: (e) => {
300
+ onFilterChange(col.name, {
301
+ op: 'between',
302
+ from: currentFilter.from || '',
303
+ to: e.target.value
304
+ });
305
+ },
306
+ }),
307
+ ] : m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.flex-1', {
308
+ type: 'number',
309
+ value: currentFilter.value || '',
310
+ placeholder: 'Value',
311
+ oninput: (e) => {
312
+ onFilterChange(col.name, {
313
+ op: currentFilter.op || 'eq',
314
+ value: e.target.value
315
+ });
316
+ },
317
+ }),
318
+ ]),
319
+ ]);
320
+ } else if (col.type === 'enum') {
321
+ return m('.mb-2', [
322
+ m('label.block.text-xs.font-medium.text-gray-600.mb-1', colLabel),
323
+ m('select.px-1.py-0.5.border.border-gray-300.rounded.text-xs.w-full', {
324
+ multiple: true,
325
+ style: 'min-height: 50px;',
326
+ value: Array.isArray(currentFilter.value) ? currentFilter.value : (currentFilter.value ? [currentFilter.value] : []),
327
+ onchange: (e) => {
328
+ const selected = Array.from(e.target.selectedOptions, opt => opt.value);
329
+ onFilterChange(col.name, selected.length > 0 ? { op: 'in', value: selected } : null);
330
+ },
331
+ }, [
332
+ ...(col.enumValues || []).map(val =>
333
+ m('option', { value: val }, val)
334
+ ),
335
+ ]),
336
+ ]);
337
+ } else {
338
+ // String/text fields
339
+ return m('.mb-2', [
340
+ m('label.block.text-xs.font-medium.text-gray-600.mb-1', colLabel),
341
+ m('.flex.items-center.gap-1', [
342
+ m('select.px-1.py-0.5.border.border-gray-300.rounded.text-xs', {
343
+ value: currentFilter.op || 'contains',
344
+ onchange: (e) => {
345
+ const op = e.target.value;
346
+ const existing = currentFilter.value || {};
347
+ onFilterChange(col.name, { ...existing, op });
348
+ },
349
+ }, [
350
+ m('option', { value: 'contains' }, '~'),
351
+ m('option', { value: 'equals' }, '='),
352
+ m('option', { value: 'starts_with' }, 'A*'),
353
+ m('option', { value: 'ends_with' }, '*Z'),
354
+ ]),
355
+ m('input.px-1.py-0.5.border.border-gray-300.rounded.text-xs.flex-1', {
356
+ type: 'text',
357
+ value: currentFilter.value || '',
358
+ placeholder: 'Enter search term',
359
+ oninput: (e) => {
360
+ onFilterChange(col.name, {
361
+ op: currentFilter.op || 'contains',
362
+ value: e.target.value
363
+ });
364
+ },
365
+ }),
366
+ ]),
367
+ ]);
368
+ }
369
+ });
370
+
371
+ return m('.bg-white.border.border-gray-200.rounded-lg.shadow-sm.p-3.mb-3', [
372
+ m('.flex.items-center.justify-between.mb-2.pb-2.border-b.border-gray-100', [
373
+ m('span.text-sm.font-medium.text-gray-700', 'Filters'),
374
+ m('button.text-xs.text-gray-400.hover:text-gray-600', {
375
+ onclick: () => vnode.attrs.onToggle(false),
376
+ }, '✕'),
377
+ ]),
378
+ m('.grid.grid-cols-2.md:grid-cols-3.lg:grid-cols-4.xl:grid-cols-5.gap-3', filterInputs),
379
+ m('.flex.items-center.justify-end.gap-2.mt-3.pt-2.border-t.border-gray-100', [
380
+ m('button.px-3.py-1.text-xs.bg-gray-100.text-gray-600.rounded.hover:bg-gray-200', {
381
+ onclick: onClear,
382
+ }, 'Clear'),
383
+ m('button.px-3.py-1.text-xs.bg-blue-600.text-white.rounded.hover:bg-blue-700', {
384
+ onclick: onApply,
385
+ }, 'Apply'),
386
+ ]),
387
+ ]);
388
+ },
389
+ };
390
+
100
391
  // Pagination Component
101
392
  const Pagination = {
102
393
  view: (vnode) => {
@@ -174,7 +465,7 @@ const FieldRenderers = {
174
465
  string: (col, value, onChange, readonly) => {
175
466
  const validations = col.validations || {};
176
467
  const ui = col.ui || {};
177
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
468
+ const label = ui.label || formatColumnLabel(col.name);
178
469
  const inputType = ui.inputType || (validations.email ? 'email' : validations.url ? 'url' : 'text');
179
470
  const placeholder = ui.placeholder || '';
180
471
  const hint = ui.hint || '';
@@ -204,7 +495,7 @@ const FieldRenderers = {
204
495
  text: (col, value, onChange, readonly) => {
205
496
  const validations = col.validations || {};
206
497
  const ui = col.ui || {};
207
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
498
+ const label = ui.label || formatColumnLabel(col.name);
208
499
  const placeholder = ui.placeholder || '';
209
500
  const hint = ui.hint || '';
210
501
  const rows = ui.rows || 4;
@@ -232,7 +523,7 @@ const FieldRenderers = {
232
523
  integer: (col, value, onChange, readonly) => {
233
524
  const validations = col.validations || {};
234
525
  const ui = col.ui || {};
235
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
526
+ const label = ui.label || formatColumnLabel(col.name);
236
527
  const placeholder = ui.placeholder || '';
237
528
  const hint = ui.hint || '';
238
529
 
@@ -261,7 +552,7 @@ const FieldRenderers = {
261
552
  float: (col, value, onChange, readonly) => {
262
553
  const validations = col.validations || {};
263
554
  const ui = col.ui || {};
264
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
555
+ const label = ui.label || formatColumnLabel(col.name);
265
556
  const placeholder = ui.placeholder || '';
266
557
  const hint = ui.hint || '';
267
558
 
@@ -289,7 +580,7 @@ const FieldRenderers = {
289
580
  // Boolean checkbox
290
581
  boolean: (col, value, onChange, readonly) => {
291
582
  const ui = col.ui || {};
292
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
583
+ const label = ui.label || formatColumnLabel(col.name);
293
584
  const hint = ui.hint || '';
294
585
 
295
586
  return m('.mb-4', [
@@ -311,7 +602,7 @@ const FieldRenderers = {
311
602
  date: (col, value, onChange, readonly) => {
312
603
  const validations = col.validations || {};
313
604
  const ui = col.ui || {};
314
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
605
+ const label = ui.label || formatColumnLabel(col.name);
315
606
  const placeholder = ui.placeholder || '';
316
607
  const hint = ui.hint || '';
317
608
  const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
@@ -340,7 +631,7 @@ const FieldRenderers = {
340
631
  datetime: (col, value, onChange, readonly) => {
341
632
  const validations = col.validations || {};
342
633
  const ui = col.ui || {};
343
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
634
+ const label = ui.label || formatColumnLabel(col.name);
344
635
  const placeholder = ui.placeholder || '';
345
636
  const hint = ui.hint || '';
346
637
  const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
@@ -368,7 +659,7 @@ const FieldRenderers = {
368
659
  // Enum select
369
660
  enum: (col, value, onChange, readonly) => {
370
661
  const ui = col.ui || {};
371
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
662
+ const label = ui.label || formatColumnLabel(col.name);
372
663
  const hint = ui.hint || '';
373
664
  const options = col.enumValues || [];
374
665
 
@@ -393,7 +684,7 @@ const FieldRenderers = {
393
684
  // JSON textarea
394
685
  json: (col, value, onChange, readonly) => {
395
686
  const ui = col.ui || {};
396
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
687
+ const label = ui.label || formatColumnLabel(col.name);
397
688
  const placeholder = ui.placeholder || '';
398
689
  const hint = ui.hint || '';
399
690
  const rows = ui.rows || 6;
@@ -427,7 +718,7 @@ const FieldRenderers = {
427
718
  array: (col, value, onChange, readonly) => {
428
719
  const validations = col.validations || {};
429
720
  const ui = col.ui || {};
430
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
721
+ const label = ui.label || formatColumnLabel(col.name);
431
722
  const placeholder = ui.placeholder || 'Comma-separated values';
432
723
  const hint = ui.hint || 'Enter comma-separated values';
433
724
  const arrayValue = Array.isArray(value) ? value.join(', ') : (value || '');
@@ -547,7 +838,7 @@ const RichTextField = {
547
838
  view: (vnode) => {
548
839
  const { name, col, value = '', onChange, readonly } = vnode.attrs;
549
840
  const ui = col.ui || {};
550
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
841
+ const label = ui.label || formatColumnLabel(col.name);
551
842
  const hint = ui.hint || '';
552
843
  const editorId = 'quill-editor-' + name;
553
844
  const required = !col.nullable && !readonly;
@@ -621,6 +912,15 @@ function isAutoColumn(col) {
621
912
  return false;
622
913
  }
623
914
 
915
+ // Check if rich-text content is empty
916
+ function isRichTextEmpty(value) {
917
+ if (!value) return true;
918
+ // Remove all HTML tags and check if only whitespace remains
919
+ const stripped = value.replace(/<[^>]*>/g, '').trim();
920
+ // Check for common empty Quill outputs
921
+ return stripped === '' || value === '<p><br></p>' || value === '<p></p>';
922
+ }
923
+
624
924
  // Login Form Component
625
925
  const LoginForm = {
626
926
  view: () => m('.max-w-md.mx-auto.mt-16', [
@@ -883,13 +1183,116 @@ function getDisplayColumns(columns) {
883
1183
  .slice(0, 6); // Max 6 columns for readability
884
1184
  }
885
1185
 
886
- // Load records with pagination
887
- function loadRecords(modelName, page = 1) {
1186
+ // Build query string from filters
1187
+ function buildFilterQuery(filters) {
1188
+ if (!filters || Object.keys(filters).length === 0) return '';
1189
+
1190
+ const params = [];
1191
+ for (const [col, filter] of Object.entries(filters)) {
1192
+ if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1193
+
1194
+ if (filter.op === 'between') {
1195
+ params.push('filter[' + col + '][op]=between');
1196
+ if (filter.from) params.push('filter[' + col + '][from]=' + encodeURIComponent(filter.from));
1197
+ if (filter.to) params.push('filter[' + col + '][to]=' + encodeURIComponent(filter.to));
1198
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1199
+ params.push('filter[' + col + '][op]=in');
1200
+ filter.value.forEach(v => params.push('filter[' + col + '][value]=' + encodeURIComponent(v)));
1201
+ } else {
1202
+ if (filter.op) params.push('filter[' + col + '][op]=' + encodeURIComponent(filter.op));
1203
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1204
+ params.push('filter[' + col + '][value]=' + encodeURIComponent(filter.value));
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ return params.length > 0 ? '&' + params.join('&') : '';
1210
+ }
1211
+
1212
+ // Parse query string to filters
1213
+ function parseFilterQuery(queryString) {
1214
+ const filters = {};
1215
+ if (!queryString) return filters;
1216
+
1217
+ const params = new URLSearchParams(queryString);
1218
+ const filterParams = {};
1219
+
1220
+ // Group filter parameters using simple string parsing (no regex needed)
1221
+ for (const [key, value] of params.entries()) {
1222
+ // Parse filter[column][prop] format
1223
+ if (key.startsWith('filter[')) {
1224
+ const firstClose = key.indexOf(']');
1225
+ const secondOpen = key.indexOf('[', firstClose);
1226
+ const secondClose = key.indexOf(']', secondOpen);
1227
+
1228
+ if (firstClose > 7 && secondOpen > firstClose && secondClose > secondOpen) {
1229
+ const col = key.substring(7, firstClose);
1230
+ const prop = key.substring(secondOpen + 1, secondClose);
1231
+
1232
+ if (!filterParams[col]) filterParams[col] = {};
1233
+ if (prop === 'value' && filterParams[col].value) {
1234
+ // Multiple values for 'in' operator
1235
+ if (!Array.isArray(filterParams[col].value)) {
1236
+ filterParams[col].value = [filterParams[col].value];
1237
+ }
1238
+ filterParams[col].value.push(value);
1239
+ } else {
1240
+ filterParams[col][prop] = value;
1241
+ }
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ // Convert to filter format
1247
+ for (const [col, data] of Object.entries(filterParams)) {
1248
+ if (data.op === 'between') {
1249
+ filters[col] = { op: 'between', from: data.from || '', to: data.to || '' };
1250
+ } else if (data.op === 'in') {
1251
+ filters[col] = { op: 'in', value: Array.isArray(data.value) ? data.value : [data.value] };
1252
+ } else {
1253
+ filters[col] = { op: data.op || 'contains', value: data.value || '' };
1254
+ }
1255
+ }
1256
+
1257
+ return filters;
1258
+ }
1259
+
1260
+ // Load records with pagination and filters
1261
+ function loadRecords(modelName, page = 1, filters = null) {
888
1262
  state.loading = true;
889
1263
  state.error = null;
890
1264
 
891
1265
  const perPage = state.pagination.perPage || 20;
892
- api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage)
1266
+ const activeFilters = filters !== null ? filters : state.filters;
1267
+ const filterQuery = buildFilterQuery(activeFilters);
1268
+
1269
+ // Update URL with filters
1270
+ const queryParams = new URLSearchParams();
1271
+ queryParams.set('page', page);
1272
+ if (Object.keys(activeFilters).length > 0) {
1273
+ for (const [col, filter] of Object.entries(activeFilters)) {
1274
+ if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1275
+ if (filter.op === 'between') {
1276
+ queryParams.set('filter[' + col + '][op]', 'between');
1277
+ if (filter.from) queryParams.set('filter[' + col + '][from]', filter.from);
1278
+ if (filter.to) queryParams.set('filter[' + col + '][to]', filter.to);
1279
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1280
+ queryParams.set('filter[' + col + '][op]', 'in');
1281
+ filter.value.forEach(v => queryParams.append('filter[' + col + '][value]', v));
1282
+ } else {
1283
+ if (filter.op) queryParams.set('filter[' + col + '][op]', filter.op);
1284
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1285
+ queryParams.set('filter[' + col + '][value]', filter.value);
1286
+ }
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // Update browser URL without reload
1292
+ const newUrl = window.location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
1293
+ window.history.replaceState({}, '', newUrl);
1294
+
1295
+ api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + filterQuery)
893
1296
  .then(result => {
894
1297
  state.records = result.data || [];
895
1298
  state.pagination = {
@@ -915,6 +1318,12 @@ const RecordList = {
915
1318
  state.records = [];
916
1319
  state.currentModelMeta = null;
917
1320
  state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1321
+ state.filterPanelOpen = false;
1322
+
1323
+ // Parse filters from URL query string
1324
+ const urlParams = new URLSearchParams(window.location.search);
1325
+ const page = parseInt(urlParams.get('page')) || 1;
1326
+ state.filters = parseFilterQuery(window.location.search);
918
1327
 
919
1328
  // Load model metadata first, then records
920
1329
  state.loading = true;
@@ -922,7 +1331,7 @@ const RecordList = {
922
1331
  .then(modelMeta => {
923
1332
  state.currentModelMeta = modelMeta;
924
1333
  state.currentModel = modelMeta;
925
- return loadRecords(modelName, 1);
1334
+ return loadRecords(modelName, page, state.filters);
926
1335
  })
927
1336
  .catch(err => {
928
1337
  state.error = err.message;
@@ -943,14 +1352,68 @@ const RecordList = {
943
1352
  return m(Layout, { breadcrumbs }, [
944
1353
  m('.flex.items-center.justify-between.mb-6', [
945
1354
  m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
946
- m('button.bg-blue-600.text-white.px-4.py-2.rounded.hover:bg-blue-700', {
947
- onclick: () => {
948
- state.currentRecord = null;
949
- state.editing = true;
950
- m.route.set('/models/' + modelName + '/new');
951
- }
952
- }, 'New Record'),
1355
+ m('.flex.items-center.gap-2', [
1356
+ m('button.bg-gray-200.text-gray-800.px-4.py-2.rounded.hover:bg-gray-300', {
1357
+ onclick: () => {
1358
+ state.filterPanelOpen = !state.filterPanelOpen;
1359
+ m.redraw();
1360
+ }
1361
+ }, [
1362
+ m('span.mr-2', '🔍'),
1363
+ 'Filter',
1364
+ ]),
1365
+ m('button.bg-blue-600.text-white.px-4.py-2.rounded.hover:bg-blue-700', {
1366
+ onclick: () => {
1367
+ state.currentRecord = null;
1368
+ state.editing = true;
1369
+ m.route.set('/models/' + modelName + '/new');
1370
+ }
1371
+ }, 'New Record'),
1372
+ ]),
953
1373
  ]),
1374
+
1375
+ // Filter badges
1376
+ m(FilterBadges, {
1377
+ filters: state.filters,
1378
+ modelMeta: modelMeta,
1379
+ onRemove: (colName) => {
1380
+ const newFilters = { ...state.filters };
1381
+ delete newFilters[colName];
1382
+ state.filters = newFilters;
1383
+ loadRecords(modelName, 1, newFilters);
1384
+ },
1385
+ }),
1386
+
1387
+ // Filter panel
1388
+ m(FilterPanel, {
1389
+ isOpen: state.filterPanelOpen,
1390
+ modelMeta: modelMeta,
1391
+ filters: state.filters,
1392
+ onToggle: (open) => {
1393
+ state.filterPanelOpen = open;
1394
+ m.redraw();
1395
+ },
1396
+ onFilterChange: (colName, filter) => {
1397
+ const newFilters = { ...state.filters };
1398
+ if (filter === null) {
1399
+ delete newFilters[colName];
1400
+ } else {
1401
+ newFilters[colName] = filter;
1402
+ }
1403
+ state.filters = newFilters;
1404
+ m.redraw();
1405
+ },
1406
+ onApply: () => {
1407
+ state.filterPanelOpen = false;
1408
+ loadRecords(modelName, 1, state.filters);
1409
+ },
1410
+ onClear: () => {
1411
+ state.filters = {};
1412
+ state.filterPanelOpen = false;
1413
+ loadRecords(modelName, 1, {});
1414
+ },
1415
+ }),
1416
+
954
1417
  state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
955
1418
  state.loading
956
1419
  ? m('p.text-gray-600', 'Loading records...')
@@ -1096,6 +1559,21 @@ const RecordForm = {
1096
1559
  state.loading = true;
1097
1560
  state.error = null;
1098
1561
  try {
1562
+ // Validate rich-text fields first
1563
+ if (modelMeta && modelMeta.columns) {
1564
+ for (const col of modelMeta.columns) {
1565
+ if (col.customField && col.customField.type === 'rich-text' && !col.nullable) {
1566
+ const hiddenInput = document.getElementById(col.name + '-value');
1567
+ const value = hiddenInput ? hiddenInput.value : state.formData[col.name];
1568
+ if (isRichTextEmpty(value)) {
1569
+ state.error = (col.ui?.label || col.name) + ' is required';
1570
+ state.loading = false;
1571
+ return;
1572
+ }
1573
+ }
1574
+ }
1575
+ }
1576
+
1099
1577
  // Build payload, excluding auto-generated fields
1100
1578
  const payload = {};
1101
1579
  if (modelMeta && modelMeta.columns) {
@@ -1111,6 +1589,13 @@ const RecordForm = {
1111
1589
  if (hiddenInput) {
1112
1590
  value = hiddenInput.value;
1113
1591
  }
1592
+ // Skip empty rich-text values (normalize to null if nullable)
1593
+ if (isRichTextEmpty(value)) {
1594
+ if (col.nullable) {
1595
+ payload[col.name] = null;
1596
+ }
1597
+ return;
1598
+ }
1114
1599
  }
1115
1600
 
1116
1601
  if (value !== undefined && value !== null && value !== '') {