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
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
754
|
-
function
|
|
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
|
-
|
|
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,
|
|
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('
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
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-
|
|
69
|
-
|
|
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
|
},
|