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 +5 -2
- package/plugins/admin-panel/api.js +136 -4
- package/plugins/admin-panel/components.js +507 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
887
|
-
function
|
|
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
|
-
|
|
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,
|
|
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('
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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 !== '') {
|