@shamar/adonis 0.1.0
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/assets/admin.css +2 -0
- package/assets/alpine.min.js +5 -0
- package/assets/input.css +2286 -0
- package/assets/shamar-ui.js +3658 -0
- package/dist/assets-controller.d.ts +12 -0
- package/dist/assets-controller.d.ts.map +1 -0
- package/dist/assets-controller.js +32 -0
- package/dist/assets-controller.js.map +1 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +21 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +20 -0
- package/dist/configure.js.map +1 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/controller.d.ts +31 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +45 -0
- package/dist/controller.js.map +1 -0
- package/dist/controllers/admin_controller.d.ts +72 -0
- package/dist/controllers/admin_controller.d.ts.map +1 -0
- package/dist/controllers/admin_controller.js +1075 -0
- package/dist/controllers/admin_controller.js.map +1 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +50 -0
- package/dist/discover.js.map +1 -0
- package/dist/form-state.d.ts +41 -0
- package/dist/form-state.d.ts.map +1 -0
- package/dist/form-state.js +74 -0
- package/dist/form-state.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/require_api_key_middleware.d.ts +30 -0
- package/dist/middleware/require_api_key_middleware.d.ts.map +1 -0
- package/dist/middleware/require_api_key_middleware.js +59 -0
- package/dist/middleware/require_api_key_middleware.js.map +1 -0
- package/dist/provider.d.ts +9 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +66 -0
- package/dist/provider.js.map +1 -0
- package/dist/resources/api_key_resource.d.ts +35 -0
- package/dist/resources/api_key_resource.d.ts.map +1 -0
- package/dist/resources/api_key_resource.js +225 -0
- package/dist/resources/api_key_resource.js.map +1 -0
- package/dist/routes.d.ts +5 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +97 -0
- package/dist/routes.js.map +1 -0
- package/dist/runtime.d.ts +27 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +74 -0
- package/dist/runtime.js.map +1 -0
- package/dist/shamar/auth.d.ts +30 -0
- package/dist/shamar/auth.d.ts.map +1 -0
- package/dist/shamar/auth.js +218 -0
- package/dist/shamar/auth.js.map +1 -0
- package/dist/shamar/branding.d.ts +24 -0
- package/dist/shamar/branding.d.ts.map +1 -0
- package/dist/shamar/branding.js +91 -0
- package/dist/shamar/branding.js.map +1 -0
- package/dist/shamar/display-relations.d.ts +7 -0
- package/dist/shamar/display-relations.d.ts.map +1 -0
- package/dist/shamar/display-relations.js +89 -0
- package/dist/shamar/display-relations.js.map +1 -0
- package/dist/shamar/list-headers.d.ts +38 -0
- package/dist/shamar/list-headers.d.ts.map +1 -0
- package/dist/shamar/list-headers.js +218 -0
- package/dist/shamar/list-headers.js.map +1 -0
- package/dist/shamar/list-query.d.ts +229 -0
- package/dist/shamar/list-query.d.ts.map +1 -0
- package/dist/shamar/list-query.js +564 -0
- package/dist/shamar/list-query.js.map +1 -0
- package/dist/shamar/menu.d.ts +48 -0
- package/dist/shamar/menu.d.ts.map +1 -0
- package/dist/shamar/menu.js +93 -0
- package/dist/shamar/menu.js.map +1 -0
- package/dist/shamar/paths.d.ts +5 -0
- package/dist/shamar/paths.d.ts.map +1 -0
- package/dist/shamar/paths.js +16 -0
- package/dist/shamar/paths.js.map +1 -0
- package/dist/shamar/relation-fields.d.ts +54 -0
- package/dist/shamar/relation-fields.d.ts.map +1 -0
- package/dist/shamar/relation-fields.js +94 -0
- package/dist/shamar/relation-fields.js.map +1 -0
- package/dist/shamar/resource-actions.d.ts +9 -0
- package/dist/shamar/resource-actions.d.ts.map +1 -0
- package/dist/shamar/resource-actions.js +42 -0
- package/dist/shamar/resource-actions.js.map +1 -0
- package/dist/shamar/view-context.d.ts +79 -0
- package/dist/shamar/view-context.d.ts.map +1 -0
- package/dist/shamar/view-context.js +74 -0
- package/dist/shamar/view-context.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/resources/views/shamar/dashboard.edge +24 -0
- package/resources/views/shamar/form.edge +87 -0
- package/resources/views/shamar/index.edge +149 -0
- package/resources/views/shamar/partials/boolean-value.edge +25 -0
- package/resources/views/shamar/partials/csrf.edge +1 -0
- package/resources/views/shamar/partials/detail-entry.edge +72 -0
- package/resources/views/shamar/partials/dialog-host.edge +125 -0
- package/resources/views/shamar/partials/field-affix-icon.edge +23 -0
- package/resources/views/shamar/partials/field-input.edge +585 -0
- package/resources/views/shamar/partials/field-relation-checkbox.edge +104 -0
- package/resources/views/shamar/partials/field-relation-m2m.edge +110 -0
- package/resources/views/shamar/partials/field-relation-m2o.edge +116 -0
- package/resources/views/shamar/partials/field-relation-table.edge +139 -0
- package/resources/views/shamar/partials/form-container.edge +82 -0
- package/resources/views/shamar/partials/icon.edge +14 -0
- package/resources/views/shamar/partials/list-row.edge +73 -0
- package/resources/views/shamar/partials/list-toolbar.edge +269 -0
- package/resources/views/shamar/partials/nav-mobile.edge +21 -0
- package/resources/views/shamar/partials/nav.edge +58 -0
- package/resources/views/shamar/partials/page-heading.edge +130 -0
- package/resources/views/shamar/partials/pagination.edge +73 -0
- package/resources/views/shamar/partials/record-pager.edge +37 -0
- package/resources/views/shamar/partials/schema-node.edge +289 -0
- package/resources/views/shamar/partials/shell-close.edge +20 -0
- package/resources/views/shamar/partials/shell-open.edge +51 -0
- package/resources/views/shamar/partials/toast.edge +5 -0
- package/resources/views/shamar/partials/topbar.edge +115 -0
- package/resources/views/shamar/show.edge +17 -0
- package/stubs/config/shamar.stub +29 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
import { resolveGridItemStyle, isValidationException } from '@shamar/core';
|
|
2
|
+
import { Authorizer as AuthorizerClass } from '@shamar/cherubim';
|
|
3
|
+
import { ResourceController } from '../controller.js';
|
|
4
|
+
import { authRequired, assertResourceAccess, buildAuthContext, ForbiddenError, resourcePolicyFlags, respondForbidden, respondUnauthorized, UnauthorizedError, } from '../shamar/auth.js';
|
|
5
|
+
import { buildListContext, buildShellContext, readFlash, } from '../shamar/view-context.js';
|
|
6
|
+
import { decorateRevokedStatus, resourceActionsFor, visibleRowActions, } from '../shamar/resource-actions.js';
|
|
7
|
+
import { buildListHeaders, groupRecordsForDisplay, labelListFilters, resolveDefaultFilters, } from '../shamar/list-headers.js';
|
|
8
|
+
import { formSections, formSchemaTree, detailSections, detailSchemaTree, normalizeListQuery, recordTitle, LIST_ALL_RECORDS_PER_PAGE, RECORD_NAV_CAP, recordNavQuery, buildRecordPager, toFormControlValue, parseCurrencyInput, } from '../shamar/list-query.js';
|
|
9
|
+
import { evaluateFormState, formClientFields } from '../form-state.js';
|
|
10
|
+
import { buildRelationUiConfig, collectRelationIds, isRelationField, recordSummaryLabel, resolveRelatedMeta, relationTitleAttribute, } from '../shamar/relation-fields.js';
|
|
11
|
+
import { hydrateRecordsForDisplay } from '../shamar/display-relations.js';
|
|
12
|
+
export class AdminController {
|
|
13
|
+
panel;
|
|
14
|
+
config;
|
|
15
|
+
resources;
|
|
16
|
+
basePath;
|
|
17
|
+
registry;
|
|
18
|
+
panelBranding;
|
|
19
|
+
authorizer;
|
|
20
|
+
constructor(panel, config, authorizer) {
|
|
21
|
+
this.panel = panel;
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.registry = panel.registry;
|
|
24
|
+
this.basePath = panel.path;
|
|
25
|
+
this.panelBranding = panel.config.branding ?? config.branding;
|
|
26
|
+
this.resources = new ResourceController(panel.adapter);
|
|
27
|
+
this.authorizer = authorizer ?? new AuthorizerClass();
|
|
28
|
+
}
|
|
29
|
+
isAuthContext(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && 'user' in value;
|
|
31
|
+
}
|
|
32
|
+
async resolveAuth(ctx) {
|
|
33
|
+
return buildAuthContext(ctx, this.config, this.panel.id);
|
|
34
|
+
}
|
|
35
|
+
async ensureAuthenticated(ctx, asJson = false) {
|
|
36
|
+
const authCtx = await this.resolveAuth(ctx);
|
|
37
|
+
const json = asJson || this.wantsJson(ctx);
|
|
38
|
+
if (authRequired(this.config) && !authCtx.user) {
|
|
39
|
+
return respondUnauthorized(ctx, this.config, json);
|
|
40
|
+
}
|
|
41
|
+
return authCtx;
|
|
42
|
+
}
|
|
43
|
+
async ensureResourceAction(ctx, meta, action, record, asJson = false) {
|
|
44
|
+
const authResult = await this.ensureAuthenticated(ctx, asJson);
|
|
45
|
+
if (!this.isAuthContext(authResult))
|
|
46
|
+
return authResult;
|
|
47
|
+
const authCtx = authResult;
|
|
48
|
+
if (!authRequired(this.config))
|
|
49
|
+
return authCtx;
|
|
50
|
+
try {
|
|
51
|
+
assertResourceAccess(this.authorizer, authCtx, this.registry, meta, action, record);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof ForbiddenError) {
|
|
55
|
+
return respondForbidden(ctx, error.message, asJson || this.wantsJson(ctx), this.basePath);
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof UnauthorizedError) {
|
|
58
|
+
return respondUnauthorized(ctx, this.config, asJson || this.wantsJson(ctx));
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return authCtx;
|
|
63
|
+
}
|
|
64
|
+
shellOpts(ctx, authCtx, extras) {
|
|
65
|
+
const policy = extras.meta && authCtx.user
|
|
66
|
+
? resourcePolicyFlags(this.authorizer, authCtx, this.registry, extras.meta, extras.record)
|
|
67
|
+
: null;
|
|
68
|
+
const { showCreateButton, showEditButton, showDeleteButton, record, ...rest } = extras;
|
|
69
|
+
return buildShellContext({
|
|
70
|
+
config: this.config,
|
|
71
|
+
registry: this.registry,
|
|
72
|
+
basePath: this.basePath,
|
|
73
|
+
branding: this.panelBranding,
|
|
74
|
+
authorizer: this.authorizer,
|
|
75
|
+
authCtx,
|
|
76
|
+
flash: readFlash(ctx),
|
|
77
|
+
showCreateButton: showCreateButton === true ? policy?.create ?? true : showCreateButton,
|
|
78
|
+
showEditButton: showEditButton === true ? policy?.update ?? true : showEditButton,
|
|
79
|
+
showDeleteButton: showDeleteButton === true ? policy?.delete ?? true : showDeleteButton,
|
|
80
|
+
...rest,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async dashboard(ctx) {
|
|
84
|
+
const authResult = await this.ensureAuthenticated(ctx);
|
|
85
|
+
if (!this.isAuthContext(authResult))
|
|
86
|
+
return authResult;
|
|
87
|
+
const authCtx = authResult;
|
|
88
|
+
const shell = this.shellOpts(ctx, authCtx, { pageTitle: 'Dashboard' });
|
|
89
|
+
const dashboardCards = shell.menuRoots.filter((root) => root.label !== 'Dashboard');
|
|
90
|
+
return ctx.view.render('shamar::dashboard', {
|
|
91
|
+
...shell,
|
|
92
|
+
dashboardCards,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async index(ctx, options) {
|
|
96
|
+
const meta = this.requireResource(ctx);
|
|
97
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'viewAny', undefined, options?.asJson);
|
|
98
|
+
if (!this.isAuthContext(authResult))
|
|
99
|
+
return authResult;
|
|
100
|
+
const authCtx = authResult;
|
|
101
|
+
const rawQuery = this.listQuery(ctx);
|
|
102
|
+
const listHeaders = buildListHeaders(meta);
|
|
103
|
+
const filtersParam = ctx.request.input('filters');
|
|
104
|
+
const groupByParam = ctx.request.input('groupBy');
|
|
105
|
+
const hasFiltersParam = filtersParam !== undefined && filtersParam !== null;
|
|
106
|
+
const hasGroupByParam = groupByParam !== undefined && groupByParam !== null;
|
|
107
|
+
const normalized = normalizeListQuery(rawQuery);
|
|
108
|
+
if (!hasFiltersParam && meta.defaultFilters?.length) {
|
|
109
|
+
normalized.filters = resolveDefaultFilters(meta, listHeaders);
|
|
110
|
+
}
|
|
111
|
+
else if (normalized.filters?.length) {
|
|
112
|
+
normalized.filters = labelListFilters(normalized.filters, listHeaders);
|
|
113
|
+
}
|
|
114
|
+
if (!hasGroupByParam && meta.defaultGroupBy) {
|
|
115
|
+
normalized.groupBy = meta.defaultGroupBy;
|
|
116
|
+
}
|
|
117
|
+
else if (hasGroupByParam && String(groupByParam).trim() === '') {
|
|
118
|
+
normalized.groupBy = undefined;
|
|
119
|
+
}
|
|
120
|
+
// Adapter sorts/filters on FK fields; display grouping uses the column path.
|
|
121
|
+
const displayGroupBy = normalized.groupBy;
|
|
122
|
+
const groupHeader = listHeaders.find((header) => header.name === displayGroupBy || header.filterField === displayGroupBy);
|
|
123
|
+
const adapterGroupBy = groupHeader?.filterField || displayGroupBy;
|
|
124
|
+
const query = this.withPolicyScope(authCtx, meta, {
|
|
125
|
+
...normalized,
|
|
126
|
+
groupBy: adapterGroupBy,
|
|
127
|
+
});
|
|
128
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
129
|
+
const result = await this.resources.index(meta, query);
|
|
130
|
+
await hydrateRecordsForDisplay(meta, result.items, this.registry, this.panel.adapter);
|
|
131
|
+
decorateRevokedStatus(result.items);
|
|
132
|
+
return ctx.response.json(result);
|
|
133
|
+
}
|
|
134
|
+
const result = await this.resources.index(meta, query);
|
|
135
|
+
await hydrateRecordsForDisplay(meta, result.items, this.registry, this.panel.adapter);
|
|
136
|
+
decorateRevokedStatus(result.items);
|
|
137
|
+
const { query: viewQuery, pagination, perPageValue } = buildListContext({
|
|
138
|
+
basePath: this.basePath,
|
|
139
|
+
meta,
|
|
140
|
+
query: rawQuery,
|
|
141
|
+
result,
|
|
142
|
+
});
|
|
143
|
+
const policy = resourcePolicyFlags(this.authorizer, authCtx, this.registry, meta);
|
|
144
|
+
const shell = this.shellOpts(ctx, authCtx, {
|
|
145
|
+
meta,
|
|
146
|
+
pageTitle: meta.label,
|
|
147
|
+
showCreateButton: policy.create,
|
|
148
|
+
showEditButton: policy.update,
|
|
149
|
+
showDeleteButton: policy.delete,
|
|
150
|
+
});
|
|
151
|
+
const groups = groupRecordsForDisplay(result.items, displayGroupBy, listHeaders);
|
|
152
|
+
return ctx.view.render('shamar::index', {
|
|
153
|
+
...shell,
|
|
154
|
+
meta,
|
|
155
|
+
resource: meta,
|
|
156
|
+
result,
|
|
157
|
+
groups,
|
|
158
|
+
query: {
|
|
159
|
+
...viewQuery,
|
|
160
|
+
perPage: perPageValue,
|
|
161
|
+
filters: normalized.filters ?? [],
|
|
162
|
+
groupBy: displayGroupBy ?? null,
|
|
163
|
+
},
|
|
164
|
+
pagination,
|
|
165
|
+
listHeaders,
|
|
166
|
+
listAllPerPage: LIST_ALL_RECORDS_PER_PAGE,
|
|
167
|
+
filtersLockedEmpty: hasFiltersParam && !(normalized.filters?.length),
|
|
168
|
+
groupLockedEmpty: hasGroupByParam && !displayGroupBy,
|
|
169
|
+
bulkActions: resourceActionsFor(meta, 'bulk', policy),
|
|
170
|
+
rowActions: resourceActionsFor(meta, 'row', policy),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async create(ctx, options) {
|
|
174
|
+
const meta = this.requireResource(ctx);
|
|
175
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'create', undefined, options?.asJson);
|
|
176
|
+
if (!this.isAuthContext(authResult))
|
|
177
|
+
return authResult;
|
|
178
|
+
const authCtx = authResult;
|
|
179
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
180
|
+
return ctx.response.json({ meta, fields: meta.fields });
|
|
181
|
+
}
|
|
182
|
+
const embed = this.isEmbed(ctx);
|
|
183
|
+
const policy = resourcePolicyFlags(this.authorizer, authCtx, this.registry, meta);
|
|
184
|
+
const shell = this.shellOpts(ctx, authCtx, {
|
|
185
|
+
meta,
|
|
186
|
+
pageTitle: `New ${meta.singularLabel}`,
|
|
187
|
+
showBackToList: false,
|
|
188
|
+
recordBreadcrumb: { mode: 'create' },
|
|
189
|
+
});
|
|
190
|
+
const initialState = this.initialFormState(meta, null, 'create');
|
|
191
|
+
this.applyCreateQueryDefaults(ctx, meta, initialState);
|
|
192
|
+
const relationUi = await this.buildRelationUiMap(meta, null, 'create', initialState);
|
|
193
|
+
return ctx.view.render('shamar::form', {
|
|
194
|
+
...shell,
|
|
195
|
+
meta,
|
|
196
|
+
resource: meta,
|
|
197
|
+
record: null,
|
|
198
|
+
mode: 'create',
|
|
199
|
+
embed,
|
|
200
|
+
/** Create-once resources (canEdit false) redirect to show after AJAX create. */
|
|
201
|
+
redirectAfterCreateToShow: !policy.update,
|
|
202
|
+
formSchema: formSchemaTree(meta),
|
|
203
|
+
formSections: formSections(meta),
|
|
204
|
+
formLiveFields: formClientFields(meta, {
|
|
205
|
+
state: initialState,
|
|
206
|
+
record: null,
|
|
207
|
+
operation: 'create',
|
|
208
|
+
}),
|
|
209
|
+
formStateEndpoint: `${this.basePath}/${meta.slug}/form-state`,
|
|
210
|
+
formInitialState: initialState,
|
|
211
|
+
formErrors: {},
|
|
212
|
+
relationUi,
|
|
213
|
+
recordPager: null,
|
|
214
|
+
resolveGridItemStyle,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async store(ctx, options) {
|
|
218
|
+
const meta = this.requireResource(ctx);
|
|
219
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'create', undefined, options?.asJson);
|
|
220
|
+
if (!this.isAuthContext(authResult))
|
|
221
|
+
return authResult;
|
|
222
|
+
let data = this.resourcePayload(meta, ctx, 'create');
|
|
223
|
+
let flashPlainText;
|
|
224
|
+
let flashMessage;
|
|
225
|
+
try {
|
|
226
|
+
const ResourceClass = this.registry.resourceClass(meta.slug);
|
|
227
|
+
if (ResourceClass?.prepareCreate) {
|
|
228
|
+
const prepared = await ResourceClass.prepareCreate(data, {
|
|
229
|
+
adapter: this.panel.adapter,
|
|
230
|
+
meta,
|
|
231
|
+
userId: authResult.user?.id ?? null,
|
|
232
|
+
});
|
|
233
|
+
data = prepared.data;
|
|
234
|
+
flashPlainText = prepared.flashPlainText;
|
|
235
|
+
flashMessage = prepared.flashMessage;
|
|
236
|
+
}
|
|
237
|
+
const record = await this.resources.store(meta, data);
|
|
238
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
239
|
+
// AJAX create shows a copy-and-confirm modal; only flash a short success for after.
|
|
240
|
+
if (flashPlainText) {
|
|
241
|
+
ctx.session.flash('success', `${meta.singularLabel} created`);
|
|
242
|
+
}
|
|
243
|
+
return ctx.response.created({
|
|
244
|
+
...record,
|
|
245
|
+
...(flashPlainText ? { plainText: flashPlainText } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return this.redirectAfterSave(ctx, meta, String(record.id), 'created', {
|
|
249
|
+
flashPlainText,
|
|
250
|
+
flashMessage,
|
|
251
|
+
authCtx: authResult,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return this.handleFormValidationError(ctx, meta, error, {
|
|
256
|
+
mode: 'create',
|
|
257
|
+
record: null,
|
|
258
|
+
data,
|
|
259
|
+
asJson: options?.asJson,
|
|
260
|
+
authCtx: authResult,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async show(ctx, options) {
|
|
265
|
+
const meta = this.requireResource(ctx);
|
|
266
|
+
const { id } = ctx.params;
|
|
267
|
+
const record = await this.resources.show(meta, id);
|
|
268
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'view', record, options?.asJson);
|
|
269
|
+
if (!this.isAuthContext(authResult))
|
|
270
|
+
return authResult;
|
|
271
|
+
const authCtx = authResult;
|
|
272
|
+
await hydrateRecordsForDisplay(meta, [record], this.registry, this.panel.adapter);
|
|
273
|
+
decorateRevokedStatus([record]);
|
|
274
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
275
|
+
return ctx.response.json(record);
|
|
276
|
+
}
|
|
277
|
+
const title = recordTitle(meta, record);
|
|
278
|
+
const policy = resourcePolicyFlags(this.authorizer, authCtx, this.registry, meta, record);
|
|
279
|
+
const shell = this.shellOpts(ctx, authCtx, {
|
|
280
|
+
meta,
|
|
281
|
+
record,
|
|
282
|
+
pageTitle: title,
|
|
283
|
+
showEditButton: policy.update,
|
|
284
|
+
showDeleteButton: policy.delete,
|
|
285
|
+
showBackToList: false,
|
|
286
|
+
recordBreadcrumb: {
|
|
287
|
+
mode: 'show',
|
|
288
|
+
recordTitle: title,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const relationUi = await this.buildRelationUiMap(meta, record, 'show', record);
|
|
292
|
+
return ctx.view.render('shamar::show', {
|
|
293
|
+
...shell,
|
|
294
|
+
meta,
|
|
295
|
+
resource: meta,
|
|
296
|
+
record,
|
|
297
|
+
id: record.id,
|
|
298
|
+
detailSchema: detailSchemaTree(meta),
|
|
299
|
+
detailSections: detailSections(meta),
|
|
300
|
+
relationUi,
|
|
301
|
+
formErrors: {},
|
|
302
|
+
recordPager: await this.resolveRecordPager(ctx, meta, String(record.id), 'show'),
|
|
303
|
+
resolveGridItemStyle,
|
|
304
|
+
recordActions: visibleRowActions(meta, policy, record),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async edit(ctx, options) {
|
|
308
|
+
const meta = this.requireResource(ctx);
|
|
309
|
+
const { id } = ctx.params;
|
|
310
|
+
const record = await this.resources.show(meta, id);
|
|
311
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'update', record, options?.asJson);
|
|
312
|
+
if (!this.isAuthContext(authResult))
|
|
313
|
+
return authResult;
|
|
314
|
+
const authCtx = authResult;
|
|
315
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
316
|
+
return ctx.response.json(record);
|
|
317
|
+
}
|
|
318
|
+
const embed = this.isEmbed(ctx);
|
|
319
|
+
const title = recordTitle(meta, record);
|
|
320
|
+
const navQuery = recordNavQuery(this.listQuery(ctx));
|
|
321
|
+
const policy = resourcePolicyFlags(this.authorizer, authCtx, this.registry, meta, record);
|
|
322
|
+
const shell = this.shellOpts(ctx, authCtx, {
|
|
323
|
+
meta,
|
|
324
|
+
record,
|
|
325
|
+
pageTitle: `Edit ${title}`,
|
|
326
|
+
showDeleteButton: policy.delete,
|
|
327
|
+
showBackToList: false,
|
|
328
|
+
recordBreadcrumb: {
|
|
329
|
+
mode: 'edit',
|
|
330
|
+
recordTitle: title,
|
|
331
|
+
recordHref: `${this.basePath}/${meta.slug}/${record.id}${navQuery}`,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const formInitialState = this.initialFormState(meta, record, 'edit');
|
|
335
|
+
const relationUi = await this.buildRelationUiMap(meta, record, 'edit', formInitialState);
|
|
336
|
+
return ctx.view.render('shamar::form', {
|
|
337
|
+
...shell,
|
|
338
|
+
meta,
|
|
339
|
+
resource: meta,
|
|
340
|
+
record,
|
|
341
|
+
id: record.id,
|
|
342
|
+
mode: 'edit',
|
|
343
|
+
embed,
|
|
344
|
+
formSchema: formSchemaTree(meta),
|
|
345
|
+
formSections: formSections(meta),
|
|
346
|
+
formLiveFields: formClientFields(meta, {
|
|
347
|
+
state: formInitialState,
|
|
348
|
+
record,
|
|
349
|
+
operation: 'edit',
|
|
350
|
+
}),
|
|
351
|
+
formStateEndpoint: `${this.basePath}/${meta.slug}/form-state`,
|
|
352
|
+
formInitialState,
|
|
353
|
+
formErrors: {},
|
|
354
|
+
relationUi,
|
|
355
|
+
recordPager: embed
|
|
356
|
+
? null
|
|
357
|
+
: await this.resolveRecordPager(ctx, meta, String(record.id), 'edit'),
|
|
358
|
+
resolveGridItemStyle,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async formState(ctx) {
|
|
362
|
+
const meta = this.requireResource(ctx);
|
|
363
|
+
const body = ctx.request.body();
|
|
364
|
+
const operation = body.operation === 'edit' || body.operation === 'view' || body.operation === 'create'
|
|
365
|
+
? body.operation
|
|
366
|
+
: 'edit';
|
|
367
|
+
let record = null;
|
|
368
|
+
if (body.id) {
|
|
369
|
+
try {
|
|
370
|
+
record = await this.resources.show(meta, String(body.id));
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
record = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const action = operation === 'create' ? 'create' : operation === 'view' ? 'view' : 'update';
|
|
377
|
+
const authResult = await this.ensureResourceAction(ctx, meta, action, record ?? undefined, true);
|
|
378
|
+
if (!this.isAuthContext(authResult))
|
|
379
|
+
return authResult;
|
|
380
|
+
const result = await evaluateFormState(meta, {
|
|
381
|
+
operation,
|
|
382
|
+
changed: body.changed,
|
|
383
|
+
state: body.state ?? {},
|
|
384
|
+
record,
|
|
385
|
+
});
|
|
386
|
+
return ctx.response.json(result);
|
|
387
|
+
}
|
|
388
|
+
async summary(ctx) {
|
|
389
|
+
const meta = this.requireResource(ctx);
|
|
390
|
+
const { id } = ctx.params;
|
|
391
|
+
const record = await this.resources.show(meta, id);
|
|
392
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'view', record, true);
|
|
393
|
+
if (!this.isAuthContext(authResult))
|
|
394
|
+
return authResult;
|
|
395
|
+
return ctx.response.json({
|
|
396
|
+
id: String(record.id),
|
|
397
|
+
label: recordSummaryLabel(meta, record),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async relationSearch(ctx) {
|
|
401
|
+
const meta = this.requireResource(ctx);
|
|
402
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'viewAny', undefined, true);
|
|
403
|
+
if (!this.isAuthContext(authResult))
|
|
404
|
+
return authResult;
|
|
405
|
+
const fieldName = String(ctx.request.input('field') ?? '');
|
|
406
|
+
const field = meta.fields.find((entry) => entry.name === fieldName);
|
|
407
|
+
if (!field?.relation) {
|
|
408
|
+
return ctx.response.badRequest({ message: 'Unknown relation field' });
|
|
409
|
+
}
|
|
410
|
+
const related = resolveRelatedMeta(this.registry, field.relation);
|
|
411
|
+
const titleAttribute = relationTitleAttribute(field.relation);
|
|
412
|
+
const q = ctx.request.input('q');
|
|
413
|
+
const limitRaw = Number(ctx.request.input('limit') ?? field.relation.preloadLimit ?? 25);
|
|
414
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
|
|
415
|
+
const parentId = ctx.request.input('parentId');
|
|
416
|
+
const linkedOnly = ctx.request.input('linked') === '1' ||
|
|
417
|
+
ctx.request.input('linked') === 1 ||
|
|
418
|
+
ctx.request.input('linked') === true;
|
|
419
|
+
const scope = {};
|
|
420
|
+
if (linkedOnly &&
|
|
421
|
+
field.relation.kind === 'hasMany' &&
|
|
422
|
+
field.relation.foreignKey &&
|
|
423
|
+
parentId) {
|
|
424
|
+
scope[field.relation.foreignKey] = String(parentId);
|
|
425
|
+
}
|
|
426
|
+
const results = await this.panel.adapter.search(related, {
|
|
427
|
+
q: q != null ? String(q) : undefined,
|
|
428
|
+
limit,
|
|
429
|
+
titleAttribute,
|
|
430
|
+
scope: Object.keys(scope).length ? scope : undefined,
|
|
431
|
+
});
|
|
432
|
+
return ctx.response.json({ results });
|
|
433
|
+
}
|
|
434
|
+
async relationQuickCreate(ctx) {
|
|
435
|
+
const meta = this.requireResource(ctx);
|
|
436
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'create', undefined, true);
|
|
437
|
+
if (!this.isAuthContext(authResult))
|
|
438
|
+
return authResult;
|
|
439
|
+
const body = ctx.request.body();
|
|
440
|
+
const fieldName = String(body.field ?? '');
|
|
441
|
+
const field = meta.fields.find((entry) => entry.name === fieldName);
|
|
442
|
+
if (!field?.relation) {
|
|
443
|
+
return ctx.response.badRequest({ message: 'Unknown relation field' });
|
|
444
|
+
}
|
|
445
|
+
if (!field.relation.createOption) {
|
|
446
|
+
return ctx.response.badRequest({ message: 'Inline create is not enabled for this field' });
|
|
447
|
+
}
|
|
448
|
+
const name = String(body.name ?? '').trim();
|
|
449
|
+
if (!name) {
|
|
450
|
+
return ctx.response.badRequest({ message: 'Name is required' });
|
|
451
|
+
}
|
|
452
|
+
const related = resolveRelatedMeta(this.registry, field.relation);
|
|
453
|
+
const titleAttribute = relationTitleAttribute(field.relation);
|
|
454
|
+
const payload = { [titleAttribute]: name };
|
|
455
|
+
if (field.relation.kind === 'hasMany' &&
|
|
456
|
+
field.relation.foreignKey &&
|
|
457
|
+
body.parentId) {
|
|
458
|
+
payload[field.relation.foreignKey] = String(body.parentId);
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const record = await this.panel.adapter.create(related, payload);
|
|
462
|
+
return ctx.response.json({
|
|
463
|
+
id: String(record.id),
|
|
464
|
+
label: recordSummaryLabel(related, record, titleAttribute),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
const message = error instanceof Error ? error.message : 'Cannot create this record inline.';
|
|
469
|
+
return ctx.response.badRequest({ message });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async relationAttach(ctx) {
|
|
473
|
+
const meta = this.requireResource(ctx);
|
|
474
|
+
const body = ctx.request.body();
|
|
475
|
+
let parentRecord;
|
|
476
|
+
if (body.parentId) {
|
|
477
|
+
try {
|
|
478
|
+
parentRecord = await this.resources.show(meta, String(body.parentId));
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
parentRecord = undefined;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'update', parentRecord, true);
|
|
485
|
+
if (!this.isAuthContext(authResult))
|
|
486
|
+
return authResult;
|
|
487
|
+
const field = meta.fields.find((entry) => entry.name === String(body.field ?? ''));
|
|
488
|
+
if (!field?.relation || field.relation.kind !== 'hasMany' || !field.relation.foreignKey) {
|
|
489
|
+
return ctx.response.badRequest({ message: 'Attach requires a hasMany relation field' });
|
|
490
|
+
}
|
|
491
|
+
if (!body.relatedId || !body.parentId) {
|
|
492
|
+
return ctx.response.badRequest({ message: 'relatedId and parentId are required' });
|
|
493
|
+
}
|
|
494
|
+
const related = resolveRelatedMeta(this.registry, field.relation);
|
|
495
|
+
const titleAttribute = relationTitleAttribute(field.relation);
|
|
496
|
+
const record = await this.panel.adapter.update(related, String(body.relatedId), {
|
|
497
|
+
[field.relation.foreignKey]: String(body.parentId),
|
|
498
|
+
});
|
|
499
|
+
return ctx.response.json({
|
|
500
|
+
id: String(record.id),
|
|
501
|
+
label: recordSummaryLabel(related, record, titleAttribute),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
async relationDetach(ctx) {
|
|
505
|
+
const meta = this.requireResource(ctx);
|
|
506
|
+
const body = ctx.request.body();
|
|
507
|
+
let parentRecord;
|
|
508
|
+
if (body.parentId) {
|
|
509
|
+
try {
|
|
510
|
+
parentRecord = await this.resources.show(meta, String(body.parentId));
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
parentRecord = undefined;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'update', parentRecord, true);
|
|
517
|
+
if (!this.isAuthContext(authResult))
|
|
518
|
+
return authResult;
|
|
519
|
+
const field = meta.fields.find((entry) => entry.name === String(body.field ?? ''));
|
|
520
|
+
if (!field?.relation || field.relation.kind !== 'hasMany' || !field.relation.foreignKey) {
|
|
521
|
+
return ctx.response.badRequest({ message: 'Detach requires a hasMany relation field' });
|
|
522
|
+
}
|
|
523
|
+
if (!body.relatedId) {
|
|
524
|
+
return ctx.response.badRequest({ message: 'relatedId is required' });
|
|
525
|
+
}
|
|
526
|
+
const related = resolveRelatedMeta(this.registry, field.relation);
|
|
527
|
+
await this.panel.adapter.update(related, String(body.relatedId), {
|
|
528
|
+
[field.relation.foreignKey]: null,
|
|
529
|
+
});
|
|
530
|
+
return ctx.response.json({ ok: true });
|
|
531
|
+
}
|
|
532
|
+
async update(ctx, options) {
|
|
533
|
+
const meta = this.requireResource(ctx);
|
|
534
|
+
const { id } = ctx.params;
|
|
535
|
+
let existing = null;
|
|
536
|
+
try {
|
|
537
|
+
existing = await this.resources.show(meta, id);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
existing = null;
|
|
541
|
+
}
|
|
542
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'update', existing ?? undefined, options?.asJson);
|
|
543
|
+
if (!this.isAuthContext(authResult))
|
|
544
|
+
return authResult;
|
|
545
|
+
const data = this.resourcePayload(meta, ctx, 'update');
|
|
546
|
+
try {
|
|
547
|
+
const record = await this.resources.update(meta, id, data);
|
|
548
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
549
|
+
return ctx.response.json(record);
|
|
550
|
+
}
|
|
551
|
+
return this.redirectAfterSave(ctx, meta, String(record.id), 'updated');
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
let record = existing;
|
|
555
|
+
if (!record) {
|
|
556
|
+
try {
|
|
557
|
+
record = await this.resources.show(meta, id);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
record = { id, ...data };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return this.handleFormValidationError(ctx, meta, error, {
|
|
564
|
+
mode: 'edit',
|
|
565
|
+
record,
|
|
566
|
+
data,
|
|
567
|
+
asJson: options?.asJson,
|
|
568
|
+
authCtx: authResult,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async destroy(ctx, options) {
|
|
573
|
+
const meta = this.requireResource(ctx);
|
|
574
|
+
const { id } = ctx.params;
|
|
575
|
+
const record = await this.resources.show(meta, id);
|
|
576
|
+
const authResult = await this.ensureResourceAction(ctx, meta, 'delete', record, options?.asJson);
|
|
577
|
+
if (!this.isAuthContext(authResult))
|
|
578
|
+
return authResult;
|
|
579
|
+
await this.resources.destroy(meta, id);
|
|
580
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
581
|
+
return ctx.response.noContent();
|
|
582
|
+
}
|
|
583
|
+
ctx.session.flash('success', `${meta.singularLabel} deleted`);
|
|
584
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
585
|
+
}
|
|
586
|
+
/** POST /:slug/bulk — bulk delete or custom bulk actions. */
|
|
587
|
+
async bulk(ctx, options) {
|
|
588
|
+
const meta = this.requireResource(ctx);
|
|
589
|
+
const action = String(ctx.request.input('action') ?? '').trim();
|
|
590
|
+
if (!action) {
|
|
591
|
+
return ctx.response.badRequest({ message: 'Missing action.' });
|
|
592
|
+
}
|
|
593
|
+
const authGate = this.actionAuthGate(meta, action);
|
|
594
|
+
const authResult = await this.ensureAuthenticated(ctx, options?.asJson);
|
|
595
|
+
if (!this.isAuthContext(authResult))
|
|
596
|
+
return authResult;
|
|
597
|
+
const authCtx = authResult;
|
|
598
|
+
const ids = this.collectBulkIds(ctx);
|
|
599
|
+
if (ids.length === 0 && !ctx.request.input('selectAll')) {
|
|
600
|
+
ctx.session.flash('error', 'No records selected.');
|
|
601
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
602
|
+
}
|
|
603
|
+
let records = [];
|
|
604
|
+
if (ctx.request.input('selectAll')) {
|
|
605
|
+
const query = this.withPolicyScope(authCtx, meta, normalizeListQuery(this.listQuery(ctx)));
|
|
606
|
+
const result = await this.resources.index(meta, {
|
|
607
|
+
...query,
|
|
608
|
+
page: 1,
|
|
609
|
+
perPage: LIST_ALL_RECORDS_PER_PAGE,
|
|
610
|
+
});
|
|
611
|
+
records = result.items;
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
for (const id of ids) {
|
|
615
|
+
try {
|
|
616
|
+
records.push(await this.resources.show(meta, id));
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
/* skip missing */
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (records.length === 0) {
|
|
624
|
+
ctx.session.flash('error', 'No matching records found.');
|
|
625
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
626
|
+
}
|
|
627
|
+
if (action === 'delete') {
|
|
628
|
+
for (const record of records) {
|
|
629
|
+
try {
|
|
630
|
+
assertResourceAccess(this.authorizer, authCtx, this.registry, meta, 'delete', record);
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
if (error instanceof ForbiddenError) {
|
|
634
|
+
return respondForbidden(ctx, error.message, options?.asJson || this.wantsJson(ctx), this.basePath);
|
|
635
|
+
}
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
await this.resources.destroy(meta, String(record.id));
|
|
639
|
+
}
|
|
640
|
+
const message = records.length === 1
|
|
641
|
+
? `${meta.singularLabel} deleted`
|
|
642
|
+
: `${records.length} ${meta.label.toLowerCase()} deleted`;
|
|
643
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
644
|
+
return ctx.response.json({ message, count: records.length });
|
|
645
|
+
}
|
|
646
|
+
ctx.session.flash('success', message);
|
|
647
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
648
|
+
}
|
|
649
|
+
return this.runCustomAction(ctx, meta, authCtx, action, records, authGate, options?.asJson);
|
|
650
|
+
}
|
|
651
|
+
/** POST /:slug/:id/action/:action — single-record custom action. */
|
|
652
|
+
async recordAction(ctx, options) {
|
|
653
|
+
const meta = this.requireResource(ctx);
|
|
654
|
+
const { id, action: actionParam } = ctx.params;
|
|
655
|
+
const action = String(actionParam ?? ctx.request.input('action') ?? '').trim();
|
|
656
|
+
if (!action) {
|
|
657
|
+
return ctx.response.badRequest({ message: 'Missing action.' });
|
|
658
|
+
}
|
|
659
|
+
const record = await this.resources.show(meta, id);
|
|
660
|
+
const authGate = this.actionAuthGate(meta, action);
|
|
661
|
+
const authResult = await this.ensureResourceAction(ctx, meta, authGate, record, options?.asJson);
|
|
662
|
+
if (!this.isAuthContext(authResult))
|
|
663
|
+
return authResult;
|
|
664
|
+
if (action === 'delete') {
|
|
665
|
+
await this.resources.destroy(meta, id);
|
|
666
|
+
if (this.wantsJson(ctx, options?.asJson)) {
|
|
667
|
+
return ctx.response.noContent();
|
|
668
|
+
}
|
|
669
|
+
ctx.session.flash('success', `${meta.singularLabel} deleted`);
|
|
670
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
671
|
+
}
|
|
672
|
+
return this.runCustomAction(ctx, meta, authResult, action, [record], authGate, options?.asJson, String(record.id));
|
|
673
|
+
}
|
|
674
|
+
actionAuthGate(meta, action) {
|
|
675
|
+
const config = (meta.actions ?? []).find((entry) => entry.name === action);
|
|
676
|
+
const gate = config?.ability ?? action;
|
|
677
|
+
if (gate === 'create')
|
|
678
|
+
return 'create';
|
|
679
|
+
if (gate === 'edit' || gate === 'update')
|
|
680
|
+
return 'update';
|
|
681
|
+
if (gate === 'view')
|
|
682
|
+
return 'view';
|
|
683
|
+
if (gate === 'viewAny')
|
|
684
|
+
return 'viewAny';
|
|
685
|
+
return 'delete';
|
|
686
|
+
}
|
|
687
|
+
collectBulkIds(ctx) {
|
|
688
|
+
const raw = ctx.request.input('ids');
|
|
689
|
+
if (Array.isArray(raw)) {
|
|
690
|
+
return raw.map(String).map((id) => id.trim()).filter(Boolean);
|
|
691
|
+
}
|
|
692
|
+
if (raw != null && String(raw).trim() !== '') {
|
|
693
|
+
return [String(raw).trim()];
|
|
694
|
+
}
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
async runCustomAction(ctx, meta, authCtx, action, records, authGate, asJson, redirectId) {
|
|
698
|
+
for (const record of records) {
|
|
699
|
+
try {
|
|
700
|
+
assertResourceAccess(this.authorizer, authCtx, this.registry, meta, authGate, record);
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (error instanceof ForbiddenError) {
|
|
704
|
+
return respondForbidden(ctx, error.message, asJson || this.wantsJson(ctx), this.basePath);
|
|
705
|
+
}
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const ResourceClass = this.registry.resourceClass(meta.slug);
|
|
710
|
+
if (!ResourceClass?.handleAction) {
|
|
711
|
+
return ctx.response.badRequest({ message: `Unknown action: ${action}` });
|
|
712
|
+
}
|
|
713
|
+
const result = await ResourceClass.handleAction(action, records, {
|
|
714
|
+
adapter: this.panel.adapter,
|
|
715
|
+
meta,
|
|
716
|
+
userId: authCtx.user?.id ?? null,
|
|
717
|
+
user: authCtx.user ?? null,
|
|
718
|
+
});
|
|
719
|
+
if (result == null) {
|
|
720
|
+
return ctx.response.badRequest({ message: `Unknown action: ${action}` });
|
|
721
|
+
}
|
|
722
|
+
const message = result.message ??
|
|
723
|
+
`${meta.singularLabel} ${action}${records.length > 1 ? ` (${records.length})` : ''}`;
|
|
724
|
+
if (this.wantsJson(ctx, asJson)) {
|
|
725
|
+
return ctx.response.json({ message, count: records.length });
|
|
726
|
+
}
|
|
727
|
+
ctx.session.flash('success', message);
|
|
728
|
+
if (redirectId) {
|
|
729
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}/${redirectId}`);
|
|
730
|
+
}
|
|
731
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}`);
|
|
732
|
+
}
|
|
733
|
+
requireResource(ctx) {
|
|
734
|
+
return this.registry.require(ctx.params.slug);
|
|
735
|
+
}
|
|
736
|
+
withPolicyScope(authCtx, meta, query) {
|
|
737
|
+
const ResourceClass = this.registry.resourceClass(meta.slug);
|
|
738
|
+
if (!ResourceClass)
|
|
739
|
+
return query;
|
|
740
|
+
const scope = this.authorizer.listScope(authCtx, ResourceClass);
|
|
741
|
+
if (!scope)
|
|
742
|
+
return query;
|
|
743
|
+
return {
|
|
744
|
+
...query,
|
|
745
|
+
scope: { ...(query.scope ?? {}), ...scope },
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
listQuery(ctx) {
|
|
749
|
+
return {
|
|
750
|
+
page: ctx.request.input('page'),
|
|
751
|
+
perPage: ctx.request.input('perPage'),
|
|
752
|
+
search: ctx.request.input('search'),
|
|
753
|
+
sort: ctx.request.input('sort'),
|
|
754
|
+
direction: ctx.request.input('direction'),
|
|
755
|
+
filters: ctx.request.input('filters'),
|
|
756
|
+
groupBy: ctx.request.input('groupBy'),
|
|
757
|
+
trashed: ctx.request.input('trashed'),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
wantsJson(ctx, force = false) {
|
|
761
|
+
if (force) {
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
const accept = ctx.request.header('accept') ?? '';
|
|
765
|
+
return accept.includes('application/json');
|
|
766
|
+
}
|
|
767
|
+
isEmbed(ctx) {
|
|
768
|
+
const query = ctx.request.input('embed');
|
|
769
|
+
if (query === '1' || query === 1 || query === true)
|
|
770
|
+
return true;
|
|
771
|
+
const body = ctx.request.input('_shamar_embed');
|
|
772
|
+
return body === '1' || body === 1 || body === true;
|
|
773
|
+
}
|
|
774
|
+
redirectAfterSave(ctx, meta, recordId, action, options) {
|
|
775
|
+
if (this.isEmbed(ctx)) {
|
|
776
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}/${recordId}?success=${action}&embed=1`);
|
|
777
|
+
}
|
|
778
|
+
if (options?.flashPlainText) {
|
|
779
|
+
ctx.session.flash('success', options.flashMessage ??
|
|
780
|
+
`${meta.singularLabel} created. Copy it now — it will not be shown again:\n${options.flashPlainText}`);
|
|
781
|
+
ctx.session.flash('apiKeyPlainText', options.flashPlainText);
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
ctx.session.flash('success', options?.flashMessage ??
|
|
785
|
+
(action === 'created'
|
|
786
|
+
? `${meta.singularLabel} created`
|
|
787
|
+
: `${meta.singularLabel} updated`));
|
|
788
|
+
}
|
|
789
|
+
const navQuery = recordNavQuery(this.listQuery(ctx));
|
|
790
|
+
// Create-once resources (canEdit false) land on show, not edit.
|
|
791
|
+
if (action === 'created' && options?.authCtx) {
|
|
792
|
+
const ResourceClass = this.registry.resourceClass(meta.slug);
|
|
793
|
+
const editable = !ResourceClass ||
|
|
794
|
+
ResourceClass.canEdit(options.authCtx.user ?? { id: '', name: '' });
|
|
795
|
+
if (!editable) {
|
|
796
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}/${recordId}${navQuery}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// Stay on the edit form so Ctrl+S / continued editing keeps context.
|
|
800
|
+
return ctx.response.redirect(`${this.basePath}/${meta.slug}/${recordId}/edit${navQuery}`);
|
|
801
|
+
}
|
|
802
|
+
async handleFormValidationError(ctx, meta, error, options) {
|
|
803
|
+
if (!isValidationException(error)) {
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
if (this.wantsJson(ctx, options.asJson)) {
|
|
807
|
+
return ctx.response.status(422).json({
|
|
808
|
+
message: error.message,
|
|
809
|
+
errors: error.errors,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
const authCtx = options.authCtx ?? (await this.resolveAuth(ctx));
|
|
813
|
+
return this.renderForm(ctx, meta, {
|
|
814
|
+
mode: options.mode,
|
|
815
|
+
record: options.record,
|
|
816
|
+
authCtx,
|
|
817
|
+
formErrors: error.errors,
|
|
818
|
+
formInitialState: {
|
|
819
|
+
...this.initialFormState(meta, options.record, options.mode),
|
|
820
|
+
...options.data,
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async renderForm(ctx, meta, options) {
|
|
825
|
+
const authCtx = options.authCtx ?? (await this.resolveAuth(ctx));
|
|
826
|
+
const embed = this.isEmbed(ctx);
|
|
827
|
+
const title = options.mode === 'create'
|
|
828
|
+
? `New ${meta.singularLabel}`
|
|
829
|
+
: `Edit ${recordTitle(meta, options.record ?? {})}`;
|
|
830
|
+
const navQuery = recordNavQuery(this.listQuery(ctx));
|
|
831
|
+
const recordId = options.record?.id;
|
|
832
|
+
const policy = resourcePolicyFlags(this.authorizer, authCtx, this.registry, meta, options.record);
|
|
833
|
+
const shell = this.shellOpts(ctx, authCtx, {
|
|
834
|
+
meta,
|
|
835
|
+
record: options.record,
|
|
836
|
+
pageTitle: title,
|
|
837
|
+
showDeleteButton: options.mode === 'edit' ? policy.delete : undefined,
|
|
838
|
+
showBackToList: false,
|
|
839
|
+
recordBreadcrumb: options.mode === 'create'
|
|
840
|
+
? { mode: 'create' }
|
|
841
|
+
: {
|
|
842
|
+
mode: 'edit',
|
|
843
|
+
recordTitle: recordTitle(meta, options.record ?? {}),
|
|
844
|
+
recordHref: recordId != null
|
|
845
|
+
? `${this.basePath}/${meta.slug}/${recordId}${navQuery}`
|
|
846
|
+
: undefined,
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
const formInitialState = options.formInitialState ??
|
|
850
|
+
this.initialFormState(meta, options.record, options.mode);
|
|
851
|
+
const relationUi = await this.buildRelationUiMap(meta, options.record, options.mode, formInitialState);
|
|
852
|
+
return ctx.view.render('shamar::form', {
|
|
853
|
+
...shell,
|
|
854
|
+
meta,
|
|
855
|
+
resource: meta,
|
|
856
|
+
record: options.record,
|
|
857
|
+
id: options.record?.id,
|
|
858
|
+
mode: options.mode,
|
|
859
|
+
embed,
|
|
860
|
+
/** Create-once resources (canEdit false) redirect to show after AJAX create. */
|
|
861
|
+
redirectAfterCreateToShow: options.mode === 'create' && !policy.update,
|
|
862
|
+
formSchema: formSchemaTree(meta),
|
|
863
|
+
formSections: formSections(meta),
|
|
864
|
+
formLiveFields: formClientFields(meta, {
|
|
865
|
+
state: formInitialState,
|
|
866
|
+
record: options.record,
|
|
867
|
+
operation: options.mode,
|
|
868
|
+
}),
|
|
869
|
+
formStateEndpoint: `${this.basePath}/${meta.slug}/form-state`,
|
|
870
|
+
formInitialState,
|
|
871
|
+
formErrors: options.formErrors ?? {},
|
|
872
|
+
relationUi,
|
|
873
|
+
recordPager: !embed && options.mode === 'edit' && options.record?.id != null
|
|
874
|
+
? await this.resolveRecordPager(ctx, meta, String(options.record.id), 'edit')
|
|
875
|
+
: null,
|
|
876
|
+
resolveGridItemStyle,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
async resolveRecordPager(ctx, meta, recordId, mode) {
|
|
880
|
+
const raw = this.listQuery(ctx);
|
|
881
|
+
const navQuery = recordNavQuery(raw);
|
|
882
|
+
const listQuery = normalizeListQuery({
|
|
883
|
+
search: raw.search,
|
|
884
|
+
sort: raw.sort,
|
|
885
|
+
direction: raw.direction,
|
|
886
|
+
page: 1,
|
|
887
|
+
perPage: RECORD_NAV_CAP,
|
|
888
|
+
});
|
|
889
|
+
// Bypass the list UI cap so neighbors follow the full filtered set.
|
|
890
|
+
listQuery.perPage = RECORD_NAV_CAP;
|
|
891
|
+
try {
|
|
892
|
+
const result = await this.panel.adapter.list(meta, listQuery);
|
|
893
|
+
const ids = result.items
|
|
894
|
+
.map((item) => (item.id != null ? String(item.id) : ''))
|
|
895
|
+
.filter(Boolean);
|
|
896
|
+
return buildRecordPager({
|
|
897
|
+
basePath: this.basePath,
|
|
898
|
+
slug: meta.slug,
|
|
899
|
+
recordId,
|
|
900
|
+
mode,
|
|
901
|
+
ids,
|
|
902
|
+
navQuery,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
applyCreateQueryDefaults(ctx, meta, state) {
|
|
910
|
+
const qs = ctx.request.qs();
|
|
911
|
+
for (const field of meta.fields) {
|
|
912
|
+
if (!(field.name in qs))
|
|
913
|
+
continue;
|
|
914
|
+
const raw = qs[field.name];
|
|
915
|
+
if (raw == null || raw === '')
|
|
916
|
+
continue;
|
|
917
|
+
state[field.name] = toFormControlValue(raw, field);
|
|
918
|
+
}
|
|
919
|
+
// Create & Edit often passes `?name=` for the title attribute.
|
|
920
|
+
if ('name' in qs && qs.name != null && qs.name !== '') {
|
|
921
|
+
const titleField = meta.recordTitleField ??
|
|
922
|
+
meta.fields.find((field) => field.name === 'name')?.name ??
|
|
923
|
+
null;
|
|
924
|
+
if (titleField && (state[titleField] === '' || state[titleField] == null)) {
|
|
925
|
+
state[titleField] = String(qs.name);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
initialFormState(meta, record, operation) {
|
|
930
|
+
const state = {};
|
|
931
|
+
for (const field of meta.fields) {
|
|
932
|
+
if (record && field.name in record) {
|
|
933
|
+
state[field.name] = toFormControlValue(record[field.name], field);
|
|
934
|
+
}
|
|
935
|
+
else if (field.default !== undefined && typeof field.default !== 'function') {
|
|
936
|
+
state[field.name] = toFormControlValue(field.default, field);
|
|
937
|
+
}
|
|
938
|
+
else if (field.type === 'boolean' || field.type === 'checkbox') {
|
|
939
|
+
state[field.name] = false;
|
|
940
|
+
}
|
|
941
|
+
else if (field.type === 'color') {
|
|
942
|
+
state[field.name] = '#000000';
|
|
943
|
+
}
|
|
944
|
+
else if (field.type === 'relation' && field.relation?.kind === 'belongsTo') {
|
|
945
|
+
// BelongsTo values are owned by the M2O combobox widget, not form state.
|
|
946
|
+
}
|
|
947
|
+
else if (field.multiple ||
|
|
948
|
+
field.type === 'tags' ||
|
|
949
|
+
field.type === 'checkboxList' ||
|
|
950
|
+
field.type === 'relationTable' ||
|
|
951
|
+
(field.type === 'relation' && field.relation?.kind !== 'belongsTo')) {
|
|
952
|
+
state[field.name] = [];
|
|
953
|
+
}
|
|
954
|
+
else if (field.type === 'select' && field.multiple) {
|
|
955
|
+
state[field.name] = [];
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
state[field.name] = '';
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
void operation;
|
|
962
|
+
return state;
|
|
963
|
+
}
|
|
964
|
+
async buildRelationUiMap(meta, record, operation, state) {
|
|
965
|
+
const map = {};
|
|
966
|
+
for (const field of meta.fields) {
|
|
967
|
+
if (!isRelationField(field) || !field.relation)
|
|
968
|
+
continue;
|
|
969
|
+
const related = resolveRelatedMeta(this.registry, field.relation);
|
|
970
|
+
const titleAttribute = relationTitleAttribute(field.relation);
|
|
971
|
+
const widget = field.relation.widget ?? 'combobox';
|
|
972
|
+
let initialItems = [];
|
|
973
|
+
if (field.relation.kind === 'hasMany' && record?.id != null && field.relation.foreignKey) {
|
|
974
|
+
initialItems = await this.panel.adapter.search(related, {
|
|
975
|
+
titleAttribute,
|
|
976
|
+
limit: field.relation.preloadLimit ?? 100,
|
|
977
|
+
scope: { [field.relation.foreignKey]: String(record.id) },
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const ids = collectRelationIds(field, {
|
|
982
|
+
...(record ?? {}),
|
|
983
|
+
...state,
|
|
984
|
+
});
|
|
985
|
+
if (ids.length > 0) {
|
|
986
|
+
initialItems = await this.panel.adapter.search(related, {
|
|
987
|
+
ids,
|
|
988
|
+
titleAttribute,
|
|
989
|
+
limit: Math.max(ids.length, 25),
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
let preloadedOptions;
|
|
994
|
+
if (widget === 'radio' || widget === 'checkboxList') {
|
|
995
|
+
preloadedOptions = await this.panel.adapter.search(related, {
|
|
996
|
+
titleAttribute,
|
|
997
|
+
limit: field.relation.preloadLimit ?? 100,
|
|
998
|
+
q: '',
|
|
999
|
+
});
|
|
1000
|
+
// Ensure selected items appear in the option list.
|
|
1001
|
+
const seen = new Set(preloadedOptions.map((item) => item.id));
|
|
1002
|
+
for (const item of initialItems) {
|
|
1003
|
+
if (!seen.has(item.id)) {
|
|
1004
|
+
preloadedOptions = [item, ...preloadedOptions];
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
map[field.name] = buildRelationUiConfig({
|
|
1009
|
+
field,
|
|
1010
|
+
parentMeta: meta,
|
|
1011
|
+
relatedMeta: related,
|
|
1012
|
+
basePath: this.basePath,
|
|
1013
|
+
record,
|
|
1014
|
+
initialItems,
|
|
1015
|
+
preloadedOptions,
|
|
1016
|
+
operation,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return map;
|
|
1020
|
+
}
|
|
1021
|
+
resourcePayload(meta, ctx, action) {
|
|
1022
|
+
const input = ctx.request.all();
|
|
1023
|
+
const data = {};
|
|
1024
|
+
for (const field of meta.fields) {
|
|
1025
|
+
if (field.hiddenOnForm)
|
|
1026
|
+
continue;
|
|
1027
|
+
if (field.dehydrated === false)
|
|
1028
|
+
continue;
|
|
1029
|
+
if (action === 'update' && field.createOnly)
|
|
1030
|
+
continue;
|
|
1031
|
+
if (field.type === 'boolean' || field.type === 'checkbox') {
|
|
1032
|
+
data[field.name] =
|
|
1033
|
+
input[field.name] === true ||
|
|
1034
|
+
input[field.name] === '1' ||
|
|
1035
|
+
input[field.name] === 'on' ||
|
|
1036
|
+
input[field.name] === 'true';
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (field.currency) {
|
|
1040
|
+
const raw = field.name in input ? input[field.name] : undefined;
|
|
1041
|
+
if (raw !== undefined) {
|
|
1042
|
+
data[field.name] = parseCurrencyInput(raw);
|
|
1043
|
+
}
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (field.type === 'tags' ||
|
|
1047
|
+
field.type === 'checkboxList' ||
|
|
1048
|
+
(field.relation &&
|
|
1049
|
+
(field.relation.kind === 'manyToMany' || field.multiple))) {
|
|
1050
|
+
const raw = input[field.name] ?? input[`${field.name}[]`];
|
|
1051
|
+
if (Array.isArray(raw)) {
|
|
1052
|
+
data[field.name] = raw.map((item) => String(item)).filter(Boolean);
|
|
1053
|
+
}
|
|
1054
|
+
else if (typeof raw === 'string' && raw.trim()) {
|
|
1055
|
+
data[field.name] =
|
|
1056
|
+
field.type === 'tags'
|
|
1057
|
+
? raw.split(',').map((item) => item.trim()).filter(Boolean)
|
|
1058
|
+
: [raw.trim()];
|
|
1059
|
+
}
|
|
1060
|
+
else if (field.name in input || `${field.name}[]` in input) {
|
|
1061
|
+
data[field.name] = [];
|
|
1062
|
+
}
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (field.name in input) {
|
|
1066
|
+
data[field.name] = input[field.name];
|
|
1067
|
+
}
|
|
1068
|
+
else if (`${field.name}[]` in input) {
|
|
1069
|
+
data[field.name] = input[`${field.name}[]`];
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return data;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
//# sourceMappingURL=admin_controller.js.map
|