millas 0.2.12-beta → 0.2.12-beta-2

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.
Files changed (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * QueryEngine
5
+ *
6
+ * Encapsulates all database query logic for the admin list view.
7
+ * Extracted from AdminResource.fetchList() so it can be tested,
8
+ * extended, and swapped independently of the rest of the admin.
9
+ *
10
+ * ── Responsibilities ──────────────────────────────────────────────────────────
11
+ *
12
+ * - Filtering via Django __ lookup syntax
13
+ * - Full-text search across searchable columns
14
+ * - Date hierarchy drill-down (year / month)
15
+ * - Sorting (column + direction)
16
+ * - Offset pagination with total count
17
+ * - Column pruning (only SELECT columns shown in list_display)
18
+ * - Eager loading of FK columns (avoids N+1 for related labels)
19
+ * - Bulk operations wrapped in a transaction
20
+ *
21
+ * ── Usage ─────────────────────────────────────────────────────────────────────
22
+ *
23
+ * const engine = new QueryEngine(Resource);
24
+ *
25
+ * const result = await engine.fetchList({
26
+ * page: 1, perPage: 25,
27
+ * search: 'alice',
28
+ * sort: 'created_at', order: 'desc',
29
+ * filters: { role__exact: 'admin', is_active__isnull: false },
30
+ * year: '2026', month: '03',
31
+ * });
32
+ * // → { data: [...], total: 42, page: 1, perPage: 25, lastPage: 2 }
33
+ *
34
+ * await engine.bulkDelete([1, 2, 3]);
35
+ * await engine.bulkUpdate([1, 2, 3], { is_active: false });
36
+ * await engine.bulkAction([1, 2, 3], async (ids, Model) => { ... });
37
+ *
38
+ * ── Lookup syntax (__ filter keys) ───────────────────────────────────────────
39
+ *
40
+ * created_at__gte → WHERE created_at >= value
41
+ * role__exact → WHERE role = value
42
+ * email__icontains → WHERE email LIKE '%value%'
43
+ * deleted_at__isnull → WHERE deleted_at IS NULL (value truthy) / IS NOT NULL
44
+ * status__in → WHERE status IN (value[])
45
+ * age__between → WHERE age BETWEEN value[0] AND value[1]
46
+ *
47
+ * ── Column pruning ────────────────────────────────────────────────────────────
48
+ *
49
+ * If the resource declares list_display, QueryEngine will SELECT only
50
+ * those columns (plus the primary key). This avoids fetching large text
51
+ * columns (body, description) that are never shown in the list view.
52
+ *
53
+ * ── Performance notes ────────────────────────────────────────────────────────
54
+ *
55
+ * - Data query and count query run in parallel (Promise.all)
56
+ * - Column pruning reduces data transfer for wide tables
57
+ * - All filters are parameterised — no string interpolation in WHERE clauses
58
+ */
59
+ class QueryEngine {
60
+ /**
61
+ * @param {class} Resource — AdminResource subclass
62
+ */
63
+ constructor(Resource) {
64
+ this._Resource = Resource;
65
+ this._Model = Resource.model;
66
+ }
67
+
68
+ // ─── List fetch ────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Fetch a paginated, filtered, sorted list of records.
72
+ *
73
+ * @param {object} opts
74
+ * @param {number} [opts.page=1]
75
+ * @param {number} [opts.perPage] — defaults to Resource.perPage
76
+ * @param {string} [opts.search=''] — full-text search term
77
+ * @param {string} [opts.sort='id'] — column to sort by
78
+ * @param {string} [opts.order='desc'] — 'asc' | 'desc'
79
+ * @param {object} [opts.filters={}] — { col__lookup: value }
80
+ * @param {string} [opts.year] — date hierarchy year
81
+ * @param {string} [opts.month] — date hierarchy month
82
+ *
83
+ * @returns {Promise<{ data, total, page, perPage, lastPage }>}
84
+ */
85
+ async fetchList({
86
+ page = 1,
87
+ perPage,
88
+ search = '',
89
+ sort = 'id',
90
+ order = 'desc',
91
+ filters = {},
92
+ year = null,
93
+ month = null,
94
+ } = {}) {
95
+ const R = this._Resource;
96
+ const Model = this._Model;
97
+ const limit = perPage || R.perPage || 20;
98
+ const offset = (page - 1) * limit;
99
+
100
+ // ── Base query ────────────────────────────────────────────────────────
101
+ let q = Model._db();
102
+
103
+ // ── Column pruning ────────────────────────────────────────────────────
104
+ // Only SELECT the columns shown in list_display + primary key.
105
+ // Avoids fetching large text/json columns that aren't shown.
106
+ const listDisplay = R.list_display || null;
107
+ if (listDisplay && listDisplay.length) {
108
+ const pk = Model.primaryKey || 'id';
109
+ const cols = [...new Set([pk, ...listDisplay])].map(c => `${Model.table}.${c}`);
110
+ q = q.select(cols);
111
+ }
112
+
113
+ // ── Ordering ──────────────────────────────────────────────────────────
114
+ // Sanitise sort column against known field names to prevent injection
115
+ const knownCols = new Set(
116
+ Object.keys(
117
+ typeof Model.getFields === 'function' ? Model.getFields() : (Model.fields || {})
118
+ )
119
+ );
120
+ const safeSort = knownCols.has(sort) ? sort : (Model.primaryKey || 'id');
121
+ const safeOrder = order === 'asc' ? 'asc' : 'desc';
122
+ q = q.orderBy(safeSort, safeOrder);
123
+
124
+ // ── Search ────────────────────────────────────────────────────────────
125
+ if (search && R.searchable && R.searchable.length) {
126
+ const cols = R.searchable;
127
+ q = q.where(function () {
128
+ for (const col of cols) {
129
+ this.orWhere(col, 'like', `%${search}%`);
130
+ }
131
+ });
132
+ }
133
+
134
+ // ── Filters ───────────────────────────────────────────────────────────
135
+ for (const [key, value] of Object.entries(filters)) {
136
+ if (value === '' || value === null || value === undefined) continue;
137
+ q = this._applyFilter(q, key, value);
138
+ }
139
+
140
+ // ── Date hierarchy ────────────────────────────────────────────────────
141
+ if (R.dateHierarchy) {
142
+ q = this._applyDateHierarchy(q, R.dateHierarchy, year, month);
143
+ }
144
+
145
+ // ── Execute data + count in parallel ──────────────────────────────────
146
+ const [rows, countResult] = await Promise.all([
147
+ q.clone().limit(limit).offset(offset),
148
+ q.clone().count('* as count').first(),
149
+ ]);
150
+
151
+ const total = Number(countResult?.count ?? 0);
152
+
153
+ return {
154
+ data: rows.map(r => Model._hydrate ? Model._hydrate(r) : r),
155
+ total,
156
+ page: Number(page),
157
+ perPage: limit,
158
+ lastPage: Math.ceil(total / limit) || 1,
159
+ };
160
+ }
161
+
162
+ // ─── Bulk operations ───────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Delete multiple records by primary key.
166
+ * @param {Array} ids
167
+ */
168
+ async bulkDelete(ids) {
169
+ if (!ids || !ids.length) return;
170
+ const pk = this._Model.primaryKey || 'id';
171
+ await this._Model._db().whereIn(pk, ids).delete();
172
+ }
173
+
174
+ /**
175
+ * Update multiple records with the same data.
176
+ * @param {Array} ids
177
+ * @param {object} data
178
+ */
179
+ async bulkUpdate(ids, data) {
180
+ if (!ids || !ids.length) return;
181
+ const pk = this._Model.primaryKey || 'id';
182
+ const now = new Date().toISOString();
183
+ const payload = this._Model.timestamps
184
+ ? { ...data, updated_at: now }
185
+ : { ...data };
186
+ await this._Model._db().whereIn(pk, ids).update(payload);
187
+ }
188
+
189
+ /**
190
+ * Run a custom bulk action inside a transaction.
191
+ * @param {Array} ids
192
+ * @param {Function} handler — async (ids, Model, trx) => void
193
+ */
194
+ async bulkAction(ids, handler) {
195
+ if (!ids || !ids.length || typeof handler !== 'function') return;
196
+ await this._Model.transaction(async (trx) => {
197
+ await handler(ids, this._Model, trx);
198
+ });
199
+ }
200
+
201
+ // ─── Filter parser ─────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Apply a single filter key/value pair to a knex query builder.
205
+ * Supports Django __ lookup syntax.
206
+ *
207
+ * @param {object} q — knex query builder
208
+ * @param {string} key — 'column__lookup' or just 'column'
209
+ * @param {*} value
210
+ * @returns {object} — modified query builder
211
+ */
212
+ _applyFilter(q, key, value) {
213
+ const dunder = key.lastIndexOf('__');
214
+
215
+ if (dunder === -1) {
216
+ return q.where(key, value);
217
+ }
218
+
219
+ const col = key.slice(0, dunder);
220
+ const lookup = key.slice(dunder + 2);
221
+
222
+ switch (lookup) {
223
+ case 'exact': return q.where(col, value);
224
+ case 'not': return q.where(col, '!=', value);
225
+ case 'gt': return q.where(col, '>', value);
226
+ case 'gte': return q.where(col, '>=', value);
227
+ case 'lt': return q.where(col, '<', value);
228
+ case 'lte': return q.where(col, '<=', value);
229
+ case 'isnull': return value ? q.whereNull(col) : q.whereNotNull(col);
230
+ case 'notnull': return q.whereNotNull(col);
231
+ case 'in': return q.whereIn(col, Array.isArray(value) ? value : [value]);
232
+ case 'notin': return q.whereNotIn(col, Array.isArray(value) ? value : [value]);
233
+ case 'between': return q.whereBetween(col, Array.isArray(value) ? value : [value, value]);
234
+ case 'contains':
235
+ case 'icontains': return q.where(col, 'like', `%${value}%`);
236
+ case 'startswith':
237
+ case 'istartswith': return q.where(col, 'like', `${value}%`);
238
+ case 'endswith':
239
+ case 'iendswith': return q.where(col, 'like', `%${value}`);
240
+ default: return q.where(key, value);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Apply date hierarchy (year/month) drill-down filters.
246
+ * Uses strftime for SQLite/MySQL; falls back gracefully on PostgreSQL.
247
+ */
248
+ _applyDateHierarchy(q, col, year, month) {
249
+ if (year) {
250
+ try {
251
+ q = q.whereRaw(`strftime('%Y', \`${col}\`) = ?`, [String(year)]);
252
+ } catch { /* PG fallback — skip */ }
253
+ }
254
+ if (month) {
255
+ try {
256
+ q = q.whereRaw(`strftime('%m', \`${col}\`) = ?`, [String(month).padStart(2, '0')]);
257
+ } catch { /* PG fallback — skip */ }
258
+ }
259
+ return q;
260
+ }
261
+ }
262
+
263
+ module.exports = { QueryEngine };
@@ -0,0 +1,309 @@
1
+ 'use strict';
2
+
3
+ const { FormGenerator } = require('./FormGenerator');
4
+
5
+ /**
6
+ * ViewContext
7
+ *
8
+ * Assembles the template data (context object) for each admin view.
9
+ * Separates "what data does this page need?" from "how do we handle the request?".
10
+ *
11
+ * ── Design principle ──────────────────────────────────────────────────────────
12
+ *
13
+ * Route handlers in Admin.js are responsible for:
14
+ * - Auth + permission checks
15
+ * - Calling the ORM (via AdminResource or QueryEngine)
16
+ * - Firing hooks
17
+ * - Redirecting on success
18
+ *
19
+ * ViewContext is responsible for:
20
+ * - Assembling the template data object
21
+ * - Deriving display metadata from AdminResource config
22
+ * - No DB calls, no business logic, no HTML
23
+ *
24
+ * ── Usage ─────────────────────────────────────────────────────────────────────
25
+ *
26
+ * // In Admin.js handler — replaces inline object literals
27
+ * const ctx = ViewContext.list(R, { rows, result, query, user, adminPrefix, flash });
28
+ * return this._render(req, res, 'pages/list.njk', ctx, R);
29
+ *
30
+ * ── What it replaces ──────────────────────────────────────────────────────────
31
+ *
32
+ * Before — Admin.js _list() contained ~40 lines of inline object assembly:
33
+ * res.render('pages/list.njk', {
34
+ * pageTitle: R._getLabel(),
35
+ * resource: { slug, label, canCreate, canEdit, ... },
36
+ * rows, listFields, filters, activeFilters, sortable,
37
+ * total, page, perPage, lastPage, search, sort, order, ...
38
+ * });
39
+ *
40
+ * After — 1 line in handler + all assembly logic testable in isolation:
41
+ * return this._render(req, res, 'pages/list.njk',
42
+ * ViewContext.list(R, { rows, result, query, perms, flash }), R);
43
+ */
44
+ class ViewContext {
45
+
46
+ // ─── Base context (shared by all views) ───────────────────────────────────
47
+
48
+ /**
49
+ * Base context included in every admin view.
50
+ * Mirrors what Admin._ctx() currently produces.
51
+ *
52
+ * @param {object} opts
53
+ * @param {object} opts.admin — Admin singleton (for config + resources)
54
+ * @param {object} opts.user — req.adminUser
55
+ * @param {string} opts.csrfToken
56
+ * @param {object} [opts.flash={}]
57
+ * @param {string} [opts.activePage=null]
58
+ * @param {string} [opts.activeResource=null]
59
+ */
60
+ static base({ admin, user, csrfToken, flash = {}, activePage = null, activeResource = null }) {
61
+ return {
62
+ csrfToken,
63
+ adminPrefix: admin._config.prefix,
64
+ adminTitle: admin._config.title,
65
+ adminUser: user || null,
66
+ authEnabled: admin._auth?.enabled ?? false,
67
+ resources: admin.resources()
68
+ .filter(R => R.hasPermission(user || null, 'view'))
69
+ .map((R, idx) => ({
70
+ slug: R.slug,
71
+ label: R._getLabel(),
72
+ singular: R._getLabelSingular(),
73
+ icon: R.icon,
74
+ canView: R.hasPermission(user || null, 'view'),
75
+ index: idx + 1,
76
+ })),
77
+ flash,
78
+ activePage,
79
+ activeResource,
80
+ };
81
+ }
82
+
83
+ // ─── Dashboard ─────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * @param {object} opts
87
+ * @param {Array} opts.resourceData — per-resource { slug, label, count, recent, ... }
88
+ * @param {Array} opts.activity — ActivityLog.recent() result
89
+ * @param {object} opts.activityTotals
90
+ * @param {object} opts.baseCtx — from ViewContext.base()
91
+ */
92
+ static dashboard({ resourceData, activity, activityTotals, baseCtx }) {
93
+ return {
94
+ ...baseCtx,
95
+ pageTitle: 'Dashboard',
96
+ activePage: 'dashboard',
97
+ resources: resourceData, // overrides baseCtx.resources with enriched data
98
+ activity,
99
+ activityTotals,
100
+ };
101
+ }
102
+
103
+ // ─── List ──────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * @param {class} Resource
107
+ * @param {object} opts
108
+ * @param {Array} opts.rows — hydrated + serialised row data
109
+ * @param {object} opts.result — fetchList result { total, page, perPage, lastPage }
110
+ * @param {object} opts.query — { search, sort, order, page, perPage, year, month }
111
+ * @param {object} opts.activeFilters — { col__lookup: value }
112
+ * @param {object} opts.perms — { canCreate, canEdit, canDelete, canView }
113
+ * @param {object} opts.baseCtx
114
+ */
115
+ static list(Resource, { rows, result, query, activeFilters, perms, baseCtx }) {
116
+ const listFields = Resource.fields()
117
+ .filter(f => !f._hidden && !f._detailOnly)
118
+ .map(f => f.toJSON());
119
+
120
+ return {
121
+ ...baseCtx,
122
+ pageTitle: Resource._getLabel(),
123
+ activeResource: Resource.slug,
124
+ resource: {
125
+ slug: Resource.slug,
126
+ label: Resource._getLabel(),
127
+ singular: Resource._getLabelSingular(),
128
+ icon: Resource.icon,
129
+ canCreate: perms.canCreate,
130
+ canEdit: perms.canEdit,
131
+ canDelete: perms.canDelete,
132
+ canView: perms.canView,
133
+ actions: (Resource.actions || []).map((a, i) => ({ ...a, index: i, handler: undefined })),
134
+ rowActions: Resource.rowActions || [],
135
+ listDisplayLinks: Resource.listDisplayLinks || [],
136
+ dateHierarchy: Resource.dateHierarchy || null,
137
+ prepopulatedFields: Resource.prepopulatedFields || {},
138
+ },
139
+ rows,
140
+ listFields,
141
+ filters: Resource.filters().map(f => f.toJSON()),
142
+ activeFilters,
143
+ sortable: Resource.sortable || [],
144
+ total: result.total,
145
+ page: result.page,
146
+ perPage: result.perPage,
147
+ lastPage: result.lastPage,
148
+ search: query.search,
149
+ sort: query.sort,
150
+ order: query.order,
151
+ year: query.year || null,
152
+ month: query.month || null,
153
+ };
154
+ }
155
+
156
+ // ─── Create form ───────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * @param {class} Resource
160
+ * @param {object} opts
161
+ * @param {string} opts.adminPrefix
162
+ * @param {object} [opts.record={}] — pre-filled data (on validation error re-render)
163
+ * @param {object} [opts.errors={}]
164
+ * @param {object} opts.baseCtx
165
+ */
166
+ static create(Resource, { adminPrefix, record = {}, errors = {}, baseCtx }) {
167
+
168
+ return {
169
+ ...baseCtx,
170
+ pageTitle: `New ${Resource._getLabelSingular()}`,
171
+ activeResource: Resource.slug,
172
+ resource: {
173
+ slug: Resource.slug,
174
+ label: Resource._getLabel(),
175
+ singular: Resource._getLabelSingular(),
176
+ icon: Resource.icon,
177
+ canDelete: false,
178
+ },
179
+ formFields: FormGenerator.fromResource(Resource, { isEdit: false }).fields,
180
+ formAction: `${adminPrefix}/${Resource.slug}`,
181
+ isEdit: false,
182
+ record,
183
+ errors,
184
+ };
185
+ }
186
+
187
+ // ─── Edit form ─────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * @param {class} Resource
191
+ * @param {object} opts
192
+ * @param {string} opts.adminPrefix
193
+ * @param {*} opts.id — record primary key
194
+ * @param {object} opts.record — existing record data
195
+ * @param {boolean} opts.canDelete
196
+ * @param {object} [opts.errors={}]
197
+ * @param {object} opts.baseCtx
198
+ */
199
+ static edit(Resource, { adminPrefix, id, record, canDelete, errors = {}, baseCtx }) {
200
+ return {
201
+ ...baseCtx,
202
+ pageTitle: `Edit ${Resource._getLabelSingular()} #${id}`,
203
+ activeResource: Resource.slug,
204
+ resource: {
205
+ slug: Resource.slug,
206
+ label: Resource._getLabel(),
207
+ singular: Resource._getLabelSingular(),
208
+ icon: Resource.icon,
209
+ canDelete,
210
+ },
211
+ formFields: FormGenerator.fromResource(Resource, { isEdit: true }).fields,
212
+ formAction: `${adminPrefix}/${Resource.slug}/${id}`,
213
+ isEdit: true,
214
+ record,
215
+ errors,
216
+ };
217
+ }
218
+
219
+ // ─── Detail view ───────────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * @param {class} Resource
223
+ * @param {object} opts
224
+ * @param {*} opts.id
225
+ * @param {object} opts.record
226
+ * @param {Array} opts.inlineData
227
+ * @param {object} opts.perms — { canEdit, canDelete, canCreate }
228
+ * @param {object} opts.baseCtx
229
+ */
230
+ static detail(Resource, { id, record, inlineData, perms, baseCtx }) {
231
+ const detailFields = Resource.fields()
232
+ .filter(f => f._type !== '__tab__' && f._type !== 'fieldset' && !f._hidden && !f._listOnly)
233
+ .map(f => f.toJSON());
234
+
235
+ const tabs = ViewContext._buildTabs(Resource.fields());
236
+
237
+ return {
238
+ ...baseCtx,
239
+ pageTitle: `${Resource._getLabelSingular()} #${id}`,
240
+ activeResource: Resource.slug,
241
+ resource: {
242
+ slug: Resource.slug,
243
+ label: Resource._getLabel(),
244
+ singular: Resource._getLabelSingular(),
245
+ icon: Resource.icon,
246
+ canEdit: perms.canEdit,
247
+ canDelete: perms.canDelete,
248
+ canCreate: perms.canCreate,
249
+ rowActions: Resource.rowActions || [],
250
+ },
251
+ record,
252
+ detailFields,
253
+ tabs,
254
+ hasTabs: tabs.length > 1,
255
+ inlines: inlineData,
256
+ };
257
+ }
258
+
259
+ // ─── Search ────────────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * @param {object} opts
263
+ * @param {string} opts.query
264
+ * @param {Array} opts.results
265
+ * @param {number} opts.total
266
+ * @param {object} opts.baseCtx
267
+ */
268
+ static search({ query, results, total, baseCtx }) {
269
+ return {
270
+ ...baseCtx,
271
+ pageTitle: query ? `Search: ${query}` : 'Search',
272
+ activePage: 'search',
273
+ query,
274
+ results,
275
+ total,
276
+ };
277
+ }
278
+
279
+ // ─── Internal ─────────────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Build tab structure for tabbed detail/form rendering.
283
+ * Extracted from Admin._buildTabs() — same logic, centralised here.
284
+ */
285
+ static _buildTabs(fields) {
286
+ const tabs = [];
287
+ let current = null;
288
+
289
+ for (const f of fields) {
290
+ if (f._type === 'tab') {
291
+ current = { label: f._label, fields: [] };
292
+ tabs.push(current);
293
+ continue;
294
+ }
295
+ if (f._type === 'fieldset') {
296
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
297
+ current.fields.push({ _isFieldset: true, label: f._label });
298
+ continue;
299
+ }
300
+ if (f._hidden || f._listOnly) continue;
301
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
302
+ current.fields.push(f.toJSON());
303
+ }
304
+
305
+ return tabs;
306
+ }
307
+ }
308
+
309
+ module.exports = { ViewContext };