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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- 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 };
|