@ultimat3/admin 22.15.0 → 24.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 (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  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 +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
@@ -0,0 +1,31 @@
1
+ // The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
2
+ // transaction, a statement timeout and a row ceiling — never through the app's plain `db.query`.
3
+ // A host wires `runSql: readOnlySql(client)`; the guard upstream decides what reaches it.
4
+
5
+ import { type DbClient, readOnlyQuery } from '@ultimat3/db';
6
+ import type { SqlResult } from './facts';
7
+
8
+ /**
9
+ * The most rows the panel asks the server for. It draws a grid, not an export: a cursor fetches
10
+ * this many and the rest of a `select * from events` is never materialised in the dev process.
11
+ */
12
+ export const DEV_SQL_MAX_ROWS = 500;
13
+
14
+ /**
15
+ * The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
16
+ * guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
17
+ * runs away still meets a read-only transaction that always rolls back, with the app's own
18
+ * credentials and no way to commit. Columns are the first row's keys — the driver hands objects.
19
+ */
20
+ export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
21
+ return async (sql) => {
22
+ const started = performance.now();
23
+ const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
24
+ client,
25
+ maxRows: DEV_SQL_MAX_ROWS,
26
+ });
27
+ const elapsedMs = Math.round(performance.now() - started);
28
+ const columns = Object.keys(rows[0] ?? {});
29
+ return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
30
+ };
31
+ }
@@ -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
@@ -1,7 +1,7 @@
1
1
  // The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,
2
2
  // because the two dashboards fail at boot (bad registry, bad mount) where an agent has no
3
3
  // stack trace to reason from — only the message.
4
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
4
+ import { NotImplementedError, registerErrorCodes, UltimateError } from '@ultimat3/core';
5
5
 
6
6
  /** Codes this package declares and owns. */
7
7
  export const ADMIN_OWNED_ERROR_CODES = [
@@ -25,11 +25,15 @@ 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
  /**
31
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` throws it; this package
32
- * neither titles nor registers it, because the owner's title is the only one that may exist.
35
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` IS core's
36
+ * `NotImplementedError`, narrowed; this package neither constructs, titles nor registers the code, because the owner's title is the only one that may exist.
33
37
  */
34
38
  export const ADMIN_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
35
39
 
@@ -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
@@ -171,11 +254,15 @@ export class AdminPagePathInvalidError extends UltimateError {
171
254
  * A `/_x` panel needs a fact the framework cannot introspect on its own — request traces,
172
255
  * caught mail, the read-only SQL tool, the committed manifest. Thrown instead of drawing an
173
256
  * empty panel, because an empty panel reads as "nothing happened".
257
+ *
258
+ * Core's NotImplementedError and not a sibling of it: core is the one constructor of the code.
259
+ * A class of its own all the same, because a panel degrades on "this source is not wired" and on
260
+ * nothing wider — a wired source whose driver answers X_NOT_IMPLEMENTED is a diagnostic, not an
261
+ * unwired tab (dev/panel-degrade.test.ts).
174
262
  */
175
- export class DevSourceUnavailableError extends UltimateError {
263
+ export class DevSourceUnavailableError extends NotImplementedError {
176
264
  constructor(input: { source: string; panel: string; wiring?: string }) {
177
265
  super({
178
- code: 'X_NOT_IMPLEMENTED',
179
266
  cause: `the /_x ${input.panel} panel needs the "${input.source}" source, which is not wired in this process`,
180
267
  // `wiring`, when supplied, replaces the whole `defaultDevSources(...)` argument text —
181
268
  // the default `hooks: { <source> }` phrasing is only valid when the source really is a
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,132 @@
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 { AdminRow } from './registry';
10
+ import type { AdminResource } from './resource';
11
+
12
+ /** The posted name of a money field's currency, beside the minor-unit amount under the field's own. */
13
+ export const currencyFieldOf = (field: string): string => `${field}.currency`;
14
+
15
+ type Posted = Readonly<Record<string, unknown>>;
16
+
17
+ /**
18
+ * What a date control is RENDERED with: `<input type="date">` wants `YYYY-MM-DD`, `datetime-local`
19
+ * wants `YYYY-MM-DDTHH:mm`. One function for the widget that draws the box and for the decode that
20
+ * reads it back, so "the operator left it as it was" is a comparison of one text with itself.
21
+ */
22
+ export const datetimeInputValue = (iso: string, precision: 'date' | 'instant'): string =>
23
+ iso.slice(0, precision === 'date' ? 10 : 16);
24
+
25
+ const isoOf = (value: unknown): string | undefined =>
26
+ value instanceof Date
27
+ ? Number.isNaN(value.getTime())
28
+ ? undefined
29
+ : value.toISOString()
30
+ : typeof value === 'string'
31
+ ? value
32
+ : undefined;
33
+
34
+ /**
35
+ * An instant posted back as the box rendered it. `datetime-local` holds minutes, so writing what
36
+ * came back turned `10:20:45.123` into `10:20:00.000` on every edit of ANY field — a change nobody
37
+ * made, recorded in the audit diff as theirs. Such a field is left out of the patch and the stored
38
+ * instant stands. A calendar date carries every digit it has and is never in this position.
39
+ */
40
+ const untouchedInstant = (
41
+ field: AdminField,
42
+ raw: string | undefined,
43
+ before: AdminRow,
44
+ ): boolean => {
45
+ if (field.widget !== 'datetime' || field.type === 'date' || raw === undefined) return false;
46
+ const stored = isoOf(before[field.name]);
47
+ return stored !== undefined && raw === datetimeInputValue(stored, 'instant');
48
+ };
49
+
50
+ /** The last value a posted name carried, as text. A repeated name arrives as a list. */
51
+ export const posted = (form: Posted, name: string): string | undefined => {
52
+ if (!Object.hasOwn(form, name)) return undefined;
53
+ const value = form[name];
54
+ const last: unknown = Array.isArray(value) ? value[value.length - 1] : value;
55
+ return typeof last === 'string' ? last : undefined;
56
+ };
57
+
58
+ /** The widgets whose value is not text: an empty box of one is no value, never `''`. */
59
+ const TYPED: ReadonlySet<AdminField['widget']> = new Set([
60
+ 'money',
61
+ 'number-input',
62
+ 'datetime',
63
+ 'json-editor',
64
+ ]);
65
+
66
+ /**
67
+ * Empty is "no value" for a column that may hold none, and the empty string for a required TEXT
68
+ * one — so the schema refuses it in its own words. A typed widget has no empty string: `''` in a
69
+ * money field reached the widget on the 422 re-render and was refused as "money value is a
70
+ * string", a 500 where the form's own issue belonged.
71
+ */
72
+ const blank = (field: AdminField): unknown =>
73
+ field.required && !TYPED.has(field.widget) ? '' : null;
74
+
75
+ const numberOf = (raw: string): unknown => {
76
+ const parsed = Number(raw);
77
+ return Number.isFinite(parsed) ? parsed : raw;
78
+ };
79
+
80
+ const jsonOf = (raw: string): unknown => {
81
+ try {
82
+ return JSON.parse(raw);
83
+ } catch {
84
+ // Left as typed: the schema names the field, which a thrown SyntaxError would not.
85
+ return raw;
86
+ }
87
+ };
88
+
89
+ function decodeField(field: AdminField, form: Posted): unknown {
90
+ const raw = posted(form, field.name);
91
+ // An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
92
+ if (field.widget === 'checkbox') return raw !== undefined && raw !== 'false';
93
+ if (raw === undefined || raw === '') return blank(field);
94
+ switch (field.widget) {
95
+ case 'number-input':
96
+ return numberOf(raw);
97
+ case 'money':
98
+ return {
99
+ minor: numberOf(raw),
100
+ currency: posted(form, currencyFieldOf(field.name)) ?? field.currency ?? '',
101
+ };
102
+ case 'datetime':
103
+ // `datetime-local` posts `YYYY-MM-DDTHH:mm` and the control says UTC beside the box; a
104
+ // calendar date stays the `YYYY-MM-DD` string its column holds.
105
+ return field.type === 'date' ? raw : new Date(`${raw.slice(0, 16)}:00.000Z`);
106
+ case 'json-editor':
107
+ return jsonOf(raw);
108
+ default:
109
+ return raw;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Every field the form rendered, decoded. A sealed column rides along as the string that was
115
+ * typed — `crud.ts` drops an empty one, which is what makes an untouched box mean "unchanged".
116
+ *
117
+ * `before` is the row an EDIT form was rendered from: an instant that comes back exactly as it was
118
+ * drawn is left out rather than rewritten at the control's precision. A create has none.
119
+ */
120
+ export function decodeForm(
121
+ resource: AdminResource,
122
+ form: Posted,
123
+ before?: AdminRow,
124
+ ): Readonly<Record<string, unknown>> {
125
+ const out: Record<string, unknown> = {};
126
+ for (const field of resource.formFields) {
127
+ if (before !== undefined && untouchedInstant(field, posted(form, field.name), before)) continue;
128
+ out[field.name] = decodeField(field, form);
129
+ }
130
+ for (const field of resource.secretFields) out[field.name] = posted(form, field.name) ?? '';
131
+ return out;
132
+ }
package/src/form.tsx CHANGED
@@ -1,13 +1,18 @@
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';
15
+ import { VERSION_FIELD } from './row-version';
11
16
  import type { ValidationIssue } from './validate';
12
17
  import type { WidgetContext } from './widget-value';
13
18
  import { Widget } from './widgets';
@@ -15,20 +20,41 @@ import { Widget } from './widgets';
15
20
  export interface AdminFormProps<Row extends AdminRow> {
16
21
  readonly resource: AdminResource<Row>;
17
22
  readonly mode: 'create' | 'edit';
18
- /** Current values, controlled by the route. Empty object for create. */
23
+ /** The stored row on a first render, the posted values on a refused one. Empty for create. */
19
24
  readonly values: Readonly<Record<string, unknown>>;
20
25
  readonly issues: readonly ValidationIssue[];
21
- readonly submitting: boolean;
22
26
  readonly error: AdminErrorParts | null;
23
27
  readonly ctx: WidgetContext;
24
- readonly onInput: (field: string, value: unknown) => void;
25
- readonly onSubmit: () => void;
26
- readonly onCancel: () => void;
28
+ /** Where the form posts: the URL that rendered it. */
29
+ readonly action: string;
30
+ /** Where Cancel goes — the list for a create, the row for an edit. */
31
+ readonly cancelHref: string;
32
+ /**
33
+ * An edit's `rowVersion()` of the row it was rendered from, posted back as `_version` so the
34
+ * write is refused if the row moved on meanwhile. `null` for a create, which has no row.
35
+ */
36
+ readonly version: string | null;
27
37
  }
28
38
 
29
39
  const issuesFor = (issues: readonly ValidationIssue[], field: string): readonly ValidationIssue[] =>
30
40
  issues.filter((issue) => issue.path === field);
31
41
 
42
+ /**
43
+ * The line under a control: the declared `hintKey`, and — for a sealed column on an edit — that an
44
+ * empty box keeps the stored value. Both, when both apply.
45
+ */
46
+ const hintOf = (field: AdminField, unchanged: boolean): string | undefined => {
47
+ const lines = [
48
+ ...(field.hintKey === undefined ? [] : [t(field.hintKey)]),
49
+ ...(unchanged ? [t('admin.form.secret-unchanged')] : []),
50
+ ];
51
+ return lines.length === 0 ? undefined : lines.join(' ');
52
+ };
53
+
54
+ /** An issue the admin raised itself carries a key; the schema's own carry their message. */
55
+ export const issueText = (issue: ValidationIssue): string =>
56
+ issue.messageKey === undefined ? issue.message : t(issue.messageKey);
57
+
32
58
  export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX.Element {
33
59
  if (props.error !== null) {
34
60
  return <ErrorState error={adminErrorFrom(props.error)} />;
@@ -36,60 +62,81 @@ export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX
36
62
 
37
63
  const titleKey = props.mode === 'create' ? 'admin.form.create' : 'admin.form.edit';
38
64
 
65
+ const input = (field: AdminField): JSX.Element => {
66
+ const secret = field.type === 'secret';
67
+ const own = issuesFor(props.issues, field.name);
68
+ return (
69
+ // The anchor target the issue summary links to. <Field> owns the control's own
70
+ // id, so the deep link lands on the wrapper and the label still points at the input.
71
+ <div id={`x-admin-field-${field.name}`}>
72
+ <Field
73
+ label={t(field.labelKey)}
74
+ // A sealed column is required to CREATE a row and optional ever after: an empty box on
75
+ // an edit means "leave it as it is", because nothing can prefill it.
76
+ required={secret ? field.required && props.mode === 'create' : field.required}
77
+ hint={hintOf(field, secret && props.mode === 'edit')}
78
+ error={own.length === 0 ? undefined : own.map(issueText).join(' ')}
79
+ >
80
+ {(control) => (
81
+ <Widget
82
+ field={field}
83
+ // A sealed column's widget takes no value by TYPE (`widget-value.ts`), so what a
84
+ // refused post typed into it is dropped there, once, for every caller.
85
+ value={props.values[field.name]}
86
+ ctx={props.ctx}
87
+ mode="edit"
88
+ control={control}
89
+ />
90
+ )}
91
+ </Field>
92
+ </div>
93
+ );
94
+ };
95
+
39
96
  return (
40
97
  <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
- >
98
+ <form class={styles['form']} method="post" action={props.action}>
99
+ {props.version === null ? null : (
100
+ <input type="hidden" name={VERSION_FIELD} value={props.version} />
101
+ )}
47
102
  {props.issues.length === 0 ? null : (
48
103
  <div class="x-admin-issues" role="alert" tabindex={-1}>
49
104
  <h3>{t('admin.form.issues')}</h3>
50
105
  <ul>
51
- {props.issues.map((issue) => (
52
- <li>
53
- <a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issue.message}
54
- </li>
55
- ))}
106
+ {props.issues.map((issue) =>
107
+ // The form's own issue (the row changed under it) names no control to jump to.
108
+ issue.path === VERSION_FIELD ? (
109
+ <li>{issueText(issue)}</li>
110
+ ) : (
111
+ <li>
112
+ <a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issueText(issue)}
113
+ </li>
114
+ ),
115
+ )}
56
116
  </ul>
57
117
  </div>
58
118
  )}
59
119
 
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>
120
+ {props.resource.formGroups.map((group) => {
121
+ // A field that exists on the other side only (`on`) is not drawn, and a group left
122
+ // with nothing to draw is not drawn either.
123
+ const fields = fieldsFor(group.fields, props.mode);
124
+ if (fields.length === 0) return null;
125
+ return group.titleKey === null ? (
126
+ fields.map(input)
127
+ ) : (
128
+ <fieldset class={styles['section']}>
129
+ <legend>{t(group.titleKey)}</legend>
130
+ {fields.map(input)}
131
+ </fieldset>
83
132
  );
84
133
  })}
85
134
 
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()}>
135
+ <div class={styles['actions']}>
136
+ <Button type="submit">{t('admin.form.save')}</Button>
137
+ <Link appearance="button" variant="ghost" tone="neutral" href={props.cancelHref}>
91
138
  {t('admin.form.cancel')}
92
- </button>
139
+ </Link>
93
140
  </div>
94
141
  </form>
95
142
  </Card>