millas 0.2.12-beta-1 → 0.2.13-beta
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 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- 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 +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -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 +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -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 +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -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/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- 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 +143 -74
- 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/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- package/src/validation/Validator.js +348 -607
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { widgetRegistry } = require('./WidgetRegistry');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FormGenerator
|
|
7
|
+
*
|
|
8
|
+
* Derives a complete, validated form schema from:
|
|
9
|
+
* 1. Model field metadata (via Model.getFields())
|
|
10
|
+
* 2. AdminResource configuration (fields(), readonlyFields, fieldsets, widgets)
|
|
11
|
+
* 3. WidgetRegistry (type → widget mapping)
|
|
12
|
+
*
|
|
13
|
+
* The output is a FormSchema — a plain serialisable object that templates
|
|
14
|
+
* consume directly. No HTML is generated here.
|
|
15
|
+
*
|
|
16
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* // In Admin.js — replacing the existing _formFields() call
|
|
19
|
+
* const schema = FormGenerator.fromResource(R, { record, isEdit });
|
|
20
|
+
* res.render('pages/form.njk', { ...ctx, formFields: schema.fields });
|
|
21
|
+
*
|
|
22
|
+
* ── What FormGenerator adds over the current _formFields() ───────────────────
|
|
23
|
+
*
|
|
24
|
+
* ✅ Widget type resolution via WidgetRegistry (customisable per field)
|
|
25
|
+
* ✅ Per-resource widget overrides (static widgets = { body: { type:'richtext' } })
|
|
26
|
+
* ✅ Validation rule derivation from field definitions
|
|
27
|
+
* ✅ Step/min/max attributes derived from field definitions (e.g. decimal scale)
|
|
28
|
+
* ✅ Coerce function available for each field (used by _sanitise)
|
|
29
|
+
* ✅ FormValidator.validate() — validates a full data object before ORM write
|
|
30
|
+
* ✅ Consistent field ordering: fieldsets respected, id always excluded
|
|
31
|
+
*
|
|
32
|
+
* ── FormSchema shape ──────────────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* {
|
|
35
|
+
* fields: [
|
|
36
|
+
* {
|
|
37
|
+
* name: string,
|
|
38
|
+
* label: string,
|
|
39
|
+
* type: string, // widget type (text|number|select|checkbox|…)
|
|
40
|
+
* ormType: string, // original ORM field type
|
|
41
|
+
* required: boolean,
|
|
42
|
+
* nullable: boolean,
|
|
43
|
+
* readonly: boolean,
|
|
44
|
+
* hidden: boolean,
|
|
45
|
+
* tab: string|null,
|
|
46
|
+
* fieldset: string|null,
|
|
47
|
+
* span: string|null, // 'full'|'third'|null
|
|
48
|
+
* options: array|null, // for select/radio
|
|
49
|
+
* placeholder: string|null,
|
|
50
|
+
* help: string|null,
|
|
51
|
+
* min: number|null,
|
|
52
|
+
* max: number|null,
|
|
53
|
+
* step: number|null,
|
|
54
|
+
* prepopulate: string|null,
|
|
55
|
+
* isReadonly: boolean,
|
|
56
|
+
* _isFieldset: boolean, // sentinel: fieldset heading row
|
|
57
|
+
* },
|
|
58
|
+
* ...
|
|
59
|
+
* ],
|
|
60
|
+
* tabs: string[], // unique tab names in order
|
|
61
|
+
* hasTab: boolean,
|
|
62
|
+
* }
|
|
63
|
+
*/
|
|
64
|
+
class FormGenerator {
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Derive a FormSchema from an AdminResource class.
|
|
68
|
+
*
|
|
69
|
+
* @param {class} Resource — AdminResource subclass
|
|
70
|
+
* @param {object} [opts]
|
|
71
|
+
* @param {object} [opts.record={}] — existing record data (for edit forms)
|
|
72
|
+
* @param {boolean}[opts.isEdit=false]
|
|
73
|
+
* @returns {FormSchema}
|
|
74
|
+
*/
|
|
75
|
+
static fromResource(Resource, { record = {}, isEdit = false } = {}) {
|
|
76
|
+
const readonlySet = new Set(Resource.readonlyFields || []);
|
|
77
|
+
const prepopFields = Resource.prepopulatedFields || {};
|
|
78
|
+
// Per-resource widget overrides: static widgets = { fieldName: { type: 'richtext' } }
|
|
79
|
+
const widgetOverrides = Resource.widgets || {};
|
|
80
|
+
|
|
81
|
+
// Get the merged model field map for widget/validation metadata
|
|
82
|
+
const modelFieldMap = Resource.model
|
|
83
|
+
? (typeof Resource.model.getFields === 'function'
|
|
84
|
+
? Resource.model.getFields()
|
|
85
|
+
: (Resource.model.fields || {}))
|
|
86
|
+
: {};
|
|
87
|
+
|
|
88
|
+
let currentTab = null;
|
|
89
|
+
let currentFieldset = null;
|
|
90
|
+
const fields = [];
|
|
91
|
+
const tabNames = [];
|
|
92
|
+
|
|
93
|
+
for (const f of Resource.fields()) {
|
|
94
|
+
|
|
95
|
+
// ── Tab separator ─────────────────────────────────────────────────────
|
|
96
|
+
if (f._type === 'tab') {
|
|
97
|
+
currentTab = f._label;
|
|
98
|
+
currentFieldset = null;
|
|
99
|
+
if (!tabNames.includes(currentTab)) tabNames.push(currentTab);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Fieldset heading ──────────────────────────────────────────────────
|
|
104
|
+
if (f._type === 'fieldset') {
|
|
105
|
+
currentFieldset = f._label;
|
|
106
|
+
fields.push({ _isFieldset: true, label: f._label, tab: currentTab });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Skip id and list-only fields from forms ───────────────────────────
|
|
111
|
+
if (f._type === 'id' || f._listOnly || f._hidden) continue;
|
|
112
|
+
|
|
113
|
+
const name = f._name;
|
|
114
|
+
|
|
115
|
+
// ── FK / M2M fields — use AdminField metadata directly ───────────────
|
|
116
|
+
// AdminField.fromModelField() already resolved the correct widget type
|
|
117
|
+
// ('fk' or 'm2m') and the fkResource slug from the model's references.
|
|
118
|
+
// FormGenerator must respect that instead of re-deriving from ormType,
|
|
119
|
+
// because modelFieldMap uses the accessor name ('landlord') while the
|
|
120
|
+
// AdminField uses the column name ('landlord_id') — they can't be matched
|
|
121
|
+
// by name, and even if they could, ormType='integer' would give widget='number'.
|
|
122
|
+
if (f._type === 'fk') {
|
|
123
|
+
const isReadonly = readonlySet.has(name) || f._readonly || false;
|
|
124
|
+
fields.push({
|
|
125
|
+
name,
|
|
126
|
+
label: f._label,
|
|
127
|
+
type: 'fk',
|
|
128
|
+
ormType: 'integer',
|
|
129
|
+
fkResource: f._fkResource || null,
|
|
130
|
+
required: !f._nullable,
|
|
131
|
+
nullable: !!f._nullable,
|
|
132
|
+
readonly: isReadonly,
|
|
133
|
+
isReadonly,
|
|
134
|
+
hidden: false,
|
|
135
|
+
tab: currentTab,
|
|
136
|
+
fieldset: currentFieldset,
|
|
137
|
+
span: f._span || null,
|
|
138
|
+
options: null,
|
|
139
|
+
placeholder: f._placeholder || null,
|
|
140
|
+
help: f._help || null,
|
|
141
|
+
min: null,
|
|
142
|
+
max: null,
|
|
143
|
+
step: null,
|
|
144
|
+
colors: {},
|
|
145
|
+
isLink: false,
|
|
146
|
+
prepopulate: null,
|
|
147
|
+
_coerce: widgetRegistry.resolve('fk').coerce,
|
|
148
|
+
_validate: widgetRegistry.resolve('fk').validate,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (f._type === 'm2m') {
|
|
154
|
+
const isReadonly = readonlySet.has(name) || f._readonly || false;
|
|
155
|
+
fields.push({
|
|
156
|
+
name,
|
|
157
|
+
label: f._label,
|
|
158
|
+
type: 'm2m',
|
|
159
|
+
ormType: 'm2m',
|
|
160
|
+
fkResource: f._m2mResource || null,
|
|
161
|
+
required: false,
|
|
162
|
+
nullable: true,
|
|
163
|
+
readonly: isReadonly,
|
|
164
|
+
isReadonly,
|
|
165
|
+
hidden: false,
|
|
166
|
+
tab: currentTab,
|
|
167
|
+
fieldset: currentFieldset,
|
|
168
|
+
span: 'full',
|
|
169
|
+
options: null,
|
|
170
|
+
placeholder: null,
|
|
171
|
+
help: f._help || null,
|
|
172
|
+
min: null,
|
|
173
|
+
max: null,
|
|
174
|
+
step: null,
|
|
175
|
+
colors: {},
|
|
176
|
+
isLink: false,
|
|
177
|
+
prepopulate: null,
|
|
178
|
+
_coerce: widgetRegistry.resolve('m2m').coerce,
|
|
179
|
+
_validate: widgetRegistry.resolve('m2m').validate,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── All other field types ─────────────────────────────────────────────
|
|
185
|
+
// AdminField carries its own _type (e.g. 'boolean', 'date', 'json',
|
|
186
|
+
// 'email', 'password', 'url', 'phone', 'color', 'richtext', 'select').
|
|
187
|
+
// For these, the AdminField._type IS the widget key — trust it directly
|
|
188
|
+
// rather than re-deriving from modelFieldMap which may not find the field
|
|
189
|
+
// (e.g. custom AdminField.boolean('active') with a name that doesn't
|
|
190
|
+
// match the model's field map exactly).
|
|
191
|
+
//
|
|
192
|
+
// AdminField types that need direct resolution (no ORM equivalent):
|
|
193
|
+
// Map AdminField._type → WidgetRegistry key.
|
|
194
|
+
// AdminField types that don't match ORM type names get aliased here.
|
|
195
|
+
// ── Type resolution ──────────────────────────────────────────────────────
|
|
196
|
+
// Two separate concerns:
|
|
197
|
+
// ormType → WidgetRegistry key (coerce/validate functions)
|
|
198
|
+
// templateType → what field.type the template sees
|
|
199
|
+
//
|
|
200
|
+
// Admin-display types (color, richtext, phone, badge, image) have no ORM
|
|
201
|
+
// equivalent. They map to a string/text registry entry for coerce/validate
|
|
202
|
+
// but the template must receive the original AdminField type to render
|
|
203
|
+
// the correct widget (color picker, WYSIWYG, tel input, etc.).
|
|
204
|
+
|
|
205
|
+
// Maps AdminField._type → WidgetRegistry key
|
|
206
|
+
const REGISTRY_TYPE = {
|
|
207
|
+
boolean: 'boolean',
|
|
208
|
+
date: 'date',
|
|
209
|
+
datetime: 'timestamp',
|
|
210
|
+
timestamp:'timestamp',
|
|
211
|
+
json: 'json',
|
|
212
|
+
email: 'email',
|
|
213
|
+
url: 'url',
|
|
214
|
+
password: 'password',
|
|
215
|
+
phone: 'string', // template renders <input type="tel">
|
|
216
|
+
color: 'string', // template renders color picker
|
|
217
|
+
richtext: 'text', // template renders WYSIWYG
|
|
218
|
+
badge: 'string', // display-only, always readonly on forms
|
|
219
|
+
image: 'string', // display-only URL, always readonly on forms
|
|
220
|
+
select: 'enum',
|
|
221
|
+
number: 'integer',
|
|
222
|
+
textarea: 'text',
|
|
223
|
+
uuid: 'uuid',
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// These AdminField types must reach the template with their original type
|
|
227
|
+
// string intact — the template switches on it to render the right widget.
|
|
228
|
+
// 'boolean' is intentionally excluded — the template checks 'checkbox'
|
|
229
|
+
// (widget.type from WidgetRegistry), not 'boolean' (AdminField._type).
|
|
230
|
+
const PRESERVE_TYPE = new Set([
|
|
231
|
+
'color', 'richtext', 'phone', 'badge', 'image',
|
|
232
|
+
'date', 'datetime', 'email', 'url', 'password',
|
|
233
|
+
'json', 'number', 'textarea', 'select',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const ormType = REGISTRY_TYPE[f._type] || modelFieldMap[name]?.type || 'string';
|
|
237
|
+
const fieldDef = modelFieldMap[name] || {};
|
|
238
|
+
|
|
239
|
+
// Resolve widget for coerce/validate — per-field override wins
|
|
240
|
+
const override = widgetOverrides[name];
|
|
241
|
+
const widget = override
|
|
242
|
+
? { ...widgetRegistry.resolve(ormType, fieldDef), ...override }
|
|
243
|
+
: widgetRegistry.resolve(ormType, fieldDef);
|
|
244
|
+
|
|
245
|
+
// badge and image have no meaningful form input — always readonly
|
|
246
|
+
const isBadgeOrImage = f._type === 'badge' || f._type === 'image';
|
|
247
|
+
const isReadonly = isBadgeOrImage || readonlySet.has(name) || f._readonly || false;
|
|
248
|
+
|
|
249
|
+
// Template sees the original AdminField type for display types,
|
|
250
|
+
// otherwise the widget type resolved from the registry.
|
|
251
|
+
const templateType = PRESERVE_TYPE.has(f._type) ? f._type : widget.type;
|
|
252
|
+
|
|
253
|
+
// Derive step from decimal scale (e.g. scale=2 → step=0.01)
|
|
254
|
+
let step = null;
|
|
255
|
+
if (ormType === 'decimal' || ormType === 'float') {
|
|
256
|
+
const scale = fieldDef.scale ?? 2;
|
|
257
|
+
step = parseFloat((Math.pow(10, -scale)).toFixed(scale));
|
|
258
|
+
} else if (ormType === 'integer' || ormType === 'bigInteger') {
|
|
259
|
+
step = 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Options for select/enum
|
|
263
|
+
let options = f._options || null;
|
|
264
|
+
if (!options && ormType === 'enum' && fieldDef.enumValues) {
|
|
265
|
+
options = fieldDef.enumValues.map(v => ({ value: v, label: v }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fields.push({
|
|
269
|
+
name,
|
|
270
|
+
label: f._label,
|
|
271
|
+
type: templateType,
|
|
272
|
+
ormType,
|
|
273
|
+
|
|
274
|
+
required: !isBadgeOrImage && !f._nullable && !fieldDef.nullable,
|
|
275
|
+
nullable: isBadgeOrImage || !!(f._nullable || fieldDef.nullable),
|
|
276
|
+
readonly: isReadonly,
|
|
277
|
+
isReadonly,
|
|
278
|
+
hidden: false,
|
|
279
|
+
|
|
280
|
+
tab: currentTab,
|
|
281
|
+
fieldset: currentFieldset,
|
|
282
|
+
span: f._span || null,
|
|
283
|
+
|
|
284
|
+
options,
|
|
285
|
+
placeholder: f._placeholder || null,
|
|
286
|
+
help: f._help || null,
|
|
287
|
+
min: f._min ?? null,
|
|
288
|
+
max: f._max ?? (ormType === 'string' ? (fieldDef.max || null) : null),
|
|
289
|
+
step,
|
|
290
|
+
colors: f._colors || {},
|
|
291
|
+
isLink: f._isLink || false,
|
|
292
|
+
prepopulate: prepopFields[name] || f._prepopulate || null,
|
|
293
|
+
|
|
294
|
+
_coerce: widget.coerce || null,
|
|
295
|
+
_validate: widget.validate || null,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
fields,
|
|
301
|
+
tabs: tabNames,
|
|
302
|
+
hasTab: tabNames.length > 0,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validate a fully coerced data object against the resource's field rules.
|
|
308
|
+
*
|
|
309
|
+
* Called by AdminResource.create() and AdminResource.update() before
|
|
310
|
+
* the ORM write — after _sanitise() has already coerced the types.
|
|
311
|
+
*
|
|
312
|
+
* Returns null if all valid.
|
|
313
|
+
* Returns { fieldName: 'error message', … } if any field fails.
|
|
314
|
+
*
|
|
315
|
+
* @param {class} Resource — AdminResource subclass
|
|
316
|
+
* @param {object} data — coerced data object
|
|
317
|
+
* @param {object} [opts]
|
|
318
|
+
* @param {boolean}[opts.isNew=true]
|
|
319
|
+
* @returns {object|null}
|
|
320
|
+
*/
|
|
321
|
+
static validate(Resource, data, { isNew = true } = {}) {
|
|
322
|
+
const modelFieldMap = Resource.model
|
|
323
|
+
? (typeof Resource.model.getFields === 'function'
|
|
324
|
+
? Resource.model.getFields()
|
|
325
|
+
: (Resource.model.fields || {}))
|
|
326
|
+
: {};
|
|
327
|
+
|
|
328
|
+
const widgetOverrides = Resource.widgets || {};
|
|
329
|
+
const errors = {};
|
|
330
|
+
|
|
331
|
+
for (const f of Resource.fields()) {
|
|
332
|
+
if (f._type === 'tab' || f._type === 'fieldset') continue;
|
|
333
|
+
if (f._type === 'id' || f._listOnly || f._hidden) continue;
|
|
334
|
+
|
|
335
|
+
const name = f._name;
|
|
336
|
+
const ormType = modelFieldMap[name]?.type || 'string';
|
|
337
|
+
const fieldDef = modelFieldMap[name] || {};
|
|
338
|
+
const override = widgetOverrides[name];
|
|
339
|
+
const widget = override
|
|
340
|
+
? { ...widgetRegistry.resolve(ormType, fieldDef), ...override }
|
|
341
|
+
: widgetRegistry.resolve(ormType, fieldDef);
|
|
342
|
+
|
|
343
|
+
const value = data[name];
|
|
344
|
+
|
|
345
|
+
// Skip password validation on edit if left blank (means "keep current")
|
|
346
|
+
if (ormType === 'password' && !isNew && (value === undefined || value === null || value === '')) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (typeof widget.validate === 'function') {
|
|
351
|
+
const err = widget.validate(value, { ...fieldDef, nullable: f._nullable || fieldDef.nullable });
|
|
352
|
+
if (err) errors[name] = err;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Run custom validation hook on the resource if defined
|
|
357
|
+
// static validate(data, errors, isNew) — can add to errors or throw
|
|
358
|
+
if (typeof Resource.validate === 'function') {
|
|
359
|
+
try {
|
|
360
|
+
Resource.validate(data, errors, isNew);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
// If validate() throws an HttpError with errors attached, use those
|
|
363
|
+
if (err.errors) Object.assign(errors, err.errors);
|
|
364
|
+
else errors._form = err.message;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return Object.keys(errors).length > 0 ? errors : null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = { FormGenerator };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HookRegistry + HookPipeline
|
|
5
|
+
*
|
|
6
|
+
* The Millas admin hook system — inspired by Django's signals and WordPress hooks.
|
|
7
|
+
*
|
|
8
|
+
* ── Supported events ──────────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* before_save fired before create or update reaches the ORM
|
|
11
|
+
* ctx: { data, user, isNew, resource }
|
|
12
|
+
* return value replaces data → allows mutation
|
|
13
|
+
*
|
|
14
|
+
* after_save fired after a successful create or update
|
|
15
|
+
* ctx: { record, user, isNew, resource }
|
|
16
|
+
*
|
|
17
|
+
* before_delete fired before destroy() reaches the ORM
|
|
18
|
+
* ctx: { record, user, resource }
|
|
19
|
+
* throw to abort the delete
|
|
20
|
+
*
|
|
21
|
+
* after_delete fired after a successful delete
|
|
22
|
+
* ctx: { id, user, resource }
|
|
23
|
+
*
|
|
24
|
+
* before_render fired before a template is rendered
|
|
25
|
+
* ctx: { view, templateCtx, user, resource }
|
|
26
|
+
* return value replaces templateCtx → allows injection
|
|
27
|
+
*
|
|
28
|
+
* after_render fired after a response is sent (fire-and-forget)
|
|
29
|
+
* ctx: { view, user, resource, ms }
|
|
30
|
+
*
|
|
31
|
+
* before_action fired before a bulk action handler runs
|
|
32
|
+
* ctx: { ids, action, user, resource }
|
|
33
|
+
*
|
|
34
|
+
* after_action fired after a bulk action completes
|
|
35
|
+
* ctx: { ids, action, result, user, resource }
|
|
36
|
+
*
|
|
37
|
+
* ── Hook resolution order ────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* 1. Global hooks (registered via AdminHooks.on()) — run first
|
|
40
|
+
* 2. Resource-level hooks (static methods on AdminConfig subclass)
|
|
41
|
+
* e.g. static async before_save(data, ctx) { ... }
|
|
42
|
+
*
|
|
43
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* const { AdminHooks } = require('./HookRegistry');
|
|
46
|
+
*
|
|
47
|
+
* // Global — runs for every resource
|
|
48
|
+
* AdminHooks.on('after_save', async (ctx) => {
|
|
49
|
+
* await Cache.invalidate(ctx.resource.slug);
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* // Global — abort a delete with a business rule
|
|
53
|
+
* AdminHooks.on('before_delete', async (ctx) => {
|
|
54
|
+
* if (ctx.record.status === 'published') {
|
|
55
|
+
* throw new Error('Cannot delete a published record.');
|
|
56
|
+
* }
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // Per-resource — defined as static methods on AdminConfig subclass
|
|
60
|
+
* class PostAdmin extends AdminConfig {
|
|
61
|
+
* static async before_save(data, ctx) {
|
|
62
|
+
* if (ctx.isNew) data.created_by = ctx.user?.id;
|
|
63
|
+
* return data; // MUST return data (possibly mutated)
|
|
64
|
+
* }
|
|
65
|
+
* static async after_save(record, ctx) {
|
|
66
|
+
* if (ctx.isNew) await Mailer.send('welcome', record);
|
|
67
|
+
* }
|
|
68
|
+
* static async before_delete(record, ctx) {
|
|
69
|
+
* if (record.is_protected) throw new Error('This record is protected.');
|
|
70
|
+
* }
|
|
71
|
+
* }
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
const VALID_EVENTS = new Set([
|
|
75
|
+
'before_save',
|
|
76
|
+
'after_save',
|
|
77
|
+
'before_delete',
|
|
78
|
+
'after_delete',
|
|
79
|
+
'before_render',
|
|
80
|
+
'after_render',
|
|
81
|
+
'before_action',
|
|
82
|
+
'after_action',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
class HookRegistry {
|
|
86
|
+
constructor() {
|
|
87
|
+
/** @type {Map<string, Function[]>} */
|
|
88
|
+
this._hooks = new Map();
|
|
89
|
+
for (const event of VALID_EVENTS) {
|
|
90
|
+
this._hooks.set(event, []);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register a global hook.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} event — one of the VALID_EVENTS
|
|
98
|
+
* @param {Function} handler — async (ctx) => void | data
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {number} [opts.priority=10] — lower runs first (like WordPress)
|
|
101
|
+
*/
|
|
102
|
+
on(event, handler, { priority = 10 } = {}) {
|
|
103
|
+
if (!VALID_EVENTS.has(event)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[AdminHooks] Unknown event "${event}". ` +
|
|
106
|
+
`Valid events: ${[...VALID_EVENTS].join(', ')}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (typeof handler !== 'function') {
|
|
110
|
+
throw new Error(`[AdminHooks] Hook handler for "${event}" must be a function.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
handler._priority = priority;
|
|
114
|
+
this._hooks.get(event).push(handler);
|
|
115
|
+
// Keep sorted by priority after each insertion
|
|
116
|
+
this._hooks.get(event).sort((a, b) => (a._priority || 10) - (b._priority || 10));
|
|
117
|
+
return this; // chainable
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove a previously registered global hook.
|
|
122
|
+
* Pass the exact same function reference used in .on().
|
|
123
|
+
*/
|
|
124
|
+
off(event, handler) {
|
|
125
|
+
if (!this._hooks.has(event)) return this;
|
|
126
|
+
const list = this._hooks.get(event).filter(h => h !== handler);
|
|
127
|
+
this._hooks.set(event, list);
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remove all global hooks for an event (or all events if none specified).
|
|
133
|
+
* Useful in tests to reset state between test cases.
|
|
134
|
+
*/
|
|
135
|
+
clear(event = null) {
|
|
136
|
+
if (event) {
|
|
137
|
+
if (this._hooks.has(event)) this._hooks.set(event, []);
|
|
138
|
+
} else {
|
|
139
|
+
for (const e of VALID_EVENTS) this._hooks.set(e, []);
|
|
140
|
+
}
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return the list of global hooks for an event.
|
|
146
|
+
* @param {string} event
|
|
147
|
+
* @returns {Function[]}
|
|
148
|
+
*/
|
|
149
|
+
getHandlers(event) {
|
|
150
|
+
return this._hooks.get(event) || [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── HookPipeline ──────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
class HookPipeline {
|
|
157
|
+
/**
|
|
158
|
+
* Run the full hook pipeline for an event.
|
|
159
|
+
*
|
|
160
|
+
* Pipeline order:
|
|
161
|
+
* 1. Global hooks (from HookRegistry)
|
|
162
|
+
* 2. Resource-level static method on AdminConfig (if defined)
|
|
163
|
+
*
|
|
164
|
+
* For before_save:
|
|
165
|
+
* - ctx.data is passed to each hook
|
|
166
|
+
* - If a hook returns a non-undefined value, that becomes the new ctx.data
|
|
167
|
+
* - Final ctx.data is what reaches the ORM
|
|
168
|
+
*
|
|
169
|
+
* For before_delete / before_render / before_action:
|
|
170
|
+
* - Hooks may throw to abort the operation
|
|
171
|
+
* - Return value is used if present (for before_render: replaces templateCtx)
|
|
172
|
+
*
|
|
173
|
+
* For after_* events:
|
|
174
|
+
* - Fire-and-forget for after_render (errors are swallowed, never crash the request)
|
|
175
|
+
* - Errors in other after_* hooks are logged but don't affect the response
|
|
176
|
+
*
|
|
177
|
+
* @param {string} event — hook event name
|
|
178
|
+
* @param {object} ctx — context passed to all handlers
|
|
179
|
+
* @param {class} Resource — AdminConfig subclass (may have static hook methods)
|
|
180
|
+
* @param {HookRegistry} registry — the global hook registry
|
|
181
|
+
* @returns {Promise<object>} — possibly mutated ctx
|
|
182
|
+
*/
|
|
183
|
+
static async run(event, ctx, Resource, registry) {
|
|
184
|
+
// ── 1. Global hooks ────────────────────────────────────────────────────
|
|
185
|
+
const globalHandlers = registry.getHandlers(event);
|
|
186
|
+
|
|
187
|
+
for (const handler of globalHandlers) {
|
|
188
|
+
try {
|
|
189
|
+
const result = await handler(ctx);
|
|
190
|
+
// For before_save: allow handler to return mutated data
|
|
191
|
+
if (result !== undefined && event === 'before_save') {
|
|
192
|
+
ctx.data = result;
|
|
193
|
+
}
|
|
194
|
+
// For before_render: allow handler to mutate templateCtx
|
|
195
|
+
if (result !== undefined && event === 'before_render') {
|
|
196
|
+
ctx.templateCtx = result;
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (event.startsWith('after_')) {
|
|
200
|
+
// after_* errors must never crash the response — log and continue
|
|
201
|
+
process.stderr.write(`[AdminHooks] Error in global "${event}" hook: ${err.message}\n`);
|
|
202
|
+
} else {
|
|
203
|
+
throw err; // before_* errors abort the operation
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── 2. Resource-level hook method ──────────────────────────────────────
|
|
209
|
+
// e.g. static async before_save(data, ctx) on the AdminConfig subclass
|
|
210
|
+
if (Resource && typeof Resource[event] === 'function') {
|
|
211
|
+
try {
|
|
212
|
+
// Convention: first arg is the "primary subject", second is the full ctx
|
|
213
|
+
// before_save(data, ctx) → returns mutated data
|
|
214
|
+
// after_save(record, ctx) → no return needed
|
|
215
|
+
// before_delete(record, ctx) → throw to abort
|
|
216
|
+
// after_delete(id, ctx) → no return needed
|
|
217
|
+
// before_render(templateCtx,ctx) → returns mutated templateCtx
|
|
218
|
+
// before_action(ids, ctx) → throw to abort
|
|
219
|
+
let primaryArg;
|
|
220
|
+
switch (event) {
|
|
221
|
+
case 'before_save': primaryArg = ctx.data; break;
|
|
222
|
+
case 'after_save': primaryArg = ctx.record; break;
|
|
223
|
+
case 'before_delete': primaryArg = ctx.record; break;
|
|
224
|
+
case 'after_delete': primaryArg = ctx.id; break;
|
|
225
|
+
case 'before_render': primaryArg = ctx.templateCtx; break;
|
|
226
|
+
case 'after_render': primaryArg = ctx.view; break;
|
|
227
|
+
case 'before_action': primaryArg = ctx.ids; break;
|
|
228
|
+
case 'after_action': primaryArg = ctx.ids; break;
|
|
229
|
+
default: primaryArg = ctx;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = await Resource[event](primaryArg, ctx);
|
|
233
|
+
|
|
234
|
+
if (result !== undefined && event === 'before_save') {
|
|
235
|
+
ctx.data = result;
|
|
236
|
+
}
|
|
237
|
+
if (result !== undefined && event === 'before_render') {
|
|
238
|
+
ctx.templateCtx = result;
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (event.startsWith('after_')) {
|
|
242
|
+
process.stderr.write(`[AdminHooks] Error in ${Resource.name}.${event}: ${err.message}\n`);
|
|
243
|
+
} else {
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return ctx;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Singleton global registry ─────────────────────────────────────────────────
|
|
254
|
+
const AdminHooks = new HookRegistry();
|
|
255
|
+
|
|
256
|
+
module.exports = { HookRegistry, HookPipeline, AdminHooks, VALID_EVENTS };
|