@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/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is the compile-time proof that a real `entity()` result satisfies it; it is checked by
|
|
7
7
|
// `tsc`, not asserted in a comment, because the admin used to read fields no entity had.
|
|
8
8
|
|
|
9
|
-
import type { Entity,
|
|
9
|
+
import type { Entity, Operator, Table } from '@ultimat3/entity';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* What the author declared about one column — `@ultimat3/entity`'s `ColumnMeta`, narrowed to
|
|
@@ -37,11 +37,21 @@ export interface AdminColumnMeta {
|
|
|
37
37
|
* hands back the resolved target — but its presence is what makes the column a foreign key.
|
|
38
38
|
*/
|
|
39
39
|
readonly references?: () => unknown;
|
|
40
|
+
/**
|
|
41
|
+
* `.sealed()`: encrypted at rest, opened on read, absent from every schema that leaves the
|
|
42
|
+
* server. Present means the admin derives NO readable field for it — see `entity-columns.ts`.
|
|
43
|
+
*/
|
|
44
|
+
readonly sealed?: { readonly lookup: boolean };
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/** One column of a registered entity. An `@ultimat3/entity` `Column` satisfies this. */
|
|
43
48
|
export interface AdminColumn {
|
|
44
49
|
readonly $meta: AdminColumnMeta;
|
|
50
|
+
/**
|
|
51
|
+
* The column's own guard. Optional in this surface only because a hand-built fixture has none;
|
|
52
|
+
* every `entity()` column carries it, and `repo-entity.ts` parses a URL's id through it.
|
|
53
|
+
*/
|
|
54
|
+
readonly $parse?: (value: unknown) => unknown;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/** One column of `$describe()` output. Money is the one property that becomes two of these. */
|
|
@@ -65,6 +75,12 @@ export interface AdminEntity {
|
|
|
65
75
|
readonly $columns: Readonly<Record<string, AdminColumn>>;
|
|
66
76
|
/** The Standard Schema the entity validates with; forms hand input straight to it. */
|
|
67
77
|
readonly $schema: unknown;
|
|
78
|
+
/**
|
|
79
|
+
* The column the handle scopes every read and write by, or `null`. Optional in this surface only
|
|
80
|
+
* because a hand-built fixture has none; every `entity()` result carries it. The admin never
|
|
81
|
+
* renders it as an input — the acting actor's tenant is the only value it may hold.
|
|
82
|
+
*/
|
|
83
|
+
readonly $tenantColumn?: string | null;
|
|
68
84
|
/** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
|
|
69
85
|
$describe(): AdminEntityDescription;
|
|
70
86
|
}
|
|
@@ -80,14 +96,63 @@ type Satisfies<Surface, T extends Surface> = T;
|
|
|
80
96
|
export type RegisteredEntity = Satisfies<AdminEntity, Entity<AdminRow>>;
|
|
81
97
|
|
|
82
98
|
/**
|
|
83
|
-
*
|
|
84
|
-
* the
|
|
85
|
-
*
|
|
99
|
+
* The slice of a typed handle's read chain the admin drives — `database()`'s `db.<entity>`. Named
|
|
100
|
+
* for the reason `AdminEntity` is: one file changes when the handle grows, and `RegisteredTable`
|
|
101
|
+
* is the `tsc`-checked proof a real one satisfies it.
|
|
86
102
|
*/
|
|
87
|
-
export
|
|
103
|
+
export interface AdminTableRead {
|
|
104
|
+
where(filter: Readonly<Record<string, unknown>>): AdminTableRead;
|
|
105
|
+
andWhere(column: string, op: Operator, value?: unknown): AdminTableRead;
|
|
106
|
+
orderBy(column: string, direction?: 'asc' | 'desc'): AdminTableRead;
|
|
107
|
+
limit(rows: number): AdminTableRead;
|
|
108
|
+
all(): Promise<readonly AdminRow[]>;
|
|
109
|
+
one(): Promise<AdminRow | null>;
|
|
110
|
+
count(): Promise<number>;
|
|
111
|
+
/** Read for the entity NAME only: it is how a handle is matched to the entity it serves. */
|
|
112
|
+
plan(): { readonly entity: string };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One table of the app's typed handle. Tenancy, soft delete and sealing are its own. */
|
|
116
|
+
export interface AdminTable extends AdminTableRead {
|
|
117
|
+
insert(values: Readonly<Record<string, unknown>>): Promise<AdminRow>;
|
|
118
|
+
update(id: string, patch: Readonly<Record<string, unknown>>): Promise<AdminRow>;
|
|
119
|
+
delete(id: string): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The app's `database()` result, as `defineAdmin({ db })` takes it. Keys are the app's spelling. */
|
|
123
|
+
export type AdminDb = Readonly<Record<string, AdminTable>>;
|
|
124
|
+
|
|
125
|
+
/** The claim, checked: `database()`'s table IS an `AdminTable`. */
|
|
126
|
+
export type RegisteredTable = Satisfies<AdminTable, Table<AdminRow>>;
|
|
127
|
+
|
|
128
|
+
export type FilterOp =
|
|
129
|
+
| 'eq'
|
|
130
|
+
| 'neq'
|
|
131
|
+
| 'contains'
|
|
132
|
+
| 'gt'
|
|
133
|
+
| 'gte'
|
|
134
|
+
| 'lt'
|
|
135
|
+
| 'lte'
|
|
136
|
+
| 'in'
|
|
137
|
+
/** `value: true` keeps the rows with no value, `value: false` the rows with one. */
|
|
138
|
+
| 'is-null';
|
|
88
139
|
|
|
89
|
-
export
|
|
140
|
+
export const FILTER_OPS: readonly FilterOp[] = [
|
|
141
|
+
'eq',
|
|
142
|
+
'neq',
|
|
143
|
+
'contains',
|
|
144
|
+
'gt',
|
|
145
|
+
'gte',
|
|
146
|
+
'lt',
|
|
147
|
+
'lte',
|
|
148
|
+
'in',
|
|
149
|
+
'is-null',
|
|
150
|
+
];
|
|
90
151
|
|
|
152
|
+
/**
|
|
153
|
+
* One predicate of a list read. Every list the admin issues is a CONJUNCTION of these — the URL's
|
|
154
|
+
* filters, the scope's, and the resource's row scope — so each maps to one indexed comparison.
|
|
155
|
+
*/
|
|
91
156
|
export interface AdminFilter {
|
|
92
157
|
readonly field: string;
|
|
93
158
|
readonly op: FilterOp;
|
|
@@ -147,10 +212,33 @@ export interface AdminActionCtx {
|
|
|
147
212
|
readonly timeZone: string;
|
|
148
213
|
}
|
|
149
214
|
|
|
215
|
+
/** How a batch action behaves past the size a request should run inline. */
|
|
216
|
+
export interface AdminBatchOptions {
|
|
217
|
+
/**
|
|
218
|
+
* The most rows one request runs INLINE. A selection larger than this is handed to the queue —
|
|
219
|
+
* one `admin.batch` job per chunk — and the answer says how many rows were queued, not done.
|
|
220
|
+
*/
|
|
221
|
+
readonly threshold: number;
|
|
222
|
+
/** Rows per queued job. Omitted: `threshold`. */
|
|
223
|
+
readonly chunk?: number;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** What a set-based "all matching" answers — the shape of a store's own bulk verb. */
|
|
227
|
+
export interface AdminMatchingResult {
|
|
228
|
+
/** Rows this call changed. */
|
|
229
|
+
readonly affected: number;
|
|
230
|
+
/** Rows still matching afterwards. Run again until it is zero. */
|
|
231
|
+
readonly remaining: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
150
234
|
/**
|
|
151
235
|
* A registered `action` as the admin surfaces it. `permission` is not optional: an action
|
|
152
236
|
* with no policy is an open door, and the admin refuses to render one
|
|
153
237
|
* (X_ADMIN_POLICY_MISSING).
|
|
238
|
+
*
|
|
239
|
+
* One declaration, every projection: the button on the detail page and on each list row, the
|
|
240
|
+
* action's own form, the batch bar, the MCP tool, the audit entry. `when` and `batch` narrow WHERE
|
|
241
|
+
* it appears and never add a second handler.
|
|
154
242
|
*/
|
|
155
243
|
export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output = unknown> {
|
|
156
244
|
readonly name: string;
|
|
@@ -158,15 +246,47 @@ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output =
|
|
|
158
246
|
/** The entity the button belongs to. Absent = a global action in the toolbar. */
|
|
159
247
|
readonly entity?: string;
|
|
160
248
|
readonly destructive?: boolean;
|
|
249
|
+
/** Absent: `admin.action.<name>` — `actionLabelKey`, the one spelling every screen reads. */
|
|
161
250
|
readonly labelKey?: string;
|
|
162
251
|
/** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
|
|
163
252
|
readonly mcp?: { readonly expose?: boolean; readonly description?: string };
|
|
164
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* The action's input schema — a `t.object({ … })`. Rendered as the action's form (one control
|
|
255
|
+
* per property), validated before the handler runs with each issue shown against its field,
|
|
256
|
+
* and handed to the MCP tool definition. Absent: the action takes no input, and its button is a
|
|
257
|
+
* confirm only. The row's `id` is the admin's own envelope and is never a property here.
|
|
258
|
+
*/
|
|
165
259
|
readonly input?: unknown;
|
|
260
|
+
/**
|
|
261
|
+
* Whether the action applies to THIS row. Decides the button on the detail page and on each
|
|
262
|
+
* list row, and is evaluated again on the server before the handler runs — a hidden button is
|
|
263
|
+
* not an authorization: `X_ADMIN_ACTION_NOT_APPLICABLE`. The row it reads carries no sealed
|
|
264
|
+
* column. Absent: the action applies to every row.
|
|
265
|
+
*/
|
|
266
|
+
readonly when?: (row: AdminRow) => boolean;
|
|
267
|
+
/**
|
|
268
|
+
* In the list's batch bar: run once per selected row through the same gate as the button, each
|
|
269
|
+
* row audited, the answer counting done, refused and failed. `true` runs every batch inline;
|
|
270
|
+
* `{ threshold }` queues a selection larger than that as one job per chunk.
|
|
271
|
+
*/
|
|
272
|
+
readonly batch?: true | AdminBatchOptions;
|
|
273
|
+
/**
|
|
274
|
+
* The batch bar's "all matching" as ONE set-based call instead of a walk of rows: handed the
|
|
275
|
+
* list's own `where` — row scope, then scope, then filters — it changes what matches and answers
|
|
276
|
+
* how many it changed and how many still match. For a store with a bounded bulk verb of its own
|
|
277
|
+
* (the jobs operator's `requeueMany`). Decided and audited once, for the set; the per-row gate is
|
|
278
|
+
* not asked per row, so the verb's own predicate is where `when` holds. A checked selection
|
|
279
|
+
* still runs row by row through `handle`. Requires `batch`.
|
|
280
|
+
*/
|
|
281
|
+
readonly matching?: (args: {
|
|
282
|
+
readonly where: readonly AdminFilter[];
|
|
283
|
+
readonly input: Input;
|
|
284
|
+
readonly ctx: AdminActionCtx;
|
|
285
|
+
}) => Promise<AdminMatchingResult>;
|
|
166
286
|
handle(args: { input: Input; ctx: AdminActionCtx }): Promise<Output>;
|
|
167
287
|
}
|
|
168
288
|
|
|
169
|
-
/** A registered `job` as
|
|
289
|
+
/** A registered `job` as an AI pane is told about it (`ai-panes.ts`) — `describeJobs()` output. */
|
|
170
290
|
export interface AdminJobSummary {
|
|
171
291
|
readonly name: string;
|
|
172
292
|
readonly queue?: string;
|
|
@@ -185,3 +305,13 @@ export function rowId(row: AdminRow, idField: string): string {
|
|
|
185
305
|
const value = row[idField];
|
|
186
306
|
return typeof value === 'string' ? value : String(value ?? '');
|
|
187
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A row as something DRAWN or DECIDED on reads it: its enumerable values, minus every sealed
|
|
311
|
+
* column. A repository row keeps a sealed property off its enumerable set already; a hand-written
|
|
312
|
+
* `repo:` may not, so the names are dropped explicitly — a computed cell is rendered and an
|
|
313
|
+
* action's `when` is evaluated per row, and a sealed value reaches neither.
|
|
314
|
+
*/
|
|
315
|
+
export function computedRow(row: AdminRow, sealed: readonly string[]): AdminRow {
|
|
316
|
+
return Object.fromEntries(Object.entries(row).filter(([key]) => !sealed.includes(key)));
|
|
317
|
+
}
|
package/src/related.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A row's RELATED rows: the `hasMany` relations a resource names in `related`, each read as the
|
|
2
|
+
// related resource's own list — its gate, its row scope, its columns — narrowed to the rows that
|
|
3
|
+
// point at this one. No second table is declared anywhere: the related resource already is one.
|
|
4
|
+
|
|
5
|
+
import { relationsFor as entityRelationsFor } from '@ultimat3/entity';
|
|
6
|
+
import { adminList, type CrudCtx, canOperate } from './crud';
|
|
7
|
+
import { AdminFieldUnsupportedError } from './errors';
|
|
8
|
+
import type { AdminPage } from './pagination';
|
|
9
|
+
import type { AdminFilter, AdminRow } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
|
|
12
|
+
/** How many related rows a detail page shows per relation. The rest are one link away. */
|
|
13
|
+
export const RELATED_PAGE_SIZE = 10;
|
|
14
|
+
|
|
15
|
+
/** One declared relation, resolved against the admin it is declared in. */
|
|
16
|
+
export interface AdminRelated {
|
|
17
|
+
/** The relation's name on the entity — what `related: [...]` spelled. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** The resource whose rows these are. */
|
|
20
|
+
readonly resource: AdminResource;
|
|
21
|
+
/** The property of THAT resource holding the foreign key. */
|
|
22
|
+
readonly field: string;
|
|
23
|
+
/** The property of the row in hand the foreign key points at. */
|
|
24
|
+
readonly localKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `related` names of one resource, resolved. Every name is a `hasMany` of the entity whose
|
|
29
|
+
* target is a resource of this admin — anything else is refused here, where it is declared, never
|
|
30
|
+
* drawn as an empty card.
|
|
31
|
+
*/
|
|
32
|
+
export function relatedOf(
|
|
33
|
+
resources: readonly AdminResource[],
|
|
34
|
+
resource: AdminResource,
|
|
35
|
+
): readonly AdminRelated[] {
|
|
36
|
+
if (resource.related.length === 0) return [];
|
|
37
|
+
// The one read of `@ultimat3/entity`'s relations in this package: the FK already written IS
|
|
38
|
+
// the relation, so the admin derives nothing of its own and names nothing twice.
|
|
39
|
+
const relations = entityRelationsFor(resource.name);
|
|
40
|
+
const offered = Object.values(relations)
|
|
41
|
+
.filter((relation) => relation.kind === 'hasMany')
|
|
42
|
+
.map((relation) => relation.name);
|
|
43
|
+
const hint = `resources.${resource.name}.related may name: ${offered.length > 0 ? offered.join(', ') : 'nothing — no entity references this one'}`;
|
|
44
|
+
|
|
45
|
+
return resource.related.map((name) => {
|
|
46
|
+
const refuse = (cause: string, fix: string): never => {
|
|
47
|
+
throw new AdminFieldUnsupportedError({ entity: resource.name, field: name, cause, fix });
|
|
48
|
+
};
|
|
49
|
+
const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
|
|
50
|
+
if (relation === undefined) {
|
|
51
|
+
return refuse('named in related and is not a relation of the entity', hint);
|
|
52
|
+
}
|
|
53
|
+
if (relation.kind !== 'hasMany') {
|
|
54
|
+
return refuse(
|
|
55
|
+
`named in related and is a belongsTo: the "${relation.localKey}" field already links to its row`,
|
|
56
|
+
hint,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const target = resources.find((known) => known.name === relation.to);
|
|
60
|
+
if (target === undefined) {
|
|
61
|
+
return refuse(
|
|
62
|
+
`named in related, and its rows are "${relation.to}", which is not a resource of this admin`,
|
|
63
|
+
`add the ${relation.to} entity to defineAdmin({ entities }) — its own list is what related draws`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { name, resource: target, field: relation.remoteKey, localKey: relation.localKey };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One related list as the detail page draws it. */
|
|
71
|
+
export interface AdminRelatedList {
|
|
72
|
+
readonly related: AdminRelated;
|
|
73
|
+
readonly page: AdminPage<AdminRow>;
|
|
74
|
+
/** The one predicate that makes this list THIS row's. */
|
|
75
|
+
readonly filter: AdminFilter;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The related lists of one row — one statement each, read beside each other. A related resource
|
|
80
|
+
* this actor may not list is LEFT OUT, silently: they asked for this row, and the refusal they
|
|
81
|
+
* would get by opening that list is logged there. Everything else is `adminList`: the related
|
|
82
|
+
* resource's policy decides, its `rows` narrows, and the read is audited under its own name.
|
|
83
|
+
*/
|
|
84
|
+
export async function relatedLists(
|
|
85
|
+
related: readonly AdminRelated[],
|
|
86
|
+
row: AdminRow,
|
|
87
|
+
ctx: CrudCtx,
|
|
88
|
+
): Promise<readonly AdminRelatedList[]> {
|
|
89
|
+
const readable = related.filter((one) => canOperate(one.resource, 'list', ctx));
|
|
90
|
+
const read = await Promise.all(
|
|
91
|
+
readable.map(async (one): Promise<AdminRelatedList | null> => {
|
|
92
|
+
const key = row[one.localKey];
|
|
93
|
+
if (key === null || key === undefined) return null;
|
|
94
|
+
const filter: AdminFilter = { field: one.field, op: 'eq', value: String(key) };
|
|
95
|
+
const result = await adminList<AdminRow>(one.resource, ctx, {
|
|
96
|
+
// No scope, not even the default one: a related row counts whatever tab it sits under.
|
|
97
|
+
scope: null,
|
|
98
|
+
filters: [filter],
|
|
99
|
+
limit: RELATED_PAGE_SIZE,
|
|
100
|
+
});
|
|
101
|
+
return result.ok ? { related: one, page: result.page, filter } : null;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
return read.filter((one): one is AdminRelatedList => one !== null);
|
|
105
|
+
}
|
package/src/relations.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// A resource read AS A TARGET: the label of each row another resource references, and the lookup
|
|
2
|
+
// a picker searches. Both are reads of the target — decided by its `list` gate, narrowed by its
|
|
3
|
+
// row scope, audited under its name — and both are batched: one statement per target resource per
|
|
4
|
+
// page, never one per row and never one per cell.
|
|
5
|
+
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
import type { AuditEntry } from './audit';
|
|
8
|
+
import type { AdminDecision } from './authz';
|
|
9
|
+
import { type CrudCtx, decideOperation } from './crud';
|
|
10
|
+
import { checkedFilter } from './list-filters';
|
|
11
|
+
import { rowWhere } from './list-scope';
|
|
12
|
+
import { type AdminPage, fetchPage } from './pagination';
|
|
13
|
+
import type { AdminRow } from './registry';
|
|
14
|
+
import { rowId } from './registry';
|
|
15
|
+
import { type AdminResource, repoOf } from './resource';
|
|
16
|
+
|
|
17
|
+
/** One row of a target, as a picker offers it and a reference cell shows it. */
|
|
18
|
+
export interface AdminOption {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A target with at most this many visible rows is offered whole, as a `<select>`. One more and
|
|
25
|
+
* the picker is a search: an option list nobody can scan is not a control.
|
|
26
|
+
*/
|
|
27
|
+
export const LOOKUP_SELECT_MAX = 50;
|
|
28
|
+
|
|
29
|
+
/** The most ids one label read names — the list's own page-size ceiling. */
|
|
30
|
+
const MAX_LABEL_IDS = 200;
|
|
31
|
+
|
|
32
|
+
/** The row's label-field value when it is text, else its id — never a blank cell. */
|
|
33
|
+
export function labelOf(
|
|
34
|
+
row: AdminRow,
|
|
35
|
+
resource: Pick<AdminResource, 'labelField' | 'idField'>,
|
|
36
|
+
): string {
|
|
37
|
+
const value = row[resource.labelField];
|
|
38
|
+
if (typeof value === 'string' && value !== '') return value;
|
|
39
|
+
return rowId(row, resource.idField);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const optionOf = (resource: AdminResource, row: AdminRow): AdminOption => ({
|
|
43
|
+
id: rowId(row, resource.idField),
|
|
44
|
+
label: labelOf(row, resource),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const readEntry = (resource: AdminResource, ctx: CrudCtx, decision: AdminDecision) =>
|
|
48
|
+
ctx.audit.append({
|
|
49
|
+
requestId: ctx.requestId,
|
|
50
|
+
actor: ctx.actor,
|
|
51
|
+
operation: 'list',
|
|
52
|
+
kind: 'operation',
|
|
53
|
+
entity: resource.name,
|
|
54
|
+
entityId: null,
|
|
55
|
+
permission: decision.permission,
|
|
56
|
+
outcome: 'allowed',
|
|
57
|
+
reason: decision.reason,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export interface AdminLookupRequest {
|
|
61
|
+
/** Matched against the target's label, by its label filter's default operator. */
|
|
62
|
+
readonly term?: string;
|
|
63
|
+
readonly cursor?: string | null;
|
|
64
|
+
readonly limit?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type AdminLookupResult =
|
|
68
|
+
| {
|
|
69
|
+
readonly ok: true;
|
|
70
|
+
readonly options: readonly AdminOption[];
|
|
71
|
+
readonly page: AdminPage<AdminRow>;
|
|
72
|
+
readonly audit: AuditEntry;
|
|
73
|
+
}
|
|
74
|
+
| { readonly ok: false; readonly decision: AdminDecision };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The one lookup of a target resource: `contains` on its label, keyset-paged in the resource's own
|
|
78
|
+
* default order, behind the same `list` gate and the same row scope as its list screen. Every
|
|
79
|
+
* filter and every input that references the resource asks this — an app writes no endpoint.
|
|
80
|
+
*/
|
|
81
|
+
export async function adminLookup(
|
|
82
|
+
resource: AdminResource,
|
|
83
|
+
ctx: CrudCtx,
|
|
84
|
+
request: AdminLookupRequest = {},
|
|
85
|
+
): Promise<AdminLookupResult> {
|
|
86
|
+
const decision = decideOperation(resource, 'list', ctx);
|
|
87
|
+
if (!decision.allowed) return { ok: false, decision };
|
|
88
|
+
const term = request.term?.trim() ?? '';
|
|
89
|
+
const where = [
|
|
90
|
+
...rowWhere(resource, ctx.actor),
|
|
91
|
+
// Through the validator, like any caller's filter: a label that takes no `contains` (an id)
|
|
92
|
+
// is matched exactly, and one that is no filter at all is refused by name.
|
|
93
|
+
...(term === '' ? [] : [checkedFilter(resource, { field: resource.labelField, value: term })]),
|
|
94
|
+
];
|
|
95
|
+
const page = await fetchPage(resource, {
|
|
96
|
+
...(request.cursor === undefined ? {} : { cursor: request.cursor }),
|
|
97
|
+
// At least 1, and finite: a `NaN` here is a page that is never trimmed and never ends.
|
|
98
|
+
limit: finiteCount('adminLookup', 'limit', request.limit ?? LOOKUP_SELECT_MAX, 1),
|
|
99
|
+
...(where.length === 0 ? {} : { where }),
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
options: page.rows.map((row) => optionOf(resource, row)),
|
|
104
|
+
page,
|
|
105
|
+
audit: await readEntry(resource, ctx, decision),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What one page needs of one target: the ids its cells show, and whether a control picks from it. */
|
|
110
|
+
export interface RelationNeed {
|
|
111
|
+
readonly ids: ReadonlySet<string>;
|
|
112
|
+
/** A filter or an input on the page picks a row of this target. */
|
|
113
|
+
readonly pick: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RelationData {
|
|
117
|
+
/** id → label, for every id the page shows that this actor may see. */
|
|
118
|
+
readonly labels: ReadonlyMap<string, string>;
|
|
119
|
+
/**
|
|
120
|
+
* Every row of the target, when there are at most `LOOKUP_SELECT_MAX` — the `<select>`. `null`
|
|
121
|
+
* when there are more (the picker is the lookup screen) or when nothing on the page picks.
|
|
122
|
+
*/
|
|
123
|
+
readonly options: readonly AdminOption[] | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const NOTHING: RelationData = { labels: new Map(), options: null };
|
|
127
|
+
|
|
128
|
+
async function relationOf(
|
|
129
|
+
resource: AdminResource,
|
|
130
|
+
ctx: CrudCtx,
|
|
131
|
+
need: RelationNeed,
|
|
132
|
+
): Promise<RelationData> {
|
|
133
|
+
if (!need.pick && need.ids.size === 0) return NOTHING;
|
|
134
|
+
const decision = decideOperation(resource, 'list', ctx);
|
|
135
|
+
// Not an event: the actor asked for another resource's page, and a label they may not read is
|
|
136
|
+
// an id shown as an id. The refusal they would get by opening the target is logged there.
|
|
137
|
+
if (!decision.allowed) return NOTHING;
|
|
138
|
+
|
|
139
|
+
const repo = repoOf(resource);
|
|
140
|
+
const visible = rowWhere(resource, ctx.actor);
|
|
141
|
+
const labels = new Map<string, string>();
|
|
142
|
+
let options: readonly AdminOption[] | null = null;
|
|
143
|
+
let wanted = [...need.ids].slice(0, MAX_LABEL_IDS);
|
|
144
|
+
|
|
145
|
+
if (need.pick) {
|
|
146
|
+
// One over the ceiling: the extra row is how "small enough for a select" is learned without
|
|
147
|
+
// a count, which on a large table is the slow query this probe exists to avoid.
|
|
148
|
+
const rows = await repo.list({
|
|
149
|
+
...(visible.length === 0 ? {} : { where: visible }),
|
|
150
|
+
sort: resource.defaultSort,
|
|
151
|
+
limit: LOOKUP_SELECT_MAX + 1,
|
|
152
|
+
});
|
|
153
|
+
for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
|
|
154
|
+
if (rows.length <= LOOKUP_SELECT_MAX) {
|
|
155
|
+
// The whole target is in hand, so every label the page needs is too: no second read.
|
|
156
|
+
options = rows
|
|
157
|
+
.map((row) => optionOf(resource, row))
|
|
158
|
+
.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
|
|
159
|
+
wanted = [];
|
|
160
|
+
} else {
|
|
161
|
+
wanted = wanted.filter((id) => !labels.has(id));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (wanted.length > 0) {
|
|
166
|
+
const rows = await repo.list({
|
|
167
|
+
where: [...visible, { field: resource.idField, op: 'in', value: wanted }],
|
|
168
|
+
sort: resource.defaultSort,
|
|
169
|
+
limit: wanted.length,
|
|
170
|
+
});
|
|
171
|
+
for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await readEntry(resource, ctx, decision);
|
|
175
|
+
return { labels, options };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Everything one page needs from the resources it references, keyed by target name. One statement
|
|
180
|
+
* per target — two for a picked target too large for a `<select>` — whatever the page's row count
|
|
181
|
+
* and however many columns point at the same target.
|
|
182
|
+
*/
|
|
183
|
+
export async function relationsFor(
|
|
184
|
+
resources: readonly AdminResource[],
|
|
185
|
+
ctx: CrudCtx,
|
|
186
|
+
needs: ReadonlyMap<string, RelationNeed>,
|
|
187
|
+
): Promise<ReadonlyMap<string, RelationData>> {
|
|
188
|
+
const targets = resources.filter((resource) => needs.has(resource.name));
|
|
189
|
+
const read = await Promise.all(
|
|
190
|
+
targets.map((resource) => relationOf(resource, ctx, needs.get(resource.name) ?? NO_NEED)),
|
|
191
|
+
);
|
|
192
|
+
return new Map(targets.map((resource, index) => [resource.name, read[index] ?? NOTHING]));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const NO_NEED: RelationNeed = { ids: new Set(), pick: false };
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What a set of rows and fields needs: per target resource, the ids the rows hold in the fields
|
|
199
|
+
* that reference it, and whether any of `picked` — a filter, a form input — picks from it.
|
|
200
|
+
*/
|
|
201
|
+
export function relationNeeds(
|
|
202
|
+
rows: readonly AdminRow[],
|
|
203
|
+
shown: readonly { readonly name: string; readonly relation?: { readonly entity: string } }[],
|
|
204
|
+
picked: readonly { readonly relation?: { readonly entity: string } }[],
|
|
205
|
+
): ReadonlyMap<string, RelationNeed> {
|
|
206
|
+
const needs = new Map<string, { ids: Set<string>; pick: boolean }>();
|
|
207
|
+
const need = (entity: string): { ids: Set<string>; pick: boolean } => {
|
|
208
|
+
const held = needs.get(entity) ?? { ids: new Set<string>(), pick: false };
|
|
209
|
+
needs.set(entity, held);
|
|
210
|
+
return held;
|
|
211
|
+
};
|
|
212
|
+
for (const field of shown) {
|
|
213
|
+
if (field.relation === undefined) continue;
|
|
214
|
+
const held = need(field.relation.entity);
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
const value = row[field.name];
|
|
217
|
+
if (value !== null && value !== undefined && value !== '') held.ids.add(String(value));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const field of picked) {
|
|
221
|
+
if (field.relation !== undefined) need(field.relation.entity).pick = true;
|
|
222
|
+
}
|
|
223
|
+
return needs;
|
|
224
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The six renderers a computed column names, and the one dispatch that draws a computed cell. A
|
|
2
|
+
// closed set on purpose: each maps a VALUE SHAPE to an `@ultimat3/ui` component, so a column says
|
|
3
|
+
// what its value is and never how it is drawn. A seventh shape is a component the app passes.
|
|
4
|
+
|
|
5
|
+
import { safeUrl } from '@ultimat3/core';
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import { Badge, Money, RelativeTime, TONES, type Tone } from '@ultimat3/ui';
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import { jsonText } from './json-text';
|
|
10
|
+
import type { AdminRow } from './registry';
|
|
11
|
+
import type { AdminComputedColumn, AdminRenderer } from './resource-list';
|
|
12
|
+
import { assertMoney, type WidgetContext } from './widget-value';
|
|
13
|
+
|
|
14
|
+
/** What `truncate` keeps. One line of a laptop-width table cell. */
|
|
15
|
+
export const TRUNCATE_AT = 80;
|
|
16
|
+
|
|
17
|
+
const isTone = (value: unknown): value is Tone => (TONES as readonly unknown[]).includes(value);
|
|
18
|
+
|
|
19
|
+
const bag = (value: unknown): Readonly<Record<string, unknown>> =>
|
|
20
|
+
typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : {};
|
|
21
|
+
|
|
22
|
+
const text = (value: unknown): string =>
|
|
23
|
+
typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
|
|
24
|
+
|
|
25
|
+
const empty = (): JSX.Element => <span>{t('admin.value.empty')}</span>;
|
|
26
|
+
|
|
27
|
+
type Render = (value: unknown, column: AdminComputedColumn, ctx: WidgetContext) => JSX.Element;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* | renderer | value |
|
|
31
|
+
* |---|---|
|
|
32
|
+
* | `badge` | text, or `{ label, tone }` |
|
|
33
|
+
* | `relative-time` | a `Date` or an ISO instant |
|
|
34
|
+
* | `money` | `{ minor, currency }` — a float is refused, as everywhere |
|
|
35
|
+
* | `truncate` | text, cut at `TRUNCATE_AT` with the whole value on `title` |
|
|
36
|
+
* | `link` | `{ href, label }` — the href through `safeUrl` |
|
|
37
|
+
* | `json` | anything — keys sorted, a bigint as its digits (`json-text.ts`) |
|
|
38
|
+
*/
|
|
39
|
+
const RENDERERS: Readonly<Record<AdminRenderer, Render>> = {
|
|
40
|
+
badge: (value) => {
|
|
41
|
+
const tone = bag(value)['tone'];
|
|
42
|
+
const label = typeof value === 'object' ? text(bag(value)['label']) : text(value);
|
|
43
|
+
return label === '' ? empty() : <Badge tone={isTone(tone) ? tone : 'neutral'}>{label}</Badge>;
|
|
44
|
+
},
|
|
45
|
+
'relative-time': (value, _column, ctx) =>
|
|
46
|
+
value instanceof Date || typeof value === 'string' ? (
|
|
47
|
+
<RelativeTime value={value} locale={ctx.locale} timeZone={ctx.timeZone} />
|
|
48
|
+
) : (
|
|
49
|
+
empty()
|
|
50
|
+
),
|
|
51
|
+
money: (value, column, ctx) => {
|
|
52
|
+
// The guard names what it refuses; a computed column has no entity field to name.
|
|
53
|
+
const money = assertMoney({ entity: 'columns', name: column.name }, value);
|
|
54
|
+
return money === null ? empty() : <Money value={money} locale={ctx.locale} />;
|
|
55
|
+
},
|
|
56
|
+
truncate: (value) => {
|
|
57
|
+
const whole = text(value);
|
|
58
|
+
if (whole === '') return empty();
|
|
59
|
+
return whole.length <= TRUNCATE_AT ? (
|
|
60
|
+
<span>{whole}</span>
|
|
61
|
+
) : (
|
|
62
|
+
<span title={whole}>{`${whole.slice(0, TRUNCATE_AT)}…`}</span>
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
link: (value) => {
|
|
66
|
+
const href = safeUrl(text(bag(value)['href']), 'href');
|
|
67
|
+
const label = text(bag(value)['label']);
|
|
68
|
+
if (label === '') return empty();
|
|
69
|
+
return href === null ? <span>{label}</span> : <a href={href}>{label}</a>;
|
|
70
|
+
},
|
|
71
|
+
json: (value) =>
|
|
72
|
+
value === undefined ? empty() : <pre class="x-admin-json">{jsonText(value)}</pre>,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The same table as a Map: a name that is not one of the six draws an empty cell. */
|
|
76
|
+
const RENDER: ReadonlyMap<string, Render> = new Map(Object.entries(RENDERERS));
|
|
77
|
+
|
|
78
|
+
/** One computed cell. */
|
|
79
|
+
export function ComputedCell(props: {
|
|
80
|
+
readonly column: AdminComputedColumn;
|
|
81
|
+
readonly row: AdminRow;
|
|
82
|
+
readonly ctx: WidgetContext;
|
|
83
|
+
}): JSX.Element {
|
|
84
|
+
const value = props.column.value(props.row);
|
|
85
|
+
const render = props.column.render;
|
|
86
|
+
return typeof render === 'function'
|
|
87
|
+
? render({ value, row: props.row, ctx: props.ctx })
|
|
88
|
+
: (RENDER.get(render)?.(value, props.column, props.ctx) ?? empty());
|
|
89
|
+
}
|