webspresso 0.0.96 → 0.0.98
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/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
|
@@ -84,7 +84,27 @@ function formatCellValue(value, col, record = null) {
|
|
|
84
84
|
return String(value);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
|
|
88
|
+
// Badge formatting for status / enum / mapped values
|
|
89
|
+
const badgeMap = (col && col.badge) || (col && col.customField && col.customField.badge);
|
|
90
|
+
if (badgeMap && typeof badgeMap === 'object') {
|
|
91
|
+
const valStr = String(value);
|
|
92
|
+
const color = badgeMap[valStr] || badgeMap['*'] || 'gray';
|
|
93
|
+
const colorClasses = {
|
|
94
|
+
green: 'bg-green-100 text-green-800 dark:bg-green-950/60 dark:text-green-300 border border-green-200 dark:border-green-800',
|
|
95
|
+
red: 'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300 border border-red-200 dark:border-red-800',
|
|
96
|
+
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300 border border-blue-200 dark:border-blue-800',
|
|
97
|
+
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
|
|
98
|
+
yellow: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
|
|
99
|
+
purple: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
|
|
100
|
+
violet: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
|
|
101
|
+
indigo: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800',
|
|
102
|
+
gray: 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700',
|
|
103
|
+
}[color] || 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700';
|
|
104
|
+
|
|
105
|
+
return m('span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.' + colorClasses, valStr);
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
switch (col?.type) {
|
|
89
109
|
case 'boolean':
|
|
90
110
|
return value
|
|
@@ -298,7 +318,7 @@ const BulkFieldUpdateDropdown = {
|
|
|
298
318
|
vnode.state._bulkFieldName = null;
|
|
299
319
|
if (onComplete) onComplete();
|
|
300
320
|
} catch (err) {
|
|
301
|
-
|
|
321
|
+
notifyError('Error: ' + err.message);
|
|
302
322
|
} finally {
|
|
303
323
|
state.bulkActionInProgress = false;
|
|
304
324
|
m.redraw();
|
|
@@ -319,9 +339,9 @@ const BulkFieldUpdateDropdown = {
|
|
|
319
339
|
const trimmed = vnode.state.bulkTemporalInput && String(vnode.state.bulkTemporalInput).trim();
|
|
320
340
|
if (!trimmed) {
|
|
321
341
|
if (state.selectedBulkField.nullable) {
|
|
322
|
-
|
|
342
|
+
notifyWarning('Enter a date/time or use Clear (set null).');
|
|
323
343
|
} else {
|
|
324
|
-
|
|
344
|
+
notifyWarning('Please enter a value.');
|
|
325
345
|
}
|
|
326
346
|
return;
|
|
327
347
|
}
|
|
@@ -342,7 +362,7 @@ const BulkFieldUpdateDropdown = {
|
|
|
342
362
|
vnode.state._bulkFieldName = null;
|
|
343
363
|
if (onComplete) onComplete();
|
|
344
364
|
} catch (err) {
|
|
345
|
-
|
|
365
|
+
notifyError('Error: ' + err.message);
|
|
346
366
|
} finally {
|
|
347
367
|
state.bulkActionInProgress = false;
|
|
348
368
|
m.redraw();
|
|
@@ -363,7 +383,7 @@ const BulkFieldUpdateDropdown = {
|
|
|
363
383
|
state.selectedBulkField = null;
|
|
364
384
|
if (onComplete) onComplete();
|
|
365
385
|
} catch (err) {
|
|
366
|
-
|
|
386
|
+
notifyError('Error: ' + err.message);
|
|
367
387
|
} finally {
|
|
368
388
|
state.bulkActionInProgress = false;
|
|
369
389
|
m.redraw();
|
|
@@ -414,13 +434,27 @@ const BulkFieldUpdateDropdown = {
|
|
|
414
434
|
},
|
|
415
435
|
};
|
|
416
436
|
|
|
417
|
-
// Get columns to display in table (
|
|
418
|
-
function getDisplayColumns(columns) {
|
|
437
|
+
// Get columns to display in table (respects listColumns if configured, else limits to reasonable number)
|
|
438
|
+
function getDisplayColumns(columns, modelMeta = null) {
|
|
419
439
|
if (!columns || columns.length === 0) return [];
|
|
420
440
|
|
|
421
441
|
// Filter out hidden columns (password_hash, api_token, etc.)
|
|
422
442
|
const visible = [...columns].filter((col) => !col.hidden);
|
|
423
443
|
|
|
444
|
+
// If model defines listColumns, respect the explicit column list and ordering
|
|
445
|
+
if (modelMeta?.listColumns && Array.isArray(modelMeta.listColumns) && modelMeta.listColumns.length > 0) {
|
|
446
|
+
const colMap = new Map(visible.map((c) => [c.name, c]));
|
|
447
|
+
const customList = [];
|
|
448
|
+
for (const name of modelMeta.listColumns) {
|
|
449
|
+
if (colMap.has(name)) {
|
|
450
|
+
customList.push(colMap.get(name));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (customList.length > 0) {
|
|
454
|
+
return customList;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
424
458
|
// Prioritize: id, name/title, then others (excluding long text/json fields)
|
|
425
459
|
const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
|
|
426
460
|
const exclude = ['password', 'content', 'body', 'description']; // Usually too long
|
|
@@ -633,6 +667,13 @@ function initializeModelView(modelName) {
|
|
|
633
667
|
.then(modelMeta => {
|
|
634
668
|
state.currentModelMeta = modelMeta;
|
|
635
669
|
state.currentModel = modelMeta;
|
|
670
|
+
if (typeof setAdminPageTitle === 'function') {
|
|
671
|
+
setAdminPageTitle(modelMeta?.label || modelName);
|
|
672
|
+
}
|
|
673
|
+
if (!state.sortColumn && modelMeta?.defaultSort?.column) {
|
|
674
|
+
state.sortColumn = modelMeta.defaultSort.column;
|
|
675
|
+
state.sortDirection = (modelMeta.defaultSort.order || 'desc').toLowerCase();
|
|
676
|
+
}
|
|
636
677
|
// Load bulk-updatable fields for this model
|
|
637
678
|
loadBulkFields(modelName);
|
|
638
679
|
return loadRecords(modelName, page, state.filters, state.sortColumn, state.sortDirection);
|
|
@@ -1,3 +1,183 @@
|
|
|
1
|
+
// Helper to extract custom actions for a model
|
|
2
|
+
function getModelActions(modelName, modelMeta) {
|
|
3
|
+
const fromMeta = Array.isArray(modelMeta?.actions) ? modelMeta.actions : [];
|
|
4
|
+
const fromConfig = Array.isArray(window.__ADMIN_CONFIG__?.actions)
|
|
5
|
+
? window.__ADMIN_CONFIG__.actions.filter(a => {
|
|
6
|
+
if (!a.models || a.models === '*') return true;
|
|
7
|
+
if (Array.isArray(a.models)) return a.models.includes(modelName);
|
|
8
|
+
return a.models === modelName;
|
|
9
|
+
})
|
|
10
|
+
: [];
|
|
11
|
+
|
|
12
|
+
const map = new Map();
|
|
13
|
+
for (const a of fromConfig) map.set(a.id, a);
|
|
14
|
+
for (const a of fromMeta) map.set(a.id, a);
|
|
15
|
+
return Array.from(map.values());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Helper to extract custom bulk actions for a model
|
|
19
|
+
function getModelBulkActions(modelName, modelMeta) {
|
|
20
|
+
const fromMeta = Array.isArray(modelMeta?.bulkActions) ? modelMeta.bulkActions : [];
|
|
21
|
+
const fromConfig = Array.isArray(window.__ADMIN_CONFIG__?.bulkActions)
|
|
22
|
+
? window.__ADMIN_CONFIG__.bulkActions.filter(a => {
|
|
23
|
+
if (a.id === 'bulk-delete' || a.id === 'bulk-restore' || a.id === 'bulk-update') return false;
|
|
24
|
+
if (!a.models || a.models === '*') return true;
|
|
25
|
+
if (Array.isArray(a.models)) return a.models.includes(modelName);
|
|
26
|
+
return a.models === modelName;
|
|
27
|
+
})
|
|
28
|
+
: [];
|
|
29
|
+
|
|
30
|
+
const map = new Map();
|
|
31
|
+
for (const a of fromConfig) map.set(a.id, a);
|
|
32
|
+
for (const a of fromMeta) map.set(a.id, a);
|
|
33
|
+
return Array.from(map.values());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Evaluate condition whether an action should be shown for a record
|
|
37
|
+
function shouldShowAction(action, record) {
|
|
38
|
+
if (!action || !action.showWhen) return true;
|
|
39
|
+
if (!record) return true;
|
|
40
|
+
if (typeof action.showWhen === 'function') {
|
|
41
|
+
try {
|
|
42
|
+
return !!action.showWhen(record);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (typeof action.showWhen === 'object' && action.showWhen !== null) {
|
|
48
|
+
return Object.entries(action.showWhen).every(([k, v]) => record[k] === v);
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Render icon for actions (SVG string or common icon names)
|
|
54
|
+
function renderActionIcon(icon) {
|
|
55
|
+
if (!icon) return null;
|
|
56
|
+
if (typeof icon === 'string' && (icon.startsWith('<svg') || icon.includes('</svg>'))) {
|
|
57
|
+
return m.trust(icon);
|
|
58
|
+
}
|
|
59
|
+
const iconPaths = {
|
|
60
|
+
check: 'M5 13l4 4L19 7',
|
|
61
|
+
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
|
62
|
+
duplicate: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
|
63
|
+
play: 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z',
|
|
64
|
+
refresh: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
|
|
65
|
+
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
|
|
66
|
+
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
|
67
|
+
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
|
|
68
|
+
mail: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
|
69
|
+
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
|
70
|
+
};
|
|
71
|
+
const pathD = iconPaths[icon];
|
|
72
|
+
if (pathD) {
|
|
73
|
+
return m('svg.w-3.5.h-3.5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
74
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: pathD })
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return m('span.text-xs', icon);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getActionColorClass(color) {
|
|
81
|
+
switch (color) {
|
|
82
|
+
case 'green':
|
|
83
|
+
return 'text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-950/40';
|
|
84
|
+
case 'red':
|
|
85
|
+
return 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/40';
|
|
86
|
+
case 'blue':
|
|
87
|
+
case 'indigo':
|
|
88
|
+
return 'text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-950/40';
|
|
89
|
+
case 'amber':
|
|
90
|
+
case 'yellow':
|
|
91
|
+
return 'text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950/40';
|
|
92
|
+
case 'purple':
|
|
93
|
+
case 'violet':
|
|
94
|
+
return 'text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-950/40';
|
|
95
|
+
default:
|
|
96
|
+
return 'text-gray-600 dark:text-slate-400 hover:text-gray-800 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700/60';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getBulkActionColorClass(color) {
|
|
101
|
+
switch (color) {
|
|
102
|
+
case 'green':
|
|
103
|
+
return 'text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/40';
|
|
104
|
+
case 'red':
|
|
105
|
+
return 'text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/40';
|
|
106
|
+
case 'blue':
|
|
107
|
+
case 'indigo':
|
|
108
|
+
return 'text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-950/40';
|
|
109
|
+
case 'amber':
|
|
110
|
+
case 'yellow':
|
|
111
|
+
return 'text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/40';
|
|
112
|
+
case 'purple':
|
|
113
|
+
case 'violet':
|
|
114
|
+
return 'text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-800 hover:bg-purple-50 dark:hover:bg-purple-950/40';
|
|
115
|
+
default:
|
|
116
|
+
return 'text-gray-700 dark:text-slate-300 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700/40';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function executeRecordAction(action, record, modelName, modelMeta, onSuccess) {
|
|
121
|
+
if (action.confirm) {
|
|
122
|
+
const msg = action.confirmMessage || ('Are you sure you want to perform "' + action.label + '"?');
|
|
123
|
+
const isConfirmed = await confirmAction({ title: action.label, message: msg, danger: action.color === 'red' });
|
|
124
|
+
if (!isConfirmed) return;
|
|
125
|
+
}
|
|
126
|
+
const primaryKey = modelMeta?.primaryKey || 'id';
|
|
127
|
+
const recId = record[primaryKey];
|
|
128
|
+
const busyKey = action.id + '-' + recId;
|
|
129
|
+
state.actionInProgress = busyKey;
|
|
130
|
+
m.redraw();
|
|
131
|
+
try {
|
|
132
|
+
const res = await api.post('/extensions/actions/' + action.id + '/' + modelName + '/' + recId);
|
|
133
|
+
const msg = res?.result?.message || res?.message;
|
|
134
|
+
if (msg) {
|
|
135
|
+
notifySuccess(msg);
|
|
136
|
+
}
|
|
137
|
+
if (typeof onSuccess === 'function') {
|
|
138
|
+
await onSuccess(res);
|
|
139
|
+
} else if (typeof loadRecords === 'function' && state.pagination) {
|
|
140
|
+
loadRecords(modelName, state.pagination.page);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const errText = 'Action failed: ' + (err.message || err);
|
|
144
|
+
notifyError(errText);
|
|
145
|
+
} finally {
|
|
146
|
+
state.actionInProgress = null;
|
|
147
|
+
m.redraw();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function executeBulkAction(action, modelName, modelMeta) {
|
|
152
|
+
const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
|
|
153
|
+
if (action.confirm) {
|
|
154
|
+
const msg = action.confirmMessage || ('Perform "' + action.label + '" on ' + count + ' records?');
|
|
155
|
+
const isConfirmed = await confirmAction({ title: action.label, message: msg, danger: action.color === 'red' });
|
|
156
|
+
if (!isConfirmed) return;
|
|
157
|
+
}
|
|
158
|
+
state.bulkActionInProgress = true;
|
|
159
|
+
m.redraw();
|
|
160
|
+
try {
|
|
161
|
+
const payload = state.selectAllMode
|
|
162
|
+
? { selectAll: true, filters: state.filters, trashed: state.trashedView }
|
|
163
|
+
: { ids: Array.from(state.selectedRecords), trashed: state.trashedView };
|
|
164
|
+
const res = await api.post('/extensions/bulk-actions/' + action.id + '/' + modelName, payload);
|
|
165
|
+
state.selectedRecords = new Set();
|
|
166
|
+
state.selectAllMode = false;
|
|
167
|
+
loadRecords(modelName, state.pagination.page);
|
|
168
|
+
if (res?.result?.message || res?.message) {
|
|
169
|
+
const msg = res.result?.message || res.message;
|
|
170
|
+
notifySuccess(msg);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const errText = 'Bulk action failed: ' + (err.message || err);
|
|
174
|
+
notifyError(errText);
|
|
175
|
+
} finally {
|
|
176
|
+
state.bulkActionInProgress = false;
|
|
177
|
+
m.redraw();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
1
181
|
// Record List Component - displays records with dynamic columns
|
|
2
182
|
const RecordList = {
|
|
3
183
|
oninit(vnode) {
|
|
@@ -25,8 +205,10 @@ const RecordList = {
|
|
|
25
205
|
view: (vnode) => {
|
|
26
206
|
const modelName = m.route.param('model');
|
|
27
207
|
const modelMeta = state.currentModelMeta;
|
|
28
|
-
const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
|
|
208
|
+
const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns, modelMeta) : [];
|
|
29
209
|
const primaryKey = modelMeta?.primaryKey || 'id';
|
|
210
|
+
const customActions = getModelActions(modelName, modelMeta);
|
|
211
|
+
const customBulkActions = getModelBulkActions(modelName, modelMeta);
|
|
30
212
|
|
|
31
213
|
// Count active filters
|
|
32
214
|
const activeFilterCount = Object.values(state.filters || {}).filter(filterPayloadIsActive).length;
|
|
@@ -101,7 +283,7 @@ const RecordList = {
|
|
|
101
283
|
if (state.trashedView) payload.trashed = 'only';
|
|
102
284
|
await downloadDataExchangeXlsx(modelName, payload);
|
|
103
285
|
} catch (err) {
|
|
104
|
-
|
|
286
|
+
notifyError('Error: ' + err.message);
|
|
105
287
|
}
|
|
106
288
|
},
|
|
107
289
|
}, [
|
|
@@ -119,7 +301,12 @@ const RecordList = {
|
|
|
119
301
|
e.target.value = '';
|
|
120
302
|
if (!file) return;
|
|
121
303
|
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
122
|
-
const mode =
|
|
304
|
+
const mode = (await confirmAction({
|
|
305
|
+
title: 'Import Mode',
|
|
306
|
+
message: 'Confirm to upsert records matching existing IDs, or cancel to insert as new records only.',
|
|
307
|
+
confirmText: 'Upsert by ID',
|
|
308
|
+
cancelText: 'Insert only'
|
|
309
|
+
})) ? 'upsert' : 'insert';
|
|
123
310
|
const upsertKey = 'id';
|
|
124
311
|
const fd = new FormData();
|
|
125
312
|
fd.append('file', file);
|
|
@@ -137,10 +324,10 @@ const RecordList = {
|
|
|
137
324
|
if (body.errors && body.errors.length) {
|
|
138
325
|
msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
|
|
139
326
|
}
|
|
140
|
-
|
|
327
|
+
notifySuccess(msg);
|
|
141
328
|
loadRecords(modelName, state.pagination.page, state.filters);
|
|
142
329
|
} catch (err) {
|
|
143
|
-
|
|
330
|
+
notifyError('Error: ' + err.message);
|
|
144
331
|
}
|
|
145
332
|
},
|
|
146
333
|
}) : null,
|
|
@@ -155,7 +342,7 @@ const RecordList = {
|
|
|
155
342
|
),
|
|
156
343
|
'Import',
|
|
157
344
|
]) : null,
|
|
158
|
-
!state.trashedView ? m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
|
|
345
|
+
!state.trashedView && !modelMeta?.readOnly && modelMeta?.permissions?.create !== false ? m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
|
|
159
346
|
onclick: () => {
|
|
160
347
|
state.currentRecord = null;
|
|
161
348
|
state.editing = true;
|
|
@@ -259,7 +446,7 @@ const RecordList = {
|
|
|
259
446
|
? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
|
|
260
447
|
disabled: state.bulkActionInProgress,
|
|
261
448
|
onclick: async () => {
|
|
262
|
-
if (!
|
|
449
|
+
if (!(await confirmAction({ title: 'Restore Records', message: 'Restore the selected records?', confirmText: 'Restore' }))) return;
|
|
263
450
|
state.bulkActionInProgress = true;
|
|
264
451
|
m.redraw();
|
|
265
452
|
try {
|
|
@@ -269,9 +456,10 @@ const RecordList = {
|
|
|
269
456
|
await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
|
|
270
457
|
state.selectedRecords = new Set();
|
|
271
458
|
state.selectAllMode = false;
|
|
459
|
+
notifySuccess('Records restored successfully');
|
|
272
460
|
loadRecords(modelName, 1);
|
|
273
461
|
} catch (err) {
|
|
274
|
-
|
|
462
|
+
notifyError('Error: ' + err.message);
|
|
275
463
|
} finally {
|
|
276
464
|
state.bulkActionInProgress = false;
|
|
277
465
|
m.redraw();
|
|
@@ -283,11 +471,11 @@ const RecordList = {
|
|
|
283
471
|
),
|
|
284
472
|
'Restore',
|
|
285
473
|
])
|
|
286
|
-
: m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white dark:bg-slate-800.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
|
|
474
|
+
: (!modelMeta?.readOnly && modelMeta?.permissions?.delete !== false ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white dark:bg-slate-800.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
|
|
287
475
|
disabled: state.bulkActionInProgress,
|
|
288
476
|
onclick: async () => {
|
|
289
477
|
const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
|
|
290
|
-
if (!
|
|
478
|
+
if (!(await confirmAction({ title: 'Delete Records', message: 'Are you sure you want to delete ' + count + ' records? This action cannot be undone.', danger: true, confirmText: 'Delete' }))) return;
|
|
291
479
|
state.bulkActionInProgress = true;
|
|
292
480
|
m.redraw();
|
|
293
481
|
try {
|
|
@@ -297,9 +485,10 @@ const RecordList = {
|
|
|
297
485
|
await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
|
|
298
486
|
state.selectedRecords = new Set();
|
|
299
487
|
state.selectAllMode = false;
|
|
488
|
+
notifySuccess('Records deleted successfully');
|
|
300
489
|
loadRecords(modelName, 1);
|
|
301
490
|
} catch (err) {
|
|
302
|
-
|
|
491
|
+
notifyError('Error: ' + err.message);
|
|
303
492
|
} finally {
|
|
304
493
|
state.bulkActionInProgress = false;
|
|
305
494
|
m.redraw();
|
|
@@ -310,7 +499,17 @@ const RecordList = {
|
|
|
310
499
|
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
|
|
311
500
|
),
|
|
312
501
|
'Delete',
|
|
313
|
-
]),
|
|
502
|
+
]) : null),
|
|
503
|
+
// Custom bulk actions
|
|
504
|
+
!state.trashedView && customBulkActions.map(action =>
|
|
505
|
+
m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.bg-white.dark:bg-slate-800.border.rounded.transition-colors.' + getBulkActionColorClass(action.color), {
|
|
506
|
+
disabled: state.bulkActionInProgress,
|
|
507
|
+
onclick: () => executeBulkAction(action, modelName, modelMeta),
|
|
508
|
+
}, [
|
|
509
|
+
action.icon ? renderActionIcon(action.icon) : null,
|
|
510
|
+
action.label,
|
|
511
|
+
])
|
|
512
|
+
),
|
|
314
513
|
!state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white dark:bg-slate-800.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
|
|
315
514
|
disabled: state.bulkActionInProgress,
|
|
316
515
|
onclick: async () => {
|
|
@@ -335,7 +534,7 @@ const RecordList = {
|
|
|
335
534
|
URL.revokeObjectURL(url);
|
|
336
535
|
}, 1000);
|
|
337
536
|
} catch (err) {
|
|
338
|
-
|
|
537
|
+
notifyError('Error: ' + err.message);
|
|
339
538
|
} finally {
|
|
340
539
|
state.bulkActionInProgress = false;
|
|
341
540
|
m.redraw();
|
|
@@ -357,7 +556,7 @@ const RecordList = {
|
|
|
357
556
|
? { selectAll: true, filters: state.filters }
|
|
358
557
|
: { ids: Array.from(state.selectedRecords) };
|
|
359
558
|
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
360
|
-
|
|
559
|
+
notifyWarning('Please select at least one record to export');
|
|
361
560
|
return;
|
|
362
561
|
}
|
|
363
562
|
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
|
|
@@ -375,7 +574,7 @@ const RecordList = {
|
|
|
375
574
|
URL.revokeObjectURL(url);
|
|
376
575
|
}, 1000);
|
|
377
576
|
} catch (err) {
|
|
378
|
-
|
|
577
|
+
notifyError('Error: ' + err.message);
|
|
379
578
|
} finally {
|
|
380
579
|
state.bulkActionInProgress = false;
|
|
381
580
|
m.redraw();
|
|
@@ -397,13 +596,13 @@ const RecordList = {
|
|
|
397
596
|
? { selectAll: true, filters: state.filters }
|
|
398
597
|
: { ids: Array.from(state.selectedRecords) };
|
|
399
598
|
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
400
|
-
|
|
599
|
+
notifyWarning('Please select at least one record to export');
|
|
401
600
|
return;
|
|
402
601
|
}
|
|
403
602
|
if (state.trashedView) payload.trashed = 'only';
|
|
404
603
|
await downloadDataExchangeXlsx(modelName, payload);
|
|
405
604
|
} catch (err) {
|
|
406
|
-
|
|
605
|
+
notifyError('Error: ' + err.message);
|
|
407
606
|
} finally {
|
|
408
607
|
state.bulkActionInProgress = false;
|
|
409
608
|
m.redraw();
|
|
@@ -543,32 +742,53 @@ const RecordList = {
|
|
|
543
742
|
onclick: async () => {
|
|
544
743
|
try {
|
|
545
744
|
await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
|
|
745
|
+
notifySuccess('Record restored successfully');
|
|
546
746
|
loadRecords(modelName, state.pagination.page);
|
|
547
747
|
} catch (err) {
|
|
548
|
-
|
|
748
|
+
notifyError('Error: ' + err.message);
|
|
549
749
|
}
|
|
550
750
|
},
|
|
551
751
|
}, 'Restore')
|
|
552
752
|
: [
|
|
753
|
+
// Custom row actions
|
|
754
|
+
...customActions
|
|
755
|
+
.filter(action => shouldShowAction(action, record))
|
|
756
|
+
.map(action =>
|
|
757
|
+
m('button.inline-flex.items-center.px-2.py-1.text-sm.rounded.mr-1.transition-colors.' + getActionColorClass(action.color), {
|
|
758
|
+
disabled: state.actionInProgress === (action.id + '-' + record[primaryKey]),
|
|
759
|
+
onclick: () => executeRecordAction(action, record, modelName, modelMeta),
|
|
760
|
+
}, [
|
|
761
|
+
action.icon ? m('span.mr-1', renderActionIcon(action.icon)) : null,
|
|
762
|
+
state.actionInProgress === (action.id + '-' + record[primaryKey]) ? '...' : action.label,
|
|
763
|
+
])
|
|
764
|
+
),
|
|
553
765
|
m('button.inline-flex.items-center.px-2.py-1.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.dark:hover:text-indigo-300.hover:bg-indigo-50.dark:hover:bg-indigo-950/40.rounded.mr-1.transition-colors', {
|
|
554
766
|
onclick: () => {
|
|
555
767
|
state.currentRecord = record;
|
|
556
768
|
state.editing = true;
|
|
557
769
|
m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
|
|
558
770
|
},
|
|
559
|
-
}, 'Edit'),
|
|
560
|
-
m('button.inline-flex.items-center.px-2.py-1.text-sm.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.hover:bg-red-50.dark:hover:bg-red-950/40.rounded.transition-colors', {
|
|
771
|
+
}, modelMeta?.readOnly || modelMeta?.permissions?.edit === false ? 'View' : 'Edit'),
|
|
772
|
+
!modelMeta?.readOnly && modelMeta?.permissions?.delete !== false ? m('button.inline-flex.items-center.px-2.py-1.text-sm.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.hover:bg-red-50.dark:hover:bg-red-950/40.rounded.transition-colors', {
|
|
561
773
|
onclick: async () => {
|
|
562
|
-
|
|
774
|
+
const isConfirmed = await confirmAction({
|
|
775
|
+
title: 'Delete Record',
|
|
776
|
+
message: 'Are you sure you want to delete this record?',
|
|
777
|
+
danger: true,
|
|
778
|
+
confirmText: 'Delete'
|
|
779
|
+
});
|
|
780
|
+
if (isConfirmed) {
|
|
563
781
|
try {
|
|
564
782
|
await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
|
|
783
|
+
notifySuccess('Record deleted successfully');
|
|
565
784
|
loadRecords(modelName, state.pagination.page);
|
|
566
785
|
} catch (err) {
|
|
567
|
-
|
|
786
|
+
const errMsg = 'Error: ' + err.message;
|
|
787
|
+
notifyError(errMsg);
|
|
568
788
|
}
|
|
569
789
|
}
|
|
570
790
|
},
|
|
571
|
-
}, 'Delete'),
|
|
791
|
+
}, 'Delete') : null,
|
|
572
792
|
],
|
|
573
793
|
]),
|
|
574
794
|
])
|
|
@@ -144,6 +144,10 @@ const RecordForm = {
|
|
|
144
144
|
api.get('/models/' + modelName)
|
|
145
145
|
.then(modelMeta => {
|
|
146
146
|
state.currentModelMeta = modelMeta;
|
|
147
|
+
if (typeof setAdminPageTitle === 'function') {
|
|
148
|
+
const label = modelMeta?.label || modelName;
|
|
149
|
+
setAdminPageTitle((id && id !== 'new' ? 'Edit ' : 'New ') + label);
|
|
150
|
+
}
|
|
147
151
|
|
|
148
152
|
// Initialize form data with defaults
|
|
149
153
|
modelMeta.columns.forEach(col => {
|
|
@@ -179,16 +183,26 @@ const RecordForm = {
|
|
|
179
183
|
const id = m.route.param('id');
|
|
180
184
|
const isNew = !id || id === 'new';
|
|
181
185
|
const modelMeta = state.currentModelMeta;
|
|
186
|
+
const isFormReadOnly = modelMeta?.readOnly === true ||
|
|
187
|
+
(isNew && modelMeta?.permissions?.create === false) ||
|
|
188
|
+
(!isNew && modelMeta?.permissions?.edit === false);
|
|
182
189
|
|
|
190
|
+
const customActions = !isNew && state.currentRecord && typeof getModelActions === 'function'
|
|
191
|
+
? getModelActions(modelName, modelMeta).filter(action => typeof shouldShowAction === 'function' ? shouldShowAction(action, state.currentRecord) : true)
|
|
192
|
+
: [];
|
|
193
|
+
|
|
183
194
|
const breadcrumbs = [
|
|
184
195
|
{ label: modelMeta?.label || modelName, href: '/models/' + modelName },
|
|
185
|
-
{ label: isNew ? 'New' : 'Edit #' + id, href: '#' },
|
|
196
|
+
{ label: isNew ? 'New' : (isFormReadOnly ? 'View #' + id : 'Edit #' + id), href: '#' },
|
|
186
197
|
];
|
|
187
198
|
|
|
188
199
|
return m(Layout, { breadcrumbs }, [
|
|
189
200
|
m('.flex.items-center.justify-between.mb-6', [
|
|
190
|
-
m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', isNew ? 'New Record' : 'Edit Record'),
|
|
191
|
-
|
|
201
|
+
m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', isNew ? 'New Record' : (isFormReadOnly ? 'View Record' : 'Edit Record')),
|
|
202
|
+
m('.flex.items-center.gap-2', [
|
|
203
|
+
isFormReadOnly ? m('span.px-2.py-0.5.text-xs.font-semibold.rounded.bg-amber-100.text-amber-800.dark:bg-amber-900/40.dark:text-amber-300', 'Read-Only') : null,
|
|
204
|
+
modelMeta ? m('span.text-gray-500.dark:text-slate-400', modelMeta.label || modelMeta.name) : null,
|
|
205
|
+
]),
|
|
192
206
|
]),
|
|
193
207
|
|
|
194
208
|
state.loading ? m('p.text-gray-600.dark:text-slate-400', 'Loading...') :
|
|
@@ -198,6 +212,7 @@ const RecordForm = {
|
|
|
198
212
|
style: 'min-height: calc(100vh - 280px);',
|
|
199
213
|
onsubmit: async (e) => {
|
|
200
214
|
e.preventDefault();
|
|
215
|
+
if (isFormReadOnly) return;
|
|
201
216
|
state.error = null;
|
|
202
217
|
state.fieldErrors = {};
|
|
203
218
|
|
|
@@ -261,6 +276,9 @@ const RecordForm = {
|
|
|
261
276
|
} else {
|
|
262
277
|
await api.put('/models/' + modelName + '/records/' + id, payload);
|
|
263
278
|
}
|
|
279
|
+
if (typeof window !== 'undefined' && window.Admin && typeof window.Admin.toast === 'function') {
|
|
280
|
+
window.Admin.toast.success(isNew ? 'Record created successfully' : 'Record updated successfully');
|
|
281
|
+
}
|
|
264
282
|
m.route.set('/models/' + modelName);
|
|
265
283
|
} catch (err) {
|
|
266
284
|
state.error = err.message || 'Failed to save record';
|
|
@@ -287,12 +305,13 @@ const RecordForm = {
|
|
|
287
305
|
// Hide hidden fields
|
|
288
306
|
if (col.ui && col.ui.hidden) return null;
|
|
289
307
|
|
|
290
|
-
const isReadonly = !!autoType || (col.ui && col.ui.readonly);
|
|
308
|
+
const isReadonly = isFormReadOnly || !!autoType || (col.ui && col.ui.readonly);
|
|
291
309
|
const renderer = getFieldRenderer(col, modelMeta);
|
|
292
310
|
const value = state.formData[col.name];
|
|
293
311
|
const fieldError = state.fieldErrors ? state.fieldErrors[col.name] : null;
|
|
294
312
|
|
|
295
313
|
const onChange = (newValue) => {
|
|
314
|
+
if (isFormReadOnly) return;
|
|
296
315
|
state.formData[col.name] = newValue;
|
|
297
316
|
if (state.fieldErrors && state.fieldErrors[col.name]) {
|
|
298
317
|
delete state.fieldErrors[col.name];
|
|
@@ -304,14 +323,42 @@ const RecordForm = {
|
|
|
304
323
|
]),
|
|
305
324
|
|
|
306
325
|
// Sticky footer buttons
|
|
307
|
-
m('.flex.gap-4.p-4.border-t.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-900.sticky.bottom-0', [
|
|
308
|
-
m('
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
326
|
+
m('.flex.items-center.justify-between.gap-4.p-4.border-t.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-900.sticky.bottom-0', [
|
|
327
|
+
m('.flex.items-center.gap-3', [
|
|
328
|
+
isFormReadOnly
|
|
329
|
+
? m('button.bg-gray-600.dark:bg-slate-700.text-white.px-6.py-2.rounded-lg.hover:bg-gray-700.dark:hover:bg-slate-600[type=button]', {
|
|
330
|
+
onclick: () => m.route.set('/models/' + modelName),
|
|
331
|
+
}, 'Back')
|
|
332
|
+
: [
|
|
333
|
+
m('button.bg-blue-600.dark:bg-blue-500.text-white.px-6.py-2.rounded-lg.hover:bg-blue-700.dark:hover:bg-blue-600.disabled:opacity-50', {
|
|
334
|
+
type: 'submit',
|
|
335
|
+
disabled: state.loading,
|
|
336
|
+
}, state.loading ? 'Saving...' : 'Save'),
|
|
337
|
+
m('button.bg-gray-200.dark:bg-slate-700.text-gray-800.dark:text-slate-200.px-6.py-2.rounded.hover:bg-gray-300.dark:hover:bg-slate-600[type=button]', {
|
|
338
|
+
onclick: () => m.route.set('/models/' + modelName),
|
|
339
|
+
}, 'Cancel'),
|
|
340
|
+
],
|
|
341
|
+
]),
|
|
342
|
+
!isNew && state.currentRecord && customActions.length > 0 ? m('.flex.items-center.gap-2', customActions.map(action => {
|
|
343
|
+
const isBusy = state.actionInProgress === (action.id + '-' + id);
|
|
344
|
+
return m('button.px-3.py-2.text-xs.font-medium.rounded-lg.border.flex.items-center.gap-1.5.transition-colors[type=button]', {
|
|
345
|
+
class: typeof getBulkActionColorClass === 'function' ? getBulkActionColorClass(action.color) : 'text-gray-700 dark:text-slate-300 border-gray-300 dark:border-slate-600',
|
|
346
|
+
disabled: isBusy || state.loading,
|
|
347
|
+
title: action.label,
|
|
348
|
+
onclick: () => executeRecordAction(action, state.currentRecord, modelName, modelMeta, async () => {
|
|
349
|
+
const refreshed = await api.get('/models/' + modelName + '/records/' + id);
|
|
350
|
+
if (refreshed?.data) {
|
|
351
|
+
state.currentRecord = refreshed.data;
|
|
352
|
+
Object.keys(refreshed.data).forEach(key => {
|
|
353
|
+
state.formData[key] = refreshed.data[key];
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}),
|
|
357
|
+
}, [
|
|
358
|
+
isBusy ? m('span.inline-block.animate-spin', '⟳') : (action.icon && typeof renderActionIcon === 'function' ? renderActionIcon(action.icon) : null),
|
|
359
|
+
m('span', action.label),
|
|
360
|
+
]);
|
|
361
|
+
})) : null,
|
|
315
362
|
]),
|
|
316
363
|
]),
|
|
317
364
|
]);
|