webspresso 0.0.22 → 0.0.24

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.22",
3
+ "version": "0.0.24",
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": {
@@ -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,107 @@ 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
+ // Format: filter[column][op]=value or filter[column][value]=value
259
+ const filterParams = {};
260
+ for (const [key, value] of Object.entries(req.query)) {
261
+ const match = key.match(/^filter\[([^\]]+)\]\[([^\]]+)\]$/);
262
+ if (match) {
263
+ const [, col, prop] = match;
264
+ if (!filterParams[col]) filterParams[col] = {};
265
+ if (prop === 'value') {
266
+ // Handle multiple values for 'in' operator
267
+ if (filterParams[col].value) {
268
+ if (!Array.isArray(filterParams[col].value)) {
269
+ filterParams[col].value = [filterParams[col].value];
270
+ }
271
+ filterParams[col].value.push(value);
272
+ } else {
273
+ filterParams[col].value = value;
274
+ }
275
+ } else {
276
+ filterParams[col][prop] = value;
277
+ }
278
+ }
279
+ }
280
+
281
+ // Apply filters
282
+ for (const [colName, filter] of Object.entries(filterParams)) {
283
+ const colMeta = model.columns.get(colName);
284
+ if (!colMeta) continue; // Skip if column doesn't exist
285
+
286
+ const op = filter.op || 'contains';
287
+ const value = filter.value;
288
+ const from = filter.from;
289
+ const to = filter.to;
290
+
291
+ if (op === 'between' && (from || to)) {
292
+ if (from && to) {
293
+ query = query.whereBetween(colName, [from, to]);
294
+ countQuery = countQuery.whereBetween(colName, [from, to]);
295
+ } else if (from) {
296
+ query = query.where(colName, '>=', from);
297
+ countQuery = countQuery.where(colName, '>=', from);
298
+ } else if (to) {
299
+ query = query.where(colName, '<=', to);
300
+ countQuery = countQuery.where(colName, '<=', to);
301
+ }
302
+ } else if (op === 'in' && Array.isArray(value) && value.length > 0) {
303
+ query = query.whereIn(colName, value);
304
+ countQuery = countQuery.whereIn(colName, value);
305
+ } else if (value !== undefined && value !== null && value !== '') {
306
+ switch (op) {
307
+ case 'contains':
308
+ if (colMeta.type === 'string' || colMeta.type === 'text') {
309
+ query = query.where(colName, 'like', `%${value}%`);
310
+ countQuery = countQuery.where(colName, 'like', `%${value}%`);
311
+ }
312
+ break;
313
+ case 'equals':
314
+ query = query.where(colName, '=', value);
315
+ countQuery = countQuery.where(colName, '=', value);
316
+ break;
317
+ case 'starts_with':
318
+ if (colMeta.type === 'string' || colMeta.type === 'text') {
319
+ query = query.where(colName, 'like', `${value}%`);
320
+ countQuery = countQuery.where(colName, 'like', `${value}%`);
321
+ }
322
+ break;
323
+ case 'ends_with':
324
+ if (colMeta.type === 'string' || colMeta.type === 'text') {
325
+ query = query.where(colName, 'like', `%${value}`);
326
+ countQuery = countQuery.where(colName, 'like', `%${value}`);
327
+ }
328
+ break;
329
+ case 'gt':
330
+ query = query.where(colName, '>', value);
331
+ countQuery = countQuery.where(colName, '>', value);
332
+ break;
333
+ case 'gte':
334
+ query = query.where(colName, '>=', value);
335
+ countQuery = countQuery.where(colName, '>=', value);
336
+ break;
337
+ case 'lt':
338
+ query = query.where(colName, '<', value);
339
+ countQuery = countQuery.where(colName, '<', value);
340
+ break;
341
+ case 'lte':
342
+ query = query.where(colName, '<=', value);
343
+ countQuery = countQuery.where(colName, '<=', value);
344
+ break;
345
+ case 'eq':
346
+ default:
347
+ query = query.where(colName, '=', value);
348
+ countQuery = countQuery.where(colName, '=', value);
349
+ break;
350
+ }
351
+ }
352
+ }
242
353
 
243
- // Apply filters if provided
354
+ // Legacy search support (backward compatibility)
244
355
  if (req.query.search) {
245
- // Simple search on string columns
246
356
  const searchTerm = `%${req.query.search}%`;
247
357
  const stringColumns = Array.from(model.columns.entries())
248
358
  .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
@@ -258,11 +368,20 @@ function createApiHandlers(options) {
258
368
  }
259
369
  }
260
370
  });
371
+ countQuery = countQuery.where(function() {
372
+ for (let i = 0; i < stringColumns.length; i++) {
373
+ if (i === 0) {
374
+ this.where(stringColumns[i], 'like', searchTerm);
375
+ } else {
376
+ this.orWhere(stringColumns[i], 'like', searchTerm);
377
+ }
378
+ }
379
+ });
261
380
  }
262
381
  }
263
382
 
264
- // Get total count
265
- const total = await repo.count();
383
+ // Get total count with filters applied
384
+ const total = await countQuery.count();
266
385
 
267
386
  // Apply pagination
268
387
  query = query.offset(offset).limit(perPage);
@@ -325,6 +444,18 @@ function createApiHandlers(options) {
325
444
  return res.status(404).json({ error: 'Model not found or not enabled' });
326
445
  }
327
446
 
447
+ // Validate rich-text fields
448
+ for (const [colName, colMeta] of model.columns) {
449
+ if (model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
450
+ const value = req.body[colName];
451
+ if (isRichTextEmpty(value)) {
452
+ return res.status(400).json({
453
+ error: `Field "${colName}" is required`
454
+ });
455
+ }
456
+ }
457
+ }
458
+
328
459
  const repo = db.getRepository(model.name);
329
460
  const record = await repo.create(req.body);
330
461
 
@@ -346,6 +477,18 @@ function createApiHandlers(options) {
346
477
  return res.status(404).json({ error: 'Model not found or not enabled' });
347
478
  }
348
479
 
480
+ // Validate rich-text fields (only if field is being updated)
481
+ for (const [colName, colMeta] of model.columns) {
482
+ if (colName in req.body && model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
483
+ const value = req.body[colName];
484
+ if (isRichTextEmpty(value)) {
485
+ return res.status(400).json({
486
+ error: `Field "${colName}" is required`
487
+ });
488
+ }
489
+ }
490
+ }
491
+
349
492
  const repo = db.getRepository(model.name);
350
493
  const record = await repo.update(id, req.body);
351
494
 
@@ -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,283 @@ 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-3.py-1.rounded-full.text-xs.font-medium.bg-blue-100.text-blue-800.mr-2.mb-2', [
140
+ text,
141
+ m('button.ml-2.hover:text-blue-900', {
142
+ onclick: () => onRemove(colName),
143
+ type: 'button',
144
+ }, '×'),
145
+ ])
146
+ );
147
+ }
148
+
149
+ return badges.length > 0 ? m('.mb-4.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
+ // Determine filter input based on column type
175
+ if (col.type === 'boolean') {
176
+ return m('.mb-4', [
177
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', colLabel),
178
+ m('.flex.items-center.gap-4', [
179
+ m('label.flex.items-center', [
180
+ m('input.mr-2', {
181
+ type: 'radio',
182
+ name: 'filter_' + col.name,
183
+ checked: currentFilter.value === 'true',
184
+ onchange: () => onFilterChange(col.name, { value: 'true' }),
185
+ }),
186
+ m('span.text-sm', 'Yes'),
187
+ ]),
188
+ m('label.flex.items-center', [
189
+ m('input.mr-2', {
190
+ type: 'radio',
191
+ name: 'filter_' + col.name,
192
+ checked: currentFilter.value === 'false',
193
+ onchange: () => onFilterChange(col.name, { value: 'false' }),
194
+ }),
195
+ m('span.text-sm', 'No'),
196
+ ]),
197
+ m('label.flex.items-center', [
198
+ m('input.mr-2', {
199
+ type: 'radio',
200
+ name: 'filter_' + col.name,
201
+ checked: !currentFilter.value,
202
+ onchange: () => onFilterChange(col.name, null),
203
+ }),
204
+ m('span.text-sm', 'All'),
205
+ ]),
206
+ ]),
207
+ ]);
208
+ } else if (col.type === 'date' || col.type === 'datetime' || col.type === 'timestamp') {
209
+ return m('.mb-4', [
210
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', colLabel),
211
+ m('.flex.items-center.gap-2', [
212
+ m('select.px-2.py-1.border.border-gray-300.rounded.text-sm', {
213
+ value: currentFilter.op || 'eq',
214
+ onchange: (e) => {
215
+ const op = e.target.value;
216
+ const existing = currentFilter.value || {};
217
+ onFilterChange(col.name, { ...existing, op });
218
+ },
219
+ }, [
220
+ m('option', { value: 'eq' }, 'Equals'),
221
+ m('option', { value: 'gt' }, 'After'),
222
+ m('option', { value: 'gte' }, 'On or After'),
223
+ m('option', { value: 'lt' }, 'Before'),
224
+ m('option', { value: 'lte' }, 'On or Before'),
225
+ m('option', { value: 'between' }, 'Between'),
226
+ ]),
227
+ currentFilter.op === 'between' ? [
228
+ m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
229
+ type: col.type === 'date' ? 'date' : 'datetime-local',
230
+ value: currentFilter.from || '',
231
+ placeholder: 'From',
232
+ oninput: (e) => {
233
+ onFilterChange(col.name, {
234
+ op: 'between',
235
+ from: e.target.value,
236
+ to: currentFilter.to || ''
237
+ });
238
+ },
239
+ }),
240
+ m('span.text-sm', 'to'),
241
+ m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
242
+ type: col.type === 'date' ? 'date' : 'datetime-local',
243
+ value: currentFilter.to || '',
244
+ placeholder: 'To',
245
+ oninput: (e) => {
246
+ onFilterChange(col.name, {
247
+ op: 'between',
248
+ from: currentFilter.from || '',
249
+ to: e.target.value
250
+ });
251
+ },
252
+ }),
253
+ ] : m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
254
+ type: col.type === 'date' ? 'date' : 'datetime-local',
255
+ value: currentFilter.value || '',
256
+ oninput: (e) => {
257
+ onFilterChange(col.name, {
258
+ op: currentFilter.op || 'eq',
259
+ value: e.target.value
260
+ });
261
+ },
262
+ }),
263
+ ]),
264
+ ]);
265
+ } else if (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') {
266
+ return m('.mb-4', [
267
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', colLabel),
268
+ m('.flex.items-center.gap-2', [
269
+ m('select.px-2.py-1.border.border-gray-300.rounded.text-sm', {
270
+ value: currentFilter.op || 'eq',
271
+ onchange: (e) => {
272
+ const op = e.target.value;
273
+ const existing = currentFilter.value || {};
274
+ onFilterChange(col.name, { ...existing, op });
275
+ },
276
+ }, [
277
+ m('option', { value: 'eq' }, 'Equals'),
278
+ m('option', { value: 'gt' }, 'Greater Than'),
279
+ m('option', { value: 'gte' }, 'Greater or Equal'),
280
+ m('option', { value: 'lt' }, 'Less Than'),
281
+ m('option', { value: 'lte' }, 'Less or Equal'),
282
+ m('option', { value: 'between' }, 'Between'),
283
+ ]),
284
+ currentFilter.op === 'between' ? [
285
+ m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
286
+ type: 'number',
287
+ value: currentFilter.from || '',
288
+ placeholder: 'From',
289
+ oninput: (e) => {
290
+ onFilterChange(col.name, {
291
+ op: 'between',
292
+ from: e.target.value,
293
+ to: currentFilter.to || ''
294
+ });
295
+ },
296
+ }),
297
+ m('span.text-sm', 'to'),
298
+ m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
299
+ type: 'number',
300
+ value: currentFilter.to || '',
301
+ placeholder: 'To',
302
+ oninput: (e) => {
303
+ onFilterChange(col.name, {
304
+ op: 'between',
305
+ from: currentFilter.from || '',
306
+ to: e.target.value
307
+ });
308
+ },
309
+ }),
310
+ ] : m('input.px-2.py-1.border.border-gray-300.rounded.text-sm', {
311
+ type: 'number',
312
+ value: currentFilter.value || '',
313
+ placeholder: 'Enter value',
314
+ oninput: (e) => {
315
+ onFilterChange(col.name, {
316
+ op: currentFilter.op || 'eq',
317
+ value: e.target.value
318
+ });
319
+ },
320
+ }),
321
+ ]),
322
+ ]);
323
+ } else if (col.type === 'enum') {
324
+ return m('.mb-4', [
325
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', colLabel),
326
+ m('select.px-2.py-1.border.border-gray-300.rounded.text-sm.w-full', {
327
+ multiple: true,
328
+ value: Array.isArray(currentFilter.value) ? currentFilter.value : (currentFilter.value ? [currentFilter.value] : []),
329
+ onchange: (e) => {
330
+ const selected = Array.from(e.target.selectedOptions, opt => opt.value);
331
+ onFilterChange(col.name, selected.length > 0 ? { op: 'in', value: selected } : null);
332
+ },
333
+ }, [
334
+ ...(col.enumValues || []).map(val =>
335
+ m('option', { value: val }, val)
336
+ ),
337
+ ]),
338
+ ]);
339
+ } else {
340
+ // String/text fields
341
+ return m('.mb-4', [
342
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', colLabel),
343
+ m('.flex.items-center.gap-2', [
344
+ m('select.px-2.py-1.border.border-gray-300.rounded.text-sm', {
345
+ value: currentFilter.op || 'contains',
346
+ onchange: (e) => {
347
+ const op = e.target.value;
348
+ const existing = currentFilter.value || {};
349
+ onFilterChange(col.name, { ...existing, op });
350
+ },
351
+ }, [
352
+ m('option', { value: 'contains' }, 'Contains'),
353
+ m('option', { value: 'equals' }, 'Equals'),
354
+ m('option', { value: 'starts_with' }, 'Starts With'),
355
+ m('option', { value: 'ends_with' }, 'Ends With'),
356
+ ]),
357
+ m('input.px-2.py-1.border.border-gray-300.rounded.text-sm.flex-1', {
358
+ type: 'text',
359
+ value: currentFilter.value || '',
360
+ placeholder: 'Enter search term',
361
+ oninput: (e) => {
362
+ onFilterChange(col.name, {
363
+ op: currentFilter.op || 'contains',
364
+ value: e.target.value
365
+ });
366
+ },
367
+ }),
368
+ ]),
369
+ ]);
370
+ }
371
+ });
372
+
373
+ return m('.bg-white.border.border-gray-200.rounded.shadow-lg.p-4.mb-4', [
374
+ m('.flex.items-center.justify-between.mb-4', [
375
+ m('h3.text-lg.font-semibold', 'Filters'),
376
+ m('button.text-sm.text-gray-600.hover:text-gray-800', {
377
+ onclick: () => vnode.attrs.onToggle(false),
378
+ }, '× Close'),
379
+ ]),
380
+ m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-4', filterInputs),
381
+ m('.flex.items-center.justify-end.gap-2.mt-4.pt-4.border-t', [
382
+ m('button.px-4.py-2.bg-gray-200.text-gray-800.rounded.hover:bg-gray-300', {
383
+ onclick: onClear,
384
+ }, 'Clear All'),
385
+ m('button.px-4.py-2.bg-blue-600.text-white.rounded.hover:bg-blue-700', {
386
+ onclick: onApply,
387
+ }, 'Apply Filters'),
388
+ ]),
389
+ ]);
390
+ },
391
+ };
392
+
100
393
  // Pagination Component
101
394
  const Pagination = {
102
395
  view: (vnode) => {
@@ -174,7 +467,7 @@ const FieldRenderers = {
174
467
  string: (col, value, onChange, readonly) => {
175
468
  const validations = col.validations || {};
176
469
  const ui = col.ui || {};
177
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
470
+ const label = ui.label || formatColumnLabel(col.name);
178
471
  const inputType = ui.inputType || (validations.email ? 'email' : validations.url ? 'url' : 'text');
179
472
  const placeholder = ui.placeholder || '';
180
473
  const hint = ui.hint || '';
@@ -204,7 +497,7 @@ const FieldRenderers = {
204
497
  text: (col, value, onChange, readonly) => {
205
498
  const validations = col.validations || {};
206
499
  const ui = col.ui || {};
207
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
500
+ const label = ui.label || formatColumnLabel(col.name);
208
501
  const placeholder = ui.placeholder || '';
209
502
  const hint = ui.hint || '';
210
503
  const rows = ui.rows || 4;
@@ -232,7 +525,7 @@ const FieldRenderers = {
232
525
  integer: (col, value, onChange, readonly) => {
233
526
  const validations = col.validations || {};
234
527
  const ui = col.ui || {};
235
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
528
+ const label = ui.label || formatColumnLabel(col.name);
236
529
  const placeholder = ui.placeholder || '';
237
530
  const hint = ui.hint || '';
238
531
 
@@ -261,7 +554,7 @@ const FieldRenderers = {
261
554
  float: (col, value, onChange, readonly) => {
262
555
  const validations = col.validations || {};
263
556
  const ui = col.ui || {};
264
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
557
+ const label = ui.label || formatColumnLabel(col.name);
265
558
  const placeholder = ui.placeholder || '';
266
559
  const hint = ui.hint || '';
267
560
 
@@ -289,7 +582,7 @@ const FieldRenderers = {
289
582
  // Boolean checkbox
290
583
  boolean: (col, value, onChange, readonly) => {
291
584
  const ui = col.ui || {};
292
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
585
+ const label = ui.label || formatColumnLabel(col.name);
293
586
  const hint = ui.hint || '';
294
587
 
295
588
  return m('.mb-4', [
@@ -311,7 +604,7 @@ const FieldRenderers = {
311
604
  date: (col, value, onChange, readonly) => {
312
605
  const validations = col.validations || {};
313
606
  const ui = col.ui || {};
314
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
607
+ const label = ui.label || formatColumnLabel(col.name);
315
608
  const placeholder = ui.placeholder || '';
316
609
  const hint = ui.hint || '';
317
610
  const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
@@ -340,7 +633,7 @@ const FieldRenderers = {
340
633
  datetime: (col, value, onChange, readonly) => {
341
634
  const validations = col.validations || {};
342
635
  const ui = col.ui || {};
343
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
636
+ const label = ui.label || formatColumnLabel(col.name);
344
637
  const placeholder = ui.placeholder || '';
345
638
  const hint = ui.hint || '';
346
639
  const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
@@ -368,7 +661,7 @@ const FieldRenderers = {
368
661
  // Enum select
369
662
  enum: (col, value, onChange, readonly) => {
370
663
  const ui = col.ui || {};
371
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
664
+ const label = ui.label || formatColumnLabel(col.name);
372
665
  const hint = ui.hint || '';
373
666
  const options = col.enumValues || [];
374
667
 
@@ -393,7 +686,7 @@ const FieldRenderers = {
393
686
  // JSON textarea
394
687
  json: (col, value, onChange, readonly) => {
395
688
  const ui = col.ui || {};
396
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
689
+ const label = ui.label || formatColumnLabel(col.name);
397
690
  const placeholder = ui.placeholder || '';
398
691
  const hint = ui.hint || '';
399
692
  const rows = ui.rows || 6;
@@ -427,7 +720,7 @@ const FieldRenderers = {
427
720
  array: (col, value, onChange, readonly) => {
428
721
  const validations = col.validations || {};
429
722
  const ui = col.ui || {};
430
- const label = ui.label || col.name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
723
+ const label = ui.label || formatColumnLabel(col.name);
431
724
  const placeholder = ui.placeholder || 'Comma-separated values';
432
725
  const hint = ui.hint || 'Enter comma-separated values';
433
726
  const arrayValue = Array.isArray(value) ? value.join(', ') : (value || '');
@@ -456,8 +749,141 @@ const FieldRenderers = {
456
749
  },
457
750
  };
458
751
 
752
+ // Rich Text Field Renderer Component
753
+ const RichTextField = {
754
+ oncreate: (vnode) => {
755
+ const { name, value = '', onchange, readonly = false } = vnode.attrs;
756
+
757
+ // Load Quill if not already loaded
758
+ if (typeof window.Quill === 'undefined') {
759
+ const link = document.createElement('link');
760
+ link.rel = 'stylesheet';
761
+ link.href = 'https://cdn.quilljs.com/1.3.6/quill.snow.css';
762
+ document.head.appendChild(link);
763
+
764
+ const script = document.createElement('script');
765
+ script.src = 'https://cdn.quilljs.com/1.3.6/quill.js';
766
+ script.onload = () => {
767
+ initEditor(vnode);
768
+ };
769
+ document.head.appendChild(script);
770
+ } else {
771
+ initEditor(vnode);
772
+ }
773
+
774
+ function initEditor(vnode) {
775
+ const editorId = 'quill-editor-' + name;
776
+ const editorEl = document.getElementById(editorId);
777
+ const hiddenInput = document.getElementById(name + '-value');
778
+ const isReadonly = readonly || false;
779
+
780
+ if (editorEl && !editorEl._quill) {
781
+ const quill = new window.Quill(editorEl, {
782
+ theme: 'snow',
783
+ readOnly: isReadonly,
784
+ modules: {
785
+ toolbar: isReadonly ? false : [
786
+ [{ 'header': [1, 2, 3, false] }],
787
+ ['bold', 'italic', 'underline', 'strike'],
788
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
789
+ ['link', 'image'],
790
+ ['clean']
791
+ ]
792
+ }
793
+ });
794
+
795
+ // Set initial content
796
+ if (value) {
797
+ quill.root.innerHTML = value;
798
+ if (hiddenInput) {
799
+ hiddenInput.value = value;
800
+ }
801
+ }
802
+
803
+ // Handle content changes
804
+ if (!isReadonly) {
805
+ quill.on('text-change', () => {
806
+ const content = quill.root.innerHTML;
807
+ if (hiddenInput) {
808
+ hiddenInput.value = content;
809
+ }
810
+ if (onchange) {
811
+ onchange(content);
812
+ }
813
+ });
814
+ }
815
+
816
+ editorEl._quill = quill;
817
+ }
818
+ }
819
+ },
820
+
821
+ onupdate: (vnode) => {
822
+ // Update editor content if value changed externally
823
+ const { name, value = '', readonly = false } = vnode.attrs;
824
+ const editorId = 'quill-editor-' + name;
825
+ const editorEl = document.getElementById(editorId);
826
+ const hiddenInput = document.getElementById(name + '-value');
827
+
828
+ if (editorEl && editorEl._quill) {
829
+ const currentContent = editorEl._quill.root.innerHTML;
830
+ const newValue = value || '';
831
+ if (currentContent !== newValue) {
832
+ editorEl._quill.root.innerHTML = newValue;
833
+ if (hiddenInput) {
834
+ hiddenInput.value = newValue;
835
+ }
836
+ }
837
+ }
838
+ },
839
+
840
+ view: (vnode) => {
841
+ const { name, col, value = '', onChange, readonly } = vnode.attrs;
842
+ const ui = col.ui || {};
843
+ const label = ui.label || formatColumnLabel(col.name);
844
+ const hint = ui.hint || '';
845
+ const editorId = 'quill-editor-' + name;
846
+ const required = !col.nullable && !readonly;
847
+
848
+ return m('.mb-4', [
849
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', { for: name },
850
+ label,
851
+ required ? m('span.text-red-500', ' *') : null
852
+ ),
853
+ m('div.border.border-gray-300.rounded', {
854
+ id: editorId,
855
+ class: readonly ? 'bg-gray-100 opacity-50' : '',
856
+ style: 'min-height: 200px;'
857
+ }),
858
+ m('input[type=hidden]', {
859
+ name,
860
+ id: name + '-value',
861
+ value: value || '',
862
+ }),
863
+ hint ? m('p.text-xs.text-gray-500.mt-1', hint) : null,
864
+ ]);
865
+ }
866
+ };
867
+
459
868
  // Get appropriate renderer for a column type
460
- function getFieldRenderer(type) {
869
+ function getFieldRenderer(col, modelMeta) {
870
+ // Check for custom field first
871
+ if (col.customField && col.customField.type) {
872
+ if (col.customField.type === 'rich-text') {
873
+ return (col, value, onChange, readonly) => {
874
+ return m(RichTextField, {
875
+ name: col.name,
876
+ col,
877
+ value: value || '',
878
+ onChange,
879
+ readonly: readonly || false,
880
+ });
881
+ };
882
+ }
883
+ // Add other custom field types here if needed
884
+ }
885
+
886
+ // Fallback to standard type renderers
461
887
  const typeMap = {
462
888
  string: 'string',
463
889
  text: 'text',
@@ -474,7 +900,7 @@ function getFieldRenderer(type) {
474
900
  array: 'array',
475
901
  uuid: 'string',
476
902
  };
477
- return FieldRenderers[typeMap[type] || 'string'];
903
+ return FieldRenderers[typeMap[col.type] || 'string'];
478
904
  }
479
905
 
480
906
  // Check if a column is auto-generated (readonly)
@@ -488,6 +914,15 @@ function isAutoColumn(col) {
488
914
  return false;
489
915
  }
490
916
 
917
+ // Check if rich-text content is empty
918
+ function isRichTextEmpty(value) {
919
+ if (!value) return true;
920
+ // Remove all HTML tags and check if only whitespace remains
921
+ const stripped = value.replace(/<[^>]*>/g, '').trim();
922
+ // Check for common empty Quill outputs
923
+ return stripped === '' || value === '<p><br></p>' || value === '<p></p>';
924
+ }
925
+
491
926
  // Login Form Component
492
927
  const LoginForm = {
493
928
  view: () => m('.max-w-md.mx-auto.mt-16', [
@@ -750,13 +1185,116 @@ function getDisplayColumns(columns) {
750
1185
  .slice(0, 6); // Max 6 columns for readability
751
1186
  }
752
1187
 
753
- // Load records with pagination
754
- function loadRecords(modelName, page = 1) {
1188
+ // Build query string from filters
1189
+ function buildFilterQuery(filters) {
1190
+ if (!filters || Object.keys(filters).length === 0) return '';
1191
+
1192
+ const params = [];
1193
+ for (const [col, filter] of Object.entries(filters)) {
1194
+ if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1195
+
1196
+ if (filter.op === 'between') {
1197
+ params.push('filter[' + col + '][op]=between');
1198
+ if (filter.from) params.push('filter[' + col + '][from]=' + encodeURIComponent(filter.from));
1199
+ if (filter.to) params.push('filter[' + col + '][to]=' + encodeURIComponent(filter.to));
1200
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1201
+ params.push('filter[' + col + '][op]=in');
1202
+ filter.value.forEach(v => params.push('filter[' + col + '][value]=' + encodeURIComponent(v)));
1203
+ } else {
1204
+ if (filter.op) params.push('filter[' + col + '][op]=' + encodeURIComponent(filter.op));
1205
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1206
+ params.push('filter[' + col + '][value]=' + encodeURIComponent(filter.value));
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ return params.length > 0 ? '&' + params.join('&') : '';
1212
+ }
1213
+
1214
+ // Parse query string to filters
1215
+ function parseFilterQuery(queryString) {
1216
+ const filters = {};
1217
+ if (!queryString) return filters;
1218
+
1219
+ const params = new URLSearchParams(queryString);
1220
+ const filterParams = {};
1221
+
1222
+ // Group filter parameters using simple string parsing (no regex needed)
1223
+ for (const [key, value] of params.entries()) {
1224
+ // Parse filter[column][prop] format
1225
+ if (key.startsWith('filter[')) {
1226
+ const firstClose = key.indexOf(']');
1227
+ const secondOpen = key.indexOf('[', firstClose);
1228
+ const secondClose = key.indexOf(']', secondOpen);
1229
+
1230
+ if (firstClose > 7 && secondOpen > firstClose && secondClose > secondOpen) {
1231
+ const col = key.substring(7, firstClose);
1232
+ const prop = key.substring(secondOpen + 1, secondClose);
1233
+
1234
+ if (!filterParams[col]) filterParams[col] = {};
1235
+ if (prop === 'value' && filterParams[col].value) {
1236
+ // Multiple values for 'in' operator
1237
+ if (!Array.isArray(filterParams[col].value)) {
1238
+ filterParams[col].value = [filterParams[col].value];
1239
+ }
1240
+ filterParams[col].value.push(value);
1241
+ } else {
1242
+ filterParams[col][prop] = value;
1243
+ }
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ // Convert to filter format
1249
+ for (const [col, data] of Object.entries(filterParams)) {
1250
+ if (data.op === 'between') {
1251
+ filters[col] = { op: 'between', from: data.from || '', to: data.to || '' };
1252
+ } else if (data.op === 'in') {
1253
+ filters[col] = { op: 'in', value: Array.isArray(data.value) ? data.value : [data.value] };
1254
+ } else {
1255
+ filters[col] = { op: data.op || 'contains', value: data.value || '' };
1256
+ }
1257
+ }
1258
+
1259
+ return filters;
1260
+ }
1261
+
1262
+ // Load records with pagination and filters
1263
+ function loadRecords(modelName, page = 1, filters = null) {
755
1264
  state.loading = true;
756
1265
  state.error = null;
757
1266
 
758
1267
  const perPage = state.pagination.perPage || 20;
759
- api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage)
1268
+ const activeFilters = filters !== null ? filters : state.filters;
1269
+ const filterQuery = buildFilterQuery(activeFilters);
1270
+
1271
+ // Update URL with filters
1272
+ const queryParams = new URLSearchParams();
1273
+ queryParams.set('page', page);
1274
+ if (Object.keys(activeFilters).length > 0) {
1275
+ for (const [col, filter] of Object.entries(activeFilters)) {
1276
+ if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1277
+ if (filter.op === 'between') {
1278
+ queryParams.set('filter[' + col + '][op]', 'between');
1279
+ if (filter.from) queryParams.set('filter[' + col + '][from]', filter.from);
1280
+ if (filter.to) queryParams.set('filter[' + col + '][to]', filter.to);
1281
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1282
+ queryParams.set('filter[' + col + '][op]', 'in');
1283
+ filter.value.forEach(v => queryParams.append('filter[' + col + '][value]', v));
1284
+ } else {
1285
+ if (filter.op) queryParams.set('filter[' + col + '][op]', filter.op);
1286
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1287
+ queryParams.set('filter[' + col + '][value]', filter.value);
1288
+ }
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ // Update browser URL without reload
1294
+ const newUrl = window.location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
1295
+ window.history.replaceState({}, '', newUrl);
1296
+
1297
+ api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + filterQuery)
760
1298
  .then(result => {
761
1299
  state.records = result.data || [];
762
1300
  state.pagination = {
@@ -782,6 +1320,12 @@ const RecordList = {
782
1320
  state.records = [];
783
1321
  state.currentModelMeta = null;
784
1322
  state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1323
+ state.filterPanelOpen = false;
1324
+
1325
+ // Parse filters from URL query string
1326
+ const urlParams = new URLSearchParams(window.location.search);
1327
+ const page = parseInt(urlParams.get('page')) || 1;
1328
+ state.filters = parseFilterQuery(window.location.search);
785
1329
 
786
1330
  // Load model metadata first, then records
787
1331
  state.loading = true;
@@ -789,7 +1333,7 @@ const RecordList = {
789
1333
  .then(modelMeta => {
790
1334
  state.currentModelMeta = modelMeta;
791
1335
  state.currentModel = modelMeta;
792
- return loadRecords(modelName, 1);
1336
+ return loadRecords(modelName, page, state.filters);
793
1337
  })
794
1338
  .catch(err => {
795
1339
  state.error = err.message;
@@ -810,14 +1354,68 @@ const RecordList = {
810
1354
  return m(Layout, { breadcrumbs }, [
811
1355
  m('.flex.items-center.justify-between.mb-6', [
812
1356
  m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
813
- m('button.bg-blue-600.text-white.px-4.py-2.rounded.hover:bg-blue-700', {
814
- onclick: () => {
815
- state.currentRecord = null;
816
- state.editing = true;
817
- m.route.set('/models/' + modelName + '/new');
818
- }
819
- }, 'New Record'),
1357
+ m('.flex.items-center.gap-2', [
1358
+ m('button.bg-gray-200.text-gray-800.px-4.py-2.rounded.hover:bg-gray-300', {
1359
+ onclick: () => {
1360
+ state.filterPanelOpen = !state.filterPanelOpen;
1361
+ m.redraw();
1362
+ }
1363
+ }, [
1364
+ m('span.mr-2', '🔍'),
1365
+ 'Filter',
1366
+ ]),
1367
+ m('button.bg-blue-600.text-white.px-4.py-2.rounded.hover:bg-blue-700', {
1368
+ onclick: () => {
1369
+ state.currentRecord = null;
1370
+ state.editing = true;
1371
+ m.route.set('/models/' + modelName + '/new');
1372
+ }
1373
+ }, 'New Record'),
1374
+ ]),
820
1375
  ]),
1376
+
1377
+ // Filter badges
1378
+ m(FilterBadges, {
1379
+ filters: state.filters,
1380
+ modelMeta: modelMeta,
1381
+ onRemove: (colName) => {
1382
+ const newFilters = { ...state.filters };
1383
+ delete newFilters[colName];
1384
+ state.filters = newFilters;
1385
+ loadRecords(modelName, 1, newFilters);
1386
+ },
1387
+ }),
1388
+
1389
+ // Filter panel
1390
+ m(FilterPanel, {
1391
+ isOpen: state.filterPanelOpen,
1392
+ modelMeta: modelMeta,
1393
+ filters: state.filters,
1394
+ onToggle: (open) => {
1395
+ state.filterPanelOpen = open;
1396
+ m.redraw();
1397
+ },
1398
+ onFilterChange: (colName, filter) => {
1399
+ const newFilters = { ...state.filters };
1400
+ if (filter === null) {
1401
+ delete newFilters[colName];
1402
+ } else {
1403
+ newFilters[colName] = filter;
1404
+ }
1405
+ state.filters = newFilters;
1406
+ m.redraw();
1407
+ },
1408
+ onApply: () => {
1409
+ state.filterPanelOpen = false;
1410
+ loadRecords(modelName, 1, state.filters);
1411
+ },
1412
+ onClear: () => {
1413
+ state.filters = {};
1414
+ state.filterPanelOpen = false;
1415
+ loadRecords(modelName, 1, {});
1416
+ },
1417
+ }),
1418
+
821
1419
  state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
822
1420
  state.loading
823
1421
  ? m('p.text-gray-600', 'Loading records...')
@@ -963,6 +1561,21 @@ const RecordForm = {
963
1561
  state.loading = true;
964
1562
  state.error = null;
965
1563
  try {
1564
+ // Validate rich-text fields first
1565
+ if (modelMeta && modelMeta.columns) {
1566
+ for (const col of modelMeta.columns) {
1567
+ if (col.customField && col.customField.type === 'rich-text' && !col.nullable) {
1568
+ const hiddenInput = document.getElementById(col.name + '-value');
1569
+ const value = hiddenInput ? hiddenInput.value : state.formData[col.name];
1570
+ if (isRichTextEmpty(value)) {
1571
+ state.error = (col.ui?.label || col.name) + ' is required';
1572
+ state.loading = false;
1573
+ return;
1574
+ }
1575
+ }
1576
+ }
1577
+ }
1578
+
966
1579
  // Build payload, excluding auto-generated fields
967
1580
  const payload = {};
968
1581
  if (modelMeta && modelMeta.columns) {
@@ -971,9 +1584,26 @@ const RecordForm = {
971
1584
  // Skip primary key and auto timestamps in payload
972
1585
  if (autoType === 'primary' || autoType === 'auto') return;
973
1586
 
974
- const value = state.formData[col.name];
975
- if (value !== undefined) {
1587
+ // For rich-text fields, get value from hidden input
1588
+ let value = state.formData[col.name];
1589
+ if (col.customField && col.customField.type === 'rich-text') {
1590
+ const hiddenInput = document.getElementById(col.name + '-value');
1591
+ if (hiddenInput) {
1592
+ value = hiddenInput.value;
1593
+ }
1594
+ // Skip empty rich-text values (normalize to null if nullable)
1595
+ if (isRichTextEmpty(value)) {
1596
+ if (col.nullable) {
1597
+ payload[col.name] = null;
1598
+ }
1599
+ return;
1600
+ }
1601
+ }
1602
+
1603
+ if (value !== undefined && value !== null && value !== '') {
976
1604
  payload[col.name] = value;
1605
+ } else if (value === null && col.nullable) {
1606
+ payload[col.name] = null;
977
1607
  }
978
1608
  });
979
1609
  }
@@ -1002,8 +1632,11 @@ const RecordForm = {
1002
1632
  // Hide primary key in new mode
1003
1633
  if (autoType === 'primary' && isNew) return null;
1004
1634
 
1005
- const isReadonly = !!autoType;
1006
- const renderer = getFieldRenderer(col.type);
1635
+ // Hide hidden fields
1636
+ if (col.ui && col.ui.hidden) return null;
1637
+
1638
+ const isReadonly = !!autoType || (col.ui && col.ui.readonly);
1639
+ const renderer = getFieldRenderer(col, modelMeta);
1007
1640
  const value = state.formData[col.name];
1008
1641
  const onChange = (newValue) => {
1009
1642
  state.formData[col.name] = newValue;
@@ -28,6 +28,8 @@ module.exports = {
28
28
  function initEditor(vnode) {
29
29
  const editorId = 'quill-editor-' + name;
30
30
  const editorEl = document.getElementById(editorId);
31
+ const hiddenInput = document.getElementById(name + '-value');
32
+
31
33
  if (editorEl && !editorEl._quill) {
32
34
  const quill = new window.Quill(editorEl, {
33
35
  theme: 'snow',
@@ -45,28 +47,56 @@ module.exports = {
45
47
  // Set initial content
46
48
  if (value) {
47
49
  quill.root.innerHTML = value;
50
+ if (hiddenInput) {
51
+ hiddenInput.value = value;
52
+ }
48
53
  }
49
54
 
50
55
  // Handle content changes
51
56
  quill.on('text-change', () => {
52
57
  const content = quill.root.innerHTML;
58
+ if (hiddenInput) {
59
+ hiddenInput.value = content;
60
+ }
53
61
  if (onchange) {
54
62
  onchange(content);
55
63
  }
56
64
  });
57
65
 
58
66
  editorEl._quill = quill;
67
+ vnode.state.quill = quill;
68
+ }
69
+ }
70
+ },
71
+
72
+ onupdate: (vnode) => {
73
+ // Update editor content if value changed externally
74
+ const { name, value = '' } = vnode.attrs;
75
+ const editorId = 'quill-editor-' + name;
76
+ const editorEl = document.getElementById(editorId);
77
+ const hiddenInput = document.getElementById(name + '-value');
78
+
79
+ if (editorEl && editorEl._quill && vnode.state.quill) {
80
+ const currentContent = editorEl._quill.root.innerHTML;
81
+ if (currentContent !== value) {
82
+ editorEl._quill.root.innerHTML = value || '';
83
+ if (hiddenInput) {
84
+ hiddenInput.value = value || '';
85
+ }
59
86
  }
60
87
  }
61
88
  },
62
89
 
63
90
  view: (vnode) => {
64
- const { name, meta = {}, required = false } = vnode.attrs;
91
+ const { name, meta = {}, required = false, value = '' } = vnode.attrs;
92
+ const ui = meta.ui || {};
93
+ const label = ui.label || meta.label || name;
94
+ const hint = ui.hint || '';
65
95
  const editorId = 'quill-editor-' + name;
66
96
 
67
97
  return m('.mb-4', [
68
- m('label.block.text-sm.font-medium.mb-2',
69
- meta.label || name,
98
+ m('label.block.text-sm.font-medium.text-gray-700.mb-1', { for: name },
99
+ label,
70
100
  required ? m('span.text-red-500', ' *') : null
71
101
  ),
72
102
  m('div.border.border-gray-300.rounded', {
@@ -76,7 +106,9 @@ module.exports = {
76
106
  m('input[type=hidden]', {
77
107
  name,
78
108
  id: name + '-value',
109
+ value: value || '',
79
110
  }),
111
+ hint ? m('p.text-xs.text-gray-500.mt-1', hint) : null,
80
112
  ]);
81
113
  }
82
114
  },