@ultimat3/admin 22.14.0 → 23.0.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/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
package/src/entity-columns.ts
CHANGED
|
@@ -81,15 +81,34 @@ const factsFor = (
|
|
|
81
81
|
...(references === undefined ? {} : { references }),
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
const factsOf = (entity: AdminEntity, sealed: boolean): readonly AdminColumnFacts[] => {
|
|
85
|
+
const references = referencesOf(entity);
|
|
86
|
+
const keys = new Set(entity.$primaryKey);
|
|
87
|
+
return Object.entries(entity.$columns)
|
|
88
|
+
.filter(([, column]) => (column.$meta.sealed !== undefined) === sealed)
|
|
89
|
+
.map(([name, column]) =>
|
|
90
|
+
factsFor(name, column.$meta, column.$meta.primaryKey || keys.has(name), references.get(name)),
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
84
94
|
/**
|
|
85
95
|
* Declaration order, which is the order every list, form and MCP schema is built in. A
|
|
86
96
|
* composite key marks each of its members, the way `$describe()` does — the admin then treats
|
|
87
97
|
* them as it treats any key column: addressable, and never editable.
|
|
98
|
+
*
|
|
99
|
+
* A `.sealed()` column is NOT here. Every screen reads a row by column name — `row[field.name]`
|
|
100
|
+
* — and a repository row still answers a sealed property with its plaintext, so a derived field
|
|
101
|
+
* for one is the secret in the list, the detail, the search and the MCP schema. Leaving it out of
|
|
102
|
+
* this list is what makes "never shown" a property of the derivation rather than of each view.
|
|
88
103
|
*/
|
|
89
104
|
export function adminColumnsOf(entity: AdminEntity): readonly AdminColumnFacts[] {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
return factsOf(entity, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The sealed columns, and only them: what a form may WRITE and nothing may read. Separate from
|
|
110
|
+
* `adminColumnsOf` so no caller can iterate one list and render both.
|
|
111
|
+
*/
|
|
112
|
+
export function adminSealedColumnsOf(entity: AdminEntity): readonly AdminColumnFacts[] {
|
|
113
|
+
return factsOf(entity, true);
|
|
95
114
|
}
|
package/src/errors.ts
CHANGED
|
@@ -25,6 +25,10 @@ export const ADMIN_OWNED_ERROR_CODES = [
|
|
|
25
25
|
// default label key, and `callAdminTool`'s lookup. Two actions sharing it is refused where the
|
|
26
26
|
// admin is DECLARED, so an app that never wires MCP still cannot ship the ambiguity.
|
|
27
27
|
'X_ADMIN_ACTION_DUPLICATE',
|
|
28
|
+
'X_ADMIN_REPO_UNBOUND',
|
|
29
|
+
'X_ADMIN_FILTER_INVALID',
|
|
30
|
+
'X_ADMIN_ACTION_NOT_APPLICABLE',
|
|
31
|
+
'X_ADMIN_MOUNT_MISSING',
|
|
28
32
|
] as const;
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -53,6 +57,11 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
|
|
|
53
57
|
X_ADMIN_PAGE_UNGUARDED: 'a custom admin page declared no permissions',
|
|
54
58
|
X_ADMIN_PAGE_PATH_INVALID: 'a custom admin page has an unusable or already-taken path',
|
|
55
59
|
X_ADMIN_ACTION_DUPLICATE: 'two admin actions share one name',
|
|
60
|
+
X_ADMIN_REPO_UNBOUND: 'an admin resource has no database handle to read rows through',
|
|
61
|
+
X_ADMIN_FILTER_INVALID:
|
|
62
|
+
'an admin list was asked for a filter, sort or scope its resource does not derive',
|
|
63
|
+
X_ADMIN_ACTION_NOT_APPLICABLE: 'an admin action was run on a row its when() rule excludes',
|
|
64
|
+
X_ADMIN_MOUNT_MISSING: 'an admin batch job ran in a process that never declared its admin',
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
// One unconditional call, so a second package claiming one of admin's codes throws
|
|
@@ -135,6 +144,80 @@ export class AdminPolicyMissingError extends UltimateError {
|
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A resource with nothing to read rows through. Refused where the admin is DECLARED: the routes
|
|
149
|
+
* of a repo-less resource would mount, render their chrome, and fail on the first row they ask for.
|
|
150
|
+
*/
|
|
151
|
+
export class AdminRepoUnboundError extends UltimateError {
|
|
152
|
+
constructor(input: { entity: string; handles: readonly string[] }) {
|
|
153
|
+
const carried = input.handles.length > 0 ? input.handles.join(', ') : 'none';
|
|
154
|
+
super({
|
|
155
|
+
code: 'X_ADMIN_REPO_UNBOUND',
|
|
156
|
+
cause: `the admin resource "${input.entity}" has no repo: defineAdmin() was given no db handle that carries it (tables in the handle: ${carried}) and no resources.${input.entity}.repo`,
|
|
157
|
+
fix: `pass the app's handle once: defineAdmin({ entities, db }) # with "${input.entity}" in the database() call that built db`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A list was asked for something its resource does not derive: a filter on a field that is not
|
|
164
|
+
* one, an operator the field's shape has no meaning for, a value that is not the column's type, a
|
|
165
|
+
* sort on an unsortable column, a scope nobody declared. Raised for a URL, an MCP call and a
|
|
166
|
+
* `scopes:` / `rows:` declaration alike — an ignored parameter is a list that silently shows
|
|
167
|
+
* every row to someone who asked for some of them.
|
|
168
|
+
*/
|
|
169
|
+
export class AdminFilterInvalidError extends UltimateError {
|
|
170
|
+
constructor(input: {
|
|
171
|
+
entity: string;
|
|
172
|
+
/** What was asked for, as the caller spelled it: `f.colour`, `sort=body:asc`, `scope=mine`. */
|
|
173
|
+
asked: string;
|
|
174
|
+
cause: string;
|
|
175
|
+
/** What the resource DOES answer, in the caller's own spelling. */
|
|
176
|
+
known: readonly string[];
|
|
177
|
+
}) {
|
|
178
|
+
const known = input.known.length > 0 ? input.known.join(', ') : 'none';
|
|
179
|
+
super({
|
|
180
|
+
code: 'X_ADMIN_FILTER_INVALID',
|
|
181
|
+
cause: `${input.entity}: "${input.asked}" ${input.cause} (this list answers: ${known})`,
|
|
182
|
+
// The manifest records every resource's filters, sorts and scopes, so the command prints the
|
|
183
|
+
// list this error names — for the resource, and for every other one.
|
|
184
|
+
fix: `x manifest --json # admin[].resources lists the filters, sorts and scopes of "${input.entity}"`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* An action was run on a row its `when()` excludes. The button was never rendered for that row —
|
|
191
|
+
* so this is a stale page (the row changed state since it was drawn) or a forged call — and the
|
|
192
|
+
* gate answers it rather than the handler: a hidden button is not an authorization.
|
|
193
|
+
*/
|
|
194
|
+
export class AdminActionNotApplicableError extends UltimateError {
|
|
195
|
+
constructor(input: { action: string; entity: string; id: string | null }) {
|
|
196
|
+
const row = input.id === null ? 'no row at all' : `the row "${input.id}"`;
|
|
197
|
+
super({
|
|
198
|
+
code: 'X_ADMIN_ACTION_NOT_APPLICABLE',
|
|
199
|
+
cause: `the admin action "${input.action}" does not apply to ${row} of "${input.entity}": its when() rule answered false for the row as it is now`,
|
|
200
|
+
fix: `x manifest --json # admin[].resources lists "${input.action}" with when: true — re-read the row (the detail page, or the admin.${input.entity}.read tool) and run it only while that rule holds`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A queued batch chunk was claimed by a process that declared no admin at the path the batch was
|
|
207
|
+
* queued from. The worker role imports the API index and not the document graph, so an admin
|
|
208
|
+
* declared only from a page module does not exist there — and neither do its actions.
|
|
209
|
+
*/
|
|
210
|
+
export class AdminMountMissingError extends UltimateError {
|
|
211
|
+
constructor(input: { basePath: string; mounted: readonly string[] }) {
|
|
212
|
+
const mounted = input.mounted.length > 0 ? input.mounted.join(', ') : 'none';
|
|
213
|
+
super({
|
|
214
|
+
code: 'X_ADMIN_MOUNT_MISSING',
|
|
215
|
+
cause: `an admin.batch job was claimed by a process with no admin declared at "${input.basePath}" (admins declared here: ${mounted}), so the action it names has no handler to run`,
|
|
216
|
+
fix: 'import the module that calls defineAdmin() from apps/web/api/index.ts, so the worker role loads it; then x verify --only manifest',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
138
221
|
/**
|
|
139
222
|
* A `pages:` entry with an empty permission list. Refused where it is written, because the
|
|
140
223
|
* alternative is an unauthenticated admin screen that `x verify` is perfectly happy with — the
|
package/src/fields.ts
CHANGED
|
@@ -18,7 +18,9 @@ export type AdminFieldType =
|
|
|
18
18
|
| 'locale'
|
|
19
19
|
| 'json'
|
|
20
20
|
| 'relation'
|
|
21
|
-
| 'file'
|
|
21
|
+
| 'file'
|
|
22
|
+
/** A `.sealed()` column: written through a form, read by nothing. Never derived from a kind. */
|
|
23
|
+
| 'secret';
|
|
22
24
|
|
|
23
25
|
export type AdminWidget =
|
|
24
26
|
| 'text-input'
|
|
@@ -32,7 +34,8 @@ export type AdminWidget =
|
|
|
32
34
|
| 'locale-picker'
|
|
33
35
|
| 'json-editor'
|
|
34
36
|
| 'reference'
|
|
35
|
-
| 'upload'
|
|
37
|
+
| 'upload'
|
|
38
|
+
| 'secret-input';
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* A derived field: everything a list cell, a detail row, a form input, a filter, and an MCP
|
|
@@ -52,13 +55,34 @@ export interface AdminField {
|
|
|
52
55
|
readonly sensitive: boolean;
|
|
53
56
|
readonly inList: boolean;
|
|
54
57
|
readonly filterable: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* HOW the field filters, when a list offers it: which control the filter bar draws and which
|
|
60
|
+
* operators a URL may name (`list-filters.ts`). Absent for a type no predicate can be written
|
|
61
|
+
* against — money, JSON, a file, a sealed column — so such a field is never a filter.
|
|
62
|
+
*/
|
|
63
|
+
readonly filterKind?: AdminFilterKind;
|
|
55
64
|
readonly sortable: boolean;
|
|
56
65
|
readonly searchable: boolean;
|
|
57
66
|
readonly values?: readonly string[];
|
|
58
67
|
readonly currency?: string;
|
|
59
|
-
|
|
68
|
+
/** The resource a foreign key points at. Its label is THAT resource's `labelField`. */
|
|
69
|
+
readonly relation?: { readonly entity: string };
|
|
70
|
+
/** i18n key of the line under the form control. Declared, never derived. */
|
|
71
|
+
readonly hintKey?: string;
|
|
72
|
+
/**
|
|
73
|
+
* The one side of the form the field exists on: `create` is set once and never edited,
|
|
74
|
+
* `update` has no value until the row exists. Absent: both. Enforced on the write, not only
|
|
75
|
+
* on the form — `crud-input.ts` drops the field from the other side's input.
|
|
76
|
+
*/
|
|
77
|
+
readonly on?: 'create' | 'update';
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The six shapes a filter takes. A closed set: a control and an operator list hang off each in
|
|
82
|
+
* `list-filters.ts`, so a seventh shape cannot exist with one and not the other.
|
|
83
|
+
*/
|
|
84
|
+
export type AdminFilterKind = 'text' | 'exact' | 'choice' | 'boolean' | 'range' | 'reference';
|
|
85
|
+
|
|
62
86
|
/** The table. Money always the Money widget; both date kinds always the DateTime widget. */
|
|
63
87
|
export const WIDGET_BY_FIELD_TYPE: Readonly<Record<AdminFieldType, AdminWidget>> = {
|
|
64
88
|
text: 'text-input',
|
|
@@ -74,6 +98,7 @@ export const WIDGET_BY_FIELD_TYPE: Readonly<Record<AdminFieldType, AdminWidget>>
|
|
|
74
98
|
json: 'json-editor',
|
|
75
99
|
relation: 'reference',
|
|
76
100
|
file: 'upload',
|
|
101
|
+
secret: 'secret-input',
|
|
77
102
|
};
|
|
78
103
|
|
|
79
104
|
export function widgetFor(type: AdminFieldType): AdminWidget {
|
|
@@ -146,12 +171,46 @@ export function listable(type: AdminFieldType): boolean {
|
|
|
146
171
|
return type !== 'json' && type !== 'file' && type !== 'textarea';
|
|
147
172
|
}
|
|
148
173
|
|
|
149
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Only indexed, unique, enum, boolean, and FK columns are offered as filters — and only when the
|
|
176
|
+
* type has a filter shape at all: an indexed money column is still two physical columns, and no
|
|
177
|
+
* single predicate names both.
|
|
178
|
+
*/
|
|
150
179
|
export function filterable(type: AdminFieldType, column: AdminColumnFacts): boolean {
|
|
180
|
+
if (filterKindFor(type, column) === undefined) return false;
|
|
151
181
|
if (column.index || column.unique || column.primaryKey) return true;
|
|
152
182
|
return type === 'enum' || type === 'boolean' || type === 'relation';
|
|
153
183
|
}
|
|
154
184
|
|
|
185
|
+
const FILTER_KIND_BY_FIELD_TYPE: Readonly<Partial<Record<AdminFieldType, AdminFilterKind>>> = {
|
|
186
|
+
boolean: 'boolean',
|
|
187
|
+
enum: 'choice',
|
|
188
|
+
timezone: 'choice',
|
|
189
|
+
locale: 'choice',
|
|
190
|
+
number: 'range',
|
|
191
|
+
date: 'range',
|
|
192
|
+
timestamptz: 'range',
|
|
193
|
+
relation: 'reference',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The shape a field filters in, or `undefined` for a type nothing can be compared against. A text
|
|
198
|
+
* box is `text` (a `contains`) only over a column `LIKE` runs against; a uuid, a `bigint` or a
|
|
199
|
+
* `numeric` renders in a text box too and takes an exact match instead — see
|
|
200
|
+
* `LIKE_ABLE_COLUMN_KINDS` below for what the other spelling costs.
|
|
201
|
+
*/
|
|
202
|
+
export function filterKindFor(
|
|
203
|
+
type: AdminFieldType,
|
|
204
|
+
column: AdminColumnFacts,
|
|
205
|
+
): AdminFilterKind | undefined {
|
|
206
|
+
if (type === 'text' || type === 'textarea') {
|
|
207
|
+
return LIKE_ABLE_COLUMN_KINDS.has(column.kind) ? 'text' : 'exact';
|
|
208
|
+
}
|
|
209
|
+
return Object.hasOwn(FILTER_KIND_BY_FIELD_TYPE, type)
|
|
210
|
+
? FILTER_KIND_BY_FIELD_TYPE[type]
|
|
211
|
+
: undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
155
214
|
/**
|
|
156
215
|
* Sortable means "usable as the keyset cursor", so it is deliberately narrow: an ordered
|
|
157
216
|
* scalar, or a text column with an index behind it. Sorting on an unindexed column would
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// A posted form → the typed input the entity's schema judges. A browser sends strings and omits
|
|
2
|
+
// an unchecked box; the row holds a number, a boolean, a Date, a `{ minor, currency }`. This is
|
|
3
|
+
// the one place that gap is closed, per WIDGET — the same table that rendered the control.
|
|
4
|
+
//
|
|
5
|
+
// It converts and never validates: a value that will not convert is passed through as the string
|
|
6
|
+
// that was typed, so the schema refuses it in its own words against the field that carried it.
|
|
7
|
+
|
|
8
|
+
import type { AdminField } from './fields';
|
|
9
|
+
import type { AdminResource } from './resource';
|
|
10
|
+
|
|
11
|
+
/** The posted name of a money field's currency, beside the minor-unit amount under the field's own. */
|
|
12
|
+
export const currencyFieldOf = (field: string): string => `${field}.currency`;
|
|
13
|
+
|
|
14
|
+
type Posted = Readonly<Record<string, unknown>>;
|
|
15
|
+
|
|
16
|
+
/** The last value a posted name carried, as text. A repeated name arrives as a list. */
|
|
17
|
+
export const posted = (form: Posted, name: string): string | undefined => {
|
|
18
|
+
if (!Object.hasOwn(form, name)) return undefined;
|
|
19
|
+
const value = form[name];
|
|
20
|
+
const last: unknown = Array.isArray(value) ? value[value.length - 1] : value;
|
|
21
|
+
return typeof last === 'string' ? last : undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The widgets whose value is not text: an empty box of one is no value, never `''`. */
|
|
25
|
+
const TYPED: ReadonlySet<AdminField['widget']> = new Set([
|
|
26
|
+
'money',
|
|
27
|
+
'number-input',
|
|
28
|
+
'datetime',
|
|
29
|
+
'json-editor',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Empty is "no value" for a column that may hold none, and the empty string for a required TEXT
|
|
34
|
+
* one — so the schema refuses it in its own words. A typed widget has no empty string: `''` in a
|
|
35
|
+
* money field reached the widget on the 422 re-render and was refused as "money value is a
|
|
36
|
+
* string", a 500 where the form's own issue belonged.
|
|
37
|
+
*/
|
|
38
|
+
const blank = (field: AdminField): unknown =>
|
|
39
|
+
field.required && !TYPED.has(field.widget) ? '' : null;
|
|
40
|
+
|
|
41
|
+
const numberOf = (raw: string): unknown => {
|
|
42
|
+
const parsed = Number(raw);
|
|
43
|
+
return Number.isFinite(parsed) ? parsed : raw;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const jsonOf = (raw: string): unknown => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
// Left as typed: the schema names the field, which a thrown SyntaxError would not.
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function decodeField(field: AdminField, form: Posted): unknown {
|
|
56
|
+
const raw = posted(form, field.name);
|
|
57
|
+
// An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
|
|
58
|
+
if (field.widget === 'checkbox') return raw !== undefined && raw !== 'false';
|
|
59
|
+
if (raw === undefined || raw === '') return blank(field);
|
|
60
|
+
switch (field.widget) {
|
|
61
|
+
case 'number-input':
|
|
62
|
+
return numberOf(raw);
|
|
63
|
+
case 'money':
|
|
64
|
+
return {
|
|
65
|
+
minor: numberOf(raw),
|
|
66
|
+
currency: posted(form, currencyFieldOf(field.name)) ?? field.currency ?? '',
|
|
67
|
+
};
|
|
68
|
+
case 'datetime':
|
|
69
|
+
// `datetime-local` posts `YYYY-MM-DDTHH:mm` and the control says UTC beside the box; a
|
|
70
|
+
// calendar date stays the `YYYY-MM-DD` string its column holds.
|
|
71
|
+
return field.type === 'date' ? raw : new Date(`${raw.slice(0, 16)}:00.000Z`);
|
|
72
|
+
case 'json-editor':
|
|
73
|
+
return jsonOf(raw);
|
|
74
|
+
default:
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every field the form rendered, decoded. A sealed column rides along as the string that was
|
|
81
|
+
* typed — `crud.ts` drops an empty one, which is what makes an untouched box mean "unchanged".
|
|
82
|
+
*/
|
|
83
|
+
export function decodeForm(
|
|
84
|
+
resource: AdminResource,
|
|
85
|
+
form: Posted,
|
|
86
|
+
): Readonly<Record<string, unknown>> {
|
|
87
|
+
const out: Record<string, unknown> = {};
|
|
88
|
+
for (const field of resource.formFields) out[field.name] = decodeField(field, form);
|
|
89
|
+
for (const field of resource.secretFields) out[field.name] = posted(form, field.name) ?? '';
|
|
90
|
+
return out;
|
|
91
|
+
}
|
package/src/form.tsx
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
// Create/edit form
|
|
2
|
-
// comes from the entity's own schema, so the form rejects
|
|
3
|
-
// reject.
|
|
1
|
+
// Create/edit form, as a native `<form method="post">`. Fields, labels, hints, groups and required
|
|
2
|
+
// flags come from the resource; validation comes from the entity's own schema, so the form rejects
|
|
3
|
+
// exactly what the action would reject. A refused submit re-renders this with the posted values and the
|
|
4
|
+
// issues against the fields they name — there is no client state to lose.
|
|
4
5
|
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card, ErrorState, Field } from '@ultimat3/ui';
|
|
7
|
+
import { Button, Card, ErrorState, Field, Link } from '@ultimat3/ui';
|
|
7
8
|
import type { JSX } from 'solid-js';
|
|
9
|
+
import styles from './admin.module.scss';
|
|
8
10
|
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
11
|
+
import type { AdminField } from './fields';
|
|
9
12
|
import type { AdminRow } from './registry';
|
|
10
13
|
import type { AdminResource } from './resource';
|
|
14
|
+
import { fieldsFor } from './resource-layout';
|
|
11
15
|
import type { ValidationIssue } from './validate';
|
|
12
16
|
import type { WidgetContext } from './widget-value';
|
|
13
17
|
import { Widget } from './widgets';
|
|
@@ -15,20 +19,36 @@ import { Widget } from './widgets';
|
|
|
15
19
|
export interface AdminFormProps<Row extends AdminRow> {
|
|
16
20
|
readonly resource: AdminResource<Row>;
|
|
17
21
|
readonly mode: 'create' | 'edit';
|
|
18
|
-
/**
|
|
22
|
+
/** The stored row on a first render, the posted values on a refused one. Empty for create. */
|
|
19
23
|
readonly values: Readonly<Record<string, unknown>>;
|
|
20
24
|
readonly issues: readonly ValidationIssue[];
|
|
21
|
-
readonly submitting: boolean;
|
|
22
25
|
readonly error: AdminErrorParts | null;
|
|
23
26
|
readonly ctx: WidgetContext;
|
|
24
|
-
|
|
25
|
-
readonly
|
|
26
|
-
|
|
27
|
+
/** Where the form posts: the URL that rendered it. */
|
|
28
|
+
readonly action: string;
|
|
29
|
+
/** Where Cancel goes — the list for a create, the row for an edit. */
|
|
30
|
+
readonly cancelHref: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const issuesFor = (issues: readonly ValidationIssue[], field: string): readonly ValidationIssue[] =>
|
|
30
34
|
issues.filter((issue) => issue.path === field);
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The line under a control: the declared `hintKey`, and — for a sealed column on an edit — that an
|
|
38
|
+
* empty box keeps the stored value. Both, when both apply.
|
|
39
|
+
*/
|
|
40
|
+
const hintOf = (field: AdminField, unchanged: boolean): string | undefined => {
|
|
41
|
+
const lines = [
|
|
42
|
+
...(field.hintKey === undefined ? [] : [t(field.hintKey)]),
|
|
43
|
+
...(unchanged ? [t('admin.form.secret-unchanged')] : []),
|
|
44
|
+
];
|
|
45
|
+
return lines.length === 0 ? undefined : lines.join(' ');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** An issue the admin raised itself carries a key; the schema's own carry their message. */
|
|
49
|
+
export const issueText = (issue: ValidationIssue): string =>
|
|
50
|
+
issue.messageKey === undefined ? issue.message : t(issue.messageKey);
|
|
51
|
+
|
|
32
52
|
export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX.Element {
|
|
33
53
|
if (props.error !== null) {
|
|
34
54
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
@@ -36,60 +56,73 @@ export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX
|
|
|
36
56
|
|
|
37
57
|
const titleKey = props.mode === 'create' ? 'admin.form.create' : 'admin.form.edit';
|
|
38
58
|
|
|
59
|
+
const input = (field: AdminField): JSX.Element => {
|
|
60
|
+
const secret = field.type === 'secret';
|
|
61
|
+
const own = issuesFor(props.issues, field.name);
|
|
62
|
+
return (
|
|
63
|
+
// The anchor target the issue summary links to. <Field> owns the control's own
|
|
64
|
+
// id, so the deep link lands on the wrapper and the label still points at the input.
|
|
65
|
+
<div id={`x-admin-field-${field.name}`}>
|
|
66
|
+
<Field
|
|
67
|
+
label={t(field.labelKey)}
|
|
68
|
+
// A sealed column is required to CREATE a row and optional ever after: an empty box on
|
|
69
|
+
// an edit means "leave it as it is", because nothing can prefill it.
|
|
70
|
+
required={secret ? field.required && props.mode === 'create' : field.required}
|
|
71
|
+
hint={hintOf(field, secret && props.mode === 'edit')}
|
|
72
|
+
error={own.length === 0 ? undefined : own.map(issueText).join(' ')}
|
|
73
|
+
>
|
|
74
|
+
{(control) => (
|
|
75
|
+
<Widget
|
|
76
|
+
field={field}
|
|
77
|
+
// A sealed column's widget takes no value by TYPE (`widget-value.ts`), so what a
|
|
78
|
+
// refused post typed into it is dropped there, once, for every caller.
|
|
79
|
+
value={props.values[field.name]}
|
|
80
|
+
ctx={props.ctx}
|
|
81
|
+
mode="edit"
|
|
82
|
+
control={control}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
</Field>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
39
90
|
return (
|
|
40
91
|
<Card header={<h2>{t(titleKey, { entity: t(props.resource.titleKey) })}</h2>}>
|
|
41
|
-
<form
|
|
42
|
-
onSubmit={(event: SubmitEvent) => {
|
|
43
|
-
event.preventDefault();
|
|
44
|
-
props.onSubmit();
|
|
45
|
-
}}
|
|
46
|
-
>
|
|
92
|
+
<form class={styles['form']} method="post" action={props.action}>
|
|
47
93
|
{props.issues.length === 0 ? null : (
|
|
48
94
|
<div class="x-admin-issues" role="alert" tabindex={-1}>
|
|
49
95
|
<h3>{t('admin.form.issues')}</h3>
|
|
50
96
|
<ul>
|
|
51
97
|
{props.issues.map((issue) => (
|
|
52
98
|
<li>
|
|
53
|
-
<a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issue
|
|
99
|
+
<a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issueText(issue)}
|
|
54
100
|
</li>
|
|
55
101
|
))}
|
|
56
102
|
</ul>
|
|
57
103
|
</div>
|
|
58
104
|
)}
|
|
59
105
|
|
|
60
|
-
{props.resource.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<Widget
|
|
73
|
-
field={field}
|
|
74
|
-
value={props.values[field.name]}
|
|
75
|
-
ctx={props.ctx}
|
|
76
|
-
mode="edit"
|
|
77
|
-
control={control}
|
|
78
|
-
onInput={props.onInput}
|
|
79
|
-
/>
|
|
80
|
-
)}
|
|
81
|
-
</Field>
|
|
82
|
-
</div>
|
|
106
|
+
{props.resource.formGroups.map((group) => {
|
|
107
|
+
// A field that exists on the other side only (`on`) is not drawn, and a group left
|
|
108
|
+
// with nothing to draw is not drawn either.
|
|
109
|
+
const fields = fieldsFor(group.fields, props.mode);
|
|
110
|
+
if (fields.length === 0) return null;
|
|
111
|
+
return group.titleKey === null ? (
|
|
112
|
+
fields.map(input)
|
|
113
|
+
) : (
|
|
114
|
+
<fieldset class={styles['section']}>
|
|
115
|
+
<legend>{t(group.titleKey)}</legend>
|
|
116
|
+
{fields.map(input)}
|
|
117
|
+
</fieldset>
|
|
83
118
|
);
|
|
84
119
|
})}
|
|
85
120
|
|
|
86
|
-
<div class=
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
</button>
|
|
90
|
-
<button type="button" onClick={() => props.onCancel()}>
|
|
121
|
+
<div class={styles['actions']}>
|
|
122
|
+
<Button type="submit">{t('admin.form.save')}</Button>
|
|
123
|
+
<Link appearance="button" variant="ghost" tone="neutral" href={props.cancelHref}>
|
|
91
124
|
{t('admin.form.cancel')}
|
|
92
|
-
</
|
|
125
|
+
</Link>
|
|
93
126
|
</div>
|
|
94
127
|
</form>
|
|
95
128
|
</Card>
|