@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.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
@@ -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
- const references = referencesOf(entity);
91
- const keys = new Set(entity.$primaryKey);
92
- return Object.entries(entity.$columns).map(([name, column]) =>
93
- factsFor(name, column.$meta, column.$meta.primaryKey || keys.has(name), references.get(name)),
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
- readonly relation?: { readonly entity: string; readonly labelField?: string };
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
- /** Only indexed, unique, enum, boolean, and FK columns are offered as filters. */
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. Fields, labels, and required flags come from the resource; validation
2
- // comes from the entity's own schema, so the form rejects exactly what the action would
3
- // reject. Issues render against the field they name, and the summary is focusable.
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
- /** Current values, controlled by the route. Empty object for create. */
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
- readonly onInput: (field: string, value: unknown) => void;
25
- readonly onSubmit: () => void;
26
- readonly onCancel: () => void;
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.message}
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.formFields.map((field) => {
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
- required={field.required}
69
- error={own.length === 0 ? undefined : own.map((issue) => issue.message).join(' ')}
70
- >
71
- {(control) => (
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="x-admin-form-actions">
87
- <button type="submit" disabled={props.submitting}>
88
- {t(props.submitting ? 'admin.form.saving' : 'admin.form.save')}
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
- </button>
125
+ </Link>
93
126
  </div>
94
127
  </form>
95
128
  </Card>