millas 0.2.4 → 0.2.6
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 +1 -1
- package/src/admin/Admin.js +241 -62
- package/src/admin/AdminAuth.js +281 -0
- package/src/admin/index.js +6 -1
- package/src/admin/resources/AdminResource.js +180 -29
- package/src/admin/views/layouts/base.njk +38 -1
- package/src/admin/views/pages/detail.njk +322 -0
- package/src/admin/views/pages/form.njk +571 -125
- package/src/admin/views/pages/list.njk +454 -0
- package/src/admin/views/pages/login.njk +354 -0
package/package.json
CHANGED
package/src/admin/Admin.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const nunjucks = require('nunjucks');
|
|
5
5
|
const ActivityLog = require('./ActivityLog');
|
|
6
|
-
const
|
|
6
|
+
const AdminAuth = require('./AdminAuth');
|
|
7
|
+
const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Admin
|
|
@@ -36,6 +37,12 @@ class Admin {
|
|
|
36
37
|
|
|
37
38
|
configure(config = {}) {
|
|
38
39
|
Object.assign(this._config, config);
|
|
40
|
+
|
|
41
|
+
// Initialise auth if configured
|
|
42
|
+
if (config.auth !== undefined) {
|
|
43
|
+
AdminAuth.configure(config.auth);
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
return this;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -72,6 +79,14 @@ class Admin {
|
|
|
72
79
|
const prefix = this._config.prefix;
|
|
73
80
|
this._njk = this._setupNunjucks(expressApp);
|
|
74
81
|
|
|
82
|
+
// ── Auth middleware (runs before all admin routes) ──────────
|
|
83
|
+
expressApp.use(prefix, AdminAuth.middleware(prefix));
|
|
84
|
+
|
|
85
|
+
// ── Login / logout ──────────────────────────────────────────
|
|
86
|
+
expressApp.get (`${prefix}/login`, (q, s) => this._loginPage(q, s));
|
|
87
|
+
expressApp.post(`${prefix}/login`, (q, s) => this._loginSubmit(q, s));
|
|
88
|
+
expressApp.get (`${prefix}/logout`, (q, s) => this._logout(q, s));
|
|
89
|
+
|
|
75
90
|
// Dashboard
|
|
76
91
|
expressApp.get(`${prefix}`, (q, s) => this._dashboard(q, s));
|
|
77
92
|
expressApp.get(`${prefix}/`, (q, s) => this._dashboard(q, s));
|
|
@@ -89,6 +104,8 @@ class Admin {
|
|
|
89
104
|
expressApp.post (`${prefix}/:resource/:id`, (q, s) => this._update(q, s));
|
|
90
105
|
expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => this._destroy(q, s));
|
|
91
106
|
expressApp.post (`${prefix}/:resource/bulk-delete`, (q, s) => this._bulkDestroy(q, s));
|
|
107
|
+
expressApp.post (`${prefix}/:resource/bulk-action`, (q, s) => this._bulkAction(q, s));
|
|
108
|
+
expressApp.post (`${prefix}/:resource/:id/action/:action`,(q, s) => this._rowAction(q, s));
|
|
92
109
|
|
|
93
110
|
return this;
|
|
94
111
|
}
|
|
@@ -216,6 +233,8 @@ class Admin {
|
|
|
216
233
|
return {
|
|
217
234
|
adminPrefix: this._config.prefix,
|
|
218
235
|
adminTitle: this._config.title,
|
|
236
|
+
adminUser: req.adminUser || null,
|
|
237
|
+
authEnabled: AdminAuth.enabled,
|
|
219
238
|
resources: this.resources().map((r, idx) => ({
|
|
220
239
|
slug: r.slug,
|
|
221
240
|
label: r._getLabel(),
|
|
@@ -224,13 +243,72 @@ class Admin {
|
|
|
224
243
|
canView: r.canView,
|
|
225
244
|
index: idx + 1,
|
|
226
245
|
})),
|
|
227
|
-
flash:
|
|
246
|
+
flash: extra._flash || {},
|
|
228
247
|
activePage: extra.activePage || null,
|
|
229
248
|
activeResource: extra.activeResource || null,
|
|
230
249
|
...extra,
|
|
231
250
|
};
|
|
232
251
|
}
|
|
233
252
|
|
|
253
|
+
_ctxWithFlash(req, res, extra = {}) {
|
|
254
|
+
return this._ctx(req, { ...extra, _flash: AdminAuth.getFlash(req, res) });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Auth pages ───────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
async _loginPage(req, res) {
|
|
260
|
+
// Already logged in → redirect to dashboard
|
|
261
|
+
if (AdminAuth.enabled) {
|
|
262
|
+
const cookies = req.headers.cookie || '';
|
|
263
|
+
if (cookies.includes(this._config.auth?.cookieName || 'millas_admin')) {
|
|
264
|
+
// Let AdminAuth verify properly
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const flash = AdminAuth.getFlash(req, res);
|
|
269
|
+
res.render('pages/login.njk', {
|
|
270
|
+
adminTitle: this._config.title,
|
|
271
|
+
adminPrefix: this._config.prefix,
|
|
272
|
+
flash,
|
|
273
|
+
next: req.query.next || '',
|
|
274
|
+
error: null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async _loginSubmit(req, res) {
|
|
279
|
+
const { email, password, remember, next } = req.body;
|
|
280
|
+
const prefix = this._config.prefix;
|
|
281
|
+
|
|
282
|
+
if (!AdminAuth.enabled) {
|
|
283
|
+
return res.redirect(next || prefix + '/');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
await AdminAuth.login(req, res, {
|
|
288
|
+
email,
|
|
289
|
+
password,
|
|
290
|
+
remember: remember === 'on' || remember === '1' || remember === 'true',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
res.redirect(next || prefix + '/');
|
|
294
|
+
} catch (err) {
|
|
295
|
+
res.render('pages/login.njk', {
|
|
296
|
+
adminTitle: this._config.title,
|
|
297
|
+
adminPrefix: prefix,
|
|
298
|
+
flash: {},
|
|
299
|
+
next: next || '',
|
|
300
|
+
error: err.message,
|
|
301
|
+
email, // re-fill email field
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_logout(req, res) {
|
|
307
|
+
AdminAuth.logout(res);
|
|
308
|
+
AdminAuth.setFlash(res, 'success', 'You have been logged out.');
|
|
309
|
+
res.redirect(`${this._config.prefix}/login`);
|
|
310
|
+
}
|
|
311
|
+
|
|
234
312
|
// ─── Pages ────────────────────────────────────────────────────────────────
|
|
235
313
|
|
|
236
314
|
async _dashboard(req, res) {
|
|
@@ -269,7 +347,7 @@ class Admin {
|
|
|
269
347
|
const activityData = ActivityLog.recent(25);
|
|
270
348
|
const activityTotals = ActivityLog.totals();
|
|
271
349
|
|
|
272
|
-
res.render('pages/dashboard.njk', this.
|
|
350
|
+
res.render('pages/dashboard.njk', this._ctxWithFlash(req, res, {
|
|
273
351
|
pageTitle: 'Dashboard',
|
|
274
352
|
activePage: 'dashboard',
|
|
275
353
|
resources: resourceData,
|
|
@@ -286,10 +364,13 @@ class Admin {
|
|
|
286
364
|
const R = this._resolve(req.params.resource, res);
|
|
287
365
|
if (!R) return;
|
|
288
366
|
|
|
289
|
-
const page
|
|
290
|
-
const search
|
|
291
|
-
const sort
|
|
292
|
-
const order
|
|
367
|
+
const page = Number(req.query.page) || 1;
|
|
368
|
+
const search = req.query.search || '';
|
|
369
|
+
const sort = req.query.sort || 'id';
|
|
370
|
+
const order = req.query.order || 'desc';
|
|
371
|
+
const perPage = Number(req.query.perPage) || R.perPage;
|
|
372
|
+
const year = req.query.year || null;
|
|
373
|
+
const month = req.query.month || null;
|
|
293
374
|
|
|
294
375
|
// Collect active filters
|
|
295
376
|
const activeFilters = {};
|
|
@@ -299,25 +380,30 @@ class Admin {
|
|
|
299
380
|
}
|
|
300
381
|
}
|
|
301
382
|
|
|
302
|
-
const result = await R.fetchList({ page, search, sort, order, filters: activeFilters });
|
|
383
|
+
const result = await R.fetchList({ page, search, sort, order, perPage, filters: activeFilters, year, month });
|
|
303
384
|
const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
|
|
304
385
|
|
|
305
386
|
const listFields = R.fields()
|
|
306
387
|
.filter(f => !f._hidden && !f._detailOnly)
|
|
307
388
|
.map(f => f.toJSON());
|
|
308
389
|
|
|
309
|
-
res.render('pages/list.njk', this.
|
|
390
|
+
res.render('pages/list.njk', this._ctxWithFlash(req, res, {
|
|
310
391
|
pageTitle: R._getLabel(),
|
|
311
392
|
activeResource: req.params.resource,
|
|
312
393
|
resource: {
|
|
313
|
-
slug:
|
|
314
|
-
label:
|
|
315
|
-
singular:
|
|
316
|
-
icon:
|
|
317
|
-
canCreate:
|
|
318
|
-
canEdit:
|
|
319
|
-
canDelete:
|
|
320
|
-
canView:
|
|
394
|
+
slug: R.slug,
|
|
395
|
+
label: R._getLabel(),
|
|
396
|
+
singular: R._getLabelSingular(),
|
|
397
|
+
icon: R.icon,
|
|
398
|
+
canCreate: R.canCreate,
|
|
399
|
+
canEdit: R.canEdit,
|
|
400
|
+
canDelete: R.canDelete,
|
|
401
|
+
canView: R.canView,
|
|
402
|
+
actions: (R.actions || []).map((a, i) => ({ ...a, index: i, handler: undefined })),
|
|
403
|
+
rowActions: R.rowActions || [],
|
|
404
|
+
listDisplayLinks: R.listDisplayLinks || [],
|
|
405
|
+
dateHierarchy: R.dateHierarchy || null,
|
|
406
|
+
prepopulatedFields: R.prepopulatedFields || {},
|
|
321
407
|
},
|
|
322
408
|
rows,
|
|
323
409
|
listFields,
|
|
@@ -331,6 +417,8 @@ class Admin {
|
|
|
331
417
|
search,
|
|
332
418
|
sort,
|
|
333
419
|
order,
|
|
420
|
+
year,
|
|
421
|
+
month,
|
|
334
422
|
}));
|
|
335
423
|
} catch (err) {
|
|
336
424
|
this._error(res, err);
|
|
@@ -343,7 +431,7 @@ class Admin {
|
|
|
343
431
|
if (!R) return;
|
|
344
432
|
if (!R.canCreate) return res.status(403).send('Not allowed');
|
|
345
433
|
|
|
346
|
-
res.render('pages/form.njk', this.
|
|
434
|
+
res.render('pages/form.njk', this._ctxWithFlash(req, res, {
|
|
347
435
|
pageTitle: `New ${R._getLabelSingular()}`,
|
|
348
436
|
activeResource: req.params.resource,
|
|
349
437
|
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
|
|
@@ -364,14 +452,25 @@ class Admin {
|
|
|
364
452
|
if (!R) return;
|
|
365
453
|
if (!R.canCreate) return res.status(403).send('Not allowed');
|
|
366
454
|
|
|
367
|
-
await R.create(req.body);
|
|
368
|
-
ActivityLog.record('create', R.slug,
|
|
455
|
+
const record = await R.create(req.body);
|
|
456
|
+
ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`);
|
|
457
|
+
|
|
458
|
+
const submit = req.body._submit || 'save';
|
|
459
|
+
if (submit === 'continue' && record?.id) {
|
|
460
|
+
AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. You may continue editing.`);
|
|
461
|
+
return res.redirect(`${this._config.prefix}/${R.slug}/${record.id}/edit`);
|
|
462
|
+
}
|
|
463
|
+
if (submit === 'add_another') {
|
|
464
|
+
AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. Add another below.`);
|
|
465
|
+
return res.redirect(`${this._config.prefix}/${R.slug}/create`);
|
|
466
|
+
}
|
|
467
|
+
|
|
369
468
|
this._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
|
|
370
469
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
371
470
|
} catch (err) {
|
|
372
471
|
if (err.status === 422) {
|
|
373
472
|
const R = this._resources.get(req.params.resource);
|
|
374
|
-
return res.render('pages/form.njk', this.
|
|
473
|
+
return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
|
|
375
474
|
pageTitle: `New ${R._getLabelSingular()}`,
|
|
376
475
|
activeResource: req.params.resource,
|
|
377
476
|
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
|
|
@@ -395,7 +494,7 @@ class Admin {
|
|
|
395
494
|
const record = await R.fetchOne(req.params.id);
|
|
396
495
|
const data = record.toJSON ? record.toJSON() : record;
|
|
397
496
|
|
|
398
|
-
res.render('pages/form.njk', this.
|
|
497
|
+
res.render('pages/form.njk', this._ctxWithFlash(req, res, {
|
|
399
498
|
pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
|
|
400
499
|
activeResource: req.params.resource,
|
|
401
500
|
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
|
|
@@ -421,13 +520,20 @@ class Admin {
|
|
|
421
520
|
if (method === 'PUT' || method === 'POST') {
|
|
422
521
|
await R.update(req.params.id, req.body);
|
|
423
522
|
ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
|
|
523
|
+
|
|
524
|
+
const submit = req.body._submit || 'save';
|
|
525
|
+
if (submit === 'continue') {
|
|
526
|
+
AdminAuth.setFlash(res, 'success', 'Changes saved. You may continue editing.');
|
|
527
|
+
return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
|
|
528
|
+
}
|
|
529
|
+
|
|
424
530
|
this._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
|
|
425
531
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
426
532
|
}
|
|
427
533
|
} catch (err) {
|
|
428
534
|
if (err.status === 422) {
|
|
429
535
|
const R = this._resources.get(req.params.resource);
|
|
430
|
-
return res.render('pages/form.njk', this.
|
|
536
|
+
return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
|
|
431
537
|
pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
|
|
432
538
|
activeResource: req.params.resource,
|
|
433
539
|
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
|
|
@@ -471,29 +577,38 @@ class Admin {
|
|
|
471
577
|
const record = await R.fetchOne(req.params.id);
|
|
472
578
|
const data = record.toJSON ? record.toJSON() : record;
|
|
473
579
|
|
|
474
|
-
// Build detail field groups (all non-hidden fields, grouped by tab)
|
|
475
580
|
const detailFields = R.fields()
|
|
476
|
-
.filter(f => f._type !== '__tab__' && !f._hidden && !f._listOnly)
|
|
581
|
+
.filter(f => f._type !== '__tab__' && f._type !== 'fieldset' && !f._hidden && !f._listOnly)
|
|
477
582
|
.map(f => f.toJSON());
|
|
478
583
|
|
|
479
584
|
const tabs = this._buildTabs(R.fields());
|
|
480
585
|
|
|
481
|
-
|
|
586
|
+
// Load inline related records
|
|
587
|
+
const inlineData = await Promise.all(
|
|
588
|
+
(R.inlines || []).map(async (inline) => {
|
|
589
|
+
const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
|
|
590
|
+
return { ...inline.toJSON(), rows };
|
|
591
|
+
})
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
res.render('pages/detail.njk', this._ctxWithFlash(req, res, {
|
|
482
595
|
pageTitle: `${R._getLabelSingular()} #${req.params.id}`,
|
|
483
596
|
activeResource: req.params.resource,
|
|
484
597
|
resource: {
|
|
485
|
-
slug:
|
|
486
|
-
label:
|
|
487
|
-
singular:
|
|
488
|
-
icon:
|
|
489
|
-
canEdit:
|
|
490
|
-
canDelete:
|
|
491
|
-
canCreate:
|
|
598
|
+
slug: R.slug,
|
|
599
|
+
label: R._getLabel(),
|
|
600
|
+
singular: R._getLabelSingular(),
|
|
601
|
+
icon: R.icon,
|
|
602
|
+
canEdit: R.canEdit,
|
|
603
|
+
canDelete: R.canDelete,
|
|
604
|
+
canCreate: R.canCreate,
|
|
605
|
+
rowActions: R.rowActions || [],
|
|
492
606
|
},
|
|
493
|
-
record:
|
|
607
|
+
record: data,
|
|
494
608
|
detailFields,
|
|
495
609
|
tabs,
|
|
496
|
-
hasTabs:
|
|
610
|
+
hasTabs: tabs.length > 1,
|
|
611
|
+
inlines: inlineData,
|
|
497
612
|
}));
|
|
498
613
|
} catch (err) {
|
|
499
614
|
this._error(res, err);
|
|
@@ -523,14 +638,72 @@ class Admin {
|
|
|
523
638
|
}
|
|
524
639
|
}
|
|
525
640
|
|
|
526
|
-
// ───
|
|
641
|
+
// ─── Bulk custom action ───────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
async _bulkAction(req, res) {
|
|
644
|
+
try {
|
|
645
|
+
const R = this._resolve(req.params.resource, res);
|
|
646
|
+
if (!R) return;
|
|
647
|
+
|
|
648
|
+
const actionIndex = Number(req.body.actionIndex);
|
|
649
|
+
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
|
|
650
|
+
const action = (R.actions || [])[actionIndex];
|
|
651
|
+
|
|
652
|
+
if (!action) {
|
|
653
|
+
this._flash(req, 'error', 'Unknown action.');
|
|
654
|
+
return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!ids.length) {
|
|
658
|
+
this._flash(req, 'error', 'No records selected.');
|
|
659
|
+
return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await action.handler(ids, R.model);
|
|
663
|
+
ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`);
|
|
664
|
+
this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
|
|
665
|
+
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
666
|
+
} catch (err) {
|
|
667
|
+
this._error(res, err);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ─── Per-row custom action ────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
async _rowAction(req, res) {
|
|
674
|
+
try {
|
|
675
|
+
const R = this._resolve(req.params.resource, res);
|
|
676
|
+
if (!R) return;
|
|
677
|
+
|
|
678
|
+
const actionName = req.params.action;
|
|
679
|
+
const rowAction = (R.rowActions || []).find(a => a.action === actionName);
|
|
680
|
+
|
|
681
|
+
if (!rowAction || !rowAction.handler) {
|
|
682
|
+
return res.status(404).send(`Row action "${actionName}" not found.`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const record = await R.fetchOne(req.params.id);
|
|
686
|
+
const result = await rowAction.handler(record, R.model);
|
|
687
|
+
|
|
688
|
+
// If handler returns a redirect URL, use it; otherwise go back to list
|
|
689
|
+
const redirect = (typeof result === 'string' && result.startsWith('/'))
|
|
690
|
+
? result
|
|
691
|
+
: `${this._config.prefix}/${R.slug}`;
|
|
692
|
+
|
|
693
|
+
ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`);
|
|
694
|
+
this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
|
|
695
|
+
this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
|
|
696
|
+
} catch (err) {
|
|
697
|
+
this._error(res, err);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
527
700
|
|
|
528
701
|
async _search(req, res) {
|
|
529
702
|
try {
|
|
530
703
|
const q = (req.query.q || '').trim();
|
|
531
704
|
|
|
532
705
|
if (!q) {
|
|
533
|
-
return res.render('pages/search.njk', this.
|
|
706
|
+
return res.render('pages/search.njk', this._ctxWithFlash(req, res, {
|
|
534
707
|
pageTitle: 'Search',
|
|
535
708
|
activePage: 'search',
|
|
536
709
|
query: '',
|
|
@@ -564,7 +737,7 @@ class Admin {
|
|
|
564
737
|
const filtered = results.filter(Boolean);
|
|
565
738
|
const total = filtered.reduce((s, r) => s + r.total, 0);
|
|
566
739
|
|
|
567
|
-
res.render('pages/search.njk', this.
|
|
740
|
+
res.render('pages/search.njk', this._ctxWithFlash(req, res, {
|
|
568
741
|
pageTitle: `Search: ${q}`,
|
|
569
742
|
activePage: 'search',
|
|
570
743
|
query: q,
|
|
@@ -641,25 +814,32 @@ class Admin {
|
|
|
641
814
|
* them as text instead of inputs.
|
|
642
815
|
*/
|
|
643
816
|
_formFields(R) {
|
|
644
|
-
const readonlySet
|
|
645
|
-
|
|
646
|
-
|
|
817
|
+
const readonlySet = new Set(R.readonlyFields || []);
|
|
818
|
+
const prepopFields = R.prepopulatedFields || {};
|
|
819
|
+
let currentTab = null;
|
|
820
|
+
let currentFieldset = null;
|
|
821
|
+
const result = [];
|
|
647
822
|
|
|
648
823
|
for (const f of R.fields()) {
|
|
649
|
-
// Tab separator — update current tab context, don't add to form fields
|
|
650
824
|
if (f._type === 'tab') {
|
|
651
|
-
currentTab
|
|
825
|
+
currentTab = f._label;
|
|
826
|
+
currentFieldset = null;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (f._type === 'fieldset') {
|
|
830
|
+
// Include fieldset headers as sentinel objects
|
|
831
|
+
result.push({ _isFieldset: true, label: f._label, tab: currentTab });
|
|
832
|
+
currentFieldset = f._label;
|
|
652
833
|
continue;
|
|
653
834
|
}
|
|
654
|
-
|
|
655
|
-
// Skip id, list-only, and fully hidden fields
|
|
656
835
|
if (f._type === 'id' || f._listOnly || f._hidden) continue;
|
|
657
836
|
|
|
658
|
-
const json
|
|
659
|
-
json.tab
|
|
660
|
-
json.
|
|
837
|
+
const json = f.toJSON();
|
|
838
|
+
json.tab = currentTab;
|
|
839
|
+
json.fieldset = currentFieldset;
|
|
840
|
+
json.required = !f._nullable;
|
|
841
|
+
json.prepopulate = prepopFields[f._name] || f._prepopulate || null;
|
|
661
842
|
|
|
662
|
-
// Mark as readonly if in readonlyFields array or flagged on field itself
|
|
663
843
|
if (readonlySet.has(f._name) || f._readonly) {
|
|
664
844
|
json.isReadonly = true;
|
|
665
845
|
}
|
|
@@ -685,11 +865,14 @@ class Admin {
|
|
|
685
865
|
tabs.push(current);
|
|
686
866
|
continue;
|
|
687
867
|
}
|
|
688
|
-
if (f.
|
|
689
|
-
|
|
690
|
-
current = { label: null, fields: [] };
|
|
691
|
-
|
|
868
|
+
if (f._type === 'fieldset') {
|
|
869
|
+
// Fieldsets are embedded as sentinel entries within a tab's fields
|
|
870
|
+
if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
|
|
871
|
+
current.fields.push({ _isFieldset: true, label: f._label });
|
|
872
|
+
continue;
|
|
692
873
|
}
|
|
874
|
+
if (f._hidden || f._listOnly) continue;
|
|
875
|
+
if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
|
|
693
876
|
current.fields.push(f.toJSON());
|
|
694
877
|
}
|
|
695
878
|
|
|
@@ -726,26 +909,21 @@ class Admin {
|
|
|
726
909
|
`);
|
|
727
910
|
}
|
|
728
911
|
|
|
729
|
-
// ─── Flash
|
|
912
|
+
// ─── Flash (cookie-based) ─────────────────────────────────────────────────
|
|
913
|
+
|
|
730
914
|
_flash(req, type, message) {
|
|
731
|
-
// Store in a simple query param on redirect
|
|
732
915
|
req._flashType = type;
|
|
733
916
|
req._flashMessage = message;
|
|
734
917
|
}
|
|
735
918
|
|
|
736
919
|
_pullFlash(req) {
|
|
737
|
-
const type = req.query._flash_type;
|
|
738
|
-
const msg = req.query._flash_msg;
|
|
739
|
-
if (type && msg) return { [type]: decodeURIComponent(msg) };
|
|
740
|
-
// Check set by _flash helper (pre-redirect)
|
|
741
920
|
if (req._flashType) return { [req._flashType]: req._flashMessage };
|
|
742
921
|
return {};
|
|
743
922
|
}
|
|
744
923
|
|
|
745
|
-
// Override redirect to include flash in URL
|
|
746
924
|
_redirectWithFlash(res, url, type, message) {
|
|
747
|
-
|
|
748
|
-
res.redirect(
|
|
925
|
+
if (type && message) AdminAuth.setFlash(res, type, message);
|
|
926
|
+
res.redirect(url);
|
|
749
927
|
}
|
|
750
928
|
|
|
751
929
|
_autoResource(ModelClass) {
|
|
@@ -764,3 +942,4 @@ module.exports.Admin = Admin;
|
|
|
764
942
|
module.exports.AdminResource = AdminResource;
|
|
765
943
|
module.exports.AdminField = AdminField;
|
|
766
944
|
module.exports.AdminFilter = AdminFilter;
|
|
945
|
+
module.exports.AdminInline = AdminInline;
|