@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.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- 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 +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- 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-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -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 +137 -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/keyset-seek.ts +189 -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 +242 -0
- package/src/list-request.ts +129 -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 +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -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/row-version.ts +20 -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 +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- 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 -16
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// How a resource's fields are ARRANGED: the detail page's sections and the form's groups. One
|
|
2
|
+
// normaliser for both, and one rule that makes it safe to declare either — a field named in no
|
|
3
|
+
// group falls into a default one, so adding a column to the entity never hides it from a screen.
|
|
4
|
+
|
|
5
|
+
import { AdminFieldUnsupportedError } from './errors';
|
|
6
|
+
import type { AdminField } from './fields';
|
|
7
|
+
|
|
8
|
+
/** One declared group: a titled run of fields, in the order they are named. */
|
|
9
|
+
export interface AdminSectionOptions {
|
|
10
|
+
/** i18n key of the heading. */
|
|
11
|
+
readonly titleKey: string;
|
|
12
|
+
readonly fields: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The form's spelling of the same thing. One shape, so a detail and its form can share a list. */
|
|
16
|
+
export type AdminFormGroupOptions = AdminSectionOptions;
|
|
17
|
+
|
|
18
|
+
export interface AdminSection {
|
|
19
|
+
/** `null` only for the single group of a resource that declared none: it has no heading. */
|
|
20
|
+
readonly titleKey: string | null;
|
|
21
|
+
readonly fields: readonly AdminField[];
|
|
22
|
+
/** The group the undeclared fields fell into. Drawn last. */
|
|
23
|
+
readonly default: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The heading of the default group, when declared groups sit above it. */
|
|
27
|
+
export const DEFAULT_SECTION_KEY = 'admin.section.other';
|
|
28
|
+
|
|
29
|
+
interface Layout {
|
|
30
|
+
readonly entity: string;
|
|
31
|
+
/** `sections` or `formGroups` — what a refusal names. */
|
|
32
|
+
readonly option: 'sections' | 'formGroups';
|
|
33
|
+
/** The fields this layout may arrange, in derived order. */
|
|
34
|
+
readonly fields: readonly AdminField[];
|
|
35
|
+
/** Why a named field that is a column is still not one of `fields`. */
|
|
36
|
+
readonly excluded: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Declared groups in declaration order, then the default group holding every field nobody named —
|
|
41
|
+
* in derived order — when there is one. A name that is not a field of this layout, or that two
|
|
42
|
+
* groups both claim, is refused where it is written.
|
|
43
|
+
*/
|
|
44
|
+
export function layoutOf(
|
|
45
|
+
layout: Layout,
|
|
46
|
+
declared: readonly AdminSectionOptions[] | undefined,
|
|
47
|
+
): readonly AdminSection[] {
|
|
48
|
+
if (declared === undefined || declared.length === 0) {
|
|
49
|
+
return [{ titleKey: null, fields: layout.fields, default: true }];
|
|
50
|
+
}
|
|
51
|
+
const claimed = new Map<string, string>();
|
|
52
|
+
const groups = declared.map((group): AdminSection => {
|
|
53
|
+
const fields = group.fields.map((name) => {
|
|
54
|
+
const field = layout.fields.find((known) => known.name === name);
|
|
55
|
+
const refuse = (cause: string, fix: string): never => {
|
|
56
|
+
throw new AdminFieldUnsupportedError({ entity: layout.entity, field: name, cause, fix });
|
|
57
|
+
};
|
|
58
|
+
if (field === undefined) {
|
|
59
|
+
return refuse(
|
|
60
|
+
`named in ${layout.option} ("${group.titleKey}") and ${layout.excluded}`,
|
|
61
|
+
`remove "${name}" from resources.${layout.entity}.${layout.option} — it may name: ${layout.fields.map((known) => known.name).join(', ')}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const owner = claimed.get(name);
|
|
65
|
+
if (owner !== undefined) {
|
|
66
|
+
return refuse(
|
|
67
|
+
`named in two ${layout.option} groups ("${owner}" and "${group.titleKey}")`,
|
|
68
|
+
`keep "${name}" in one group of resources.${layout.entity}.${layout.option}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
claimed.set(name, group.titleKey);
|
|
72
|
+
return field;
|
|
73
|
+
});
|
|
74
|
+
return { titleKey: group.titleKey, fields, default: false };
|
|
75
|
+
});
|
|
76
|
+
const rest = layout.fields.filter((field) => !claimed.has(field.name));
|
|
77
|
+
return rest.length === 0
|
|
78
|
+
? groups
|
|
79
|
+
: [...groups, { titleKey: DEFAULT_SECTION_KEY, fields: rest, default: true }];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The fields of one group a form in `mode` draws: a field declared for the other side is left out. */
|
|
83
|
+
export function fieldsFor(
|
|
84
|
+
fields: readonly AdminField[],
|
|
85
|
+
mode: 'create' | 'edit',
|
|
86
|
+
): readonly AdminField[] {
|
|
87
|
+
const side = mode === 'create' ? 'create' : 'update';
|
|
88
|
+
return fields.filter((field) => field.on === undefined || field.on === side);
|
|
89
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The list half of a resource declaration: scopes, the row scope and computed columns — each
|
|
2
|
+
// normalised once and validated where it is written, so a list screen, the search, the lookup and
|
|
3
|
+
// the MCP tool all read the same three values and none of them re-reads the options.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import type { AdminActor } from './authz';
|
|
7
|
+
import { AdminFieldUnsupportedError, AdminFilterInvalidError } from './errors';
|
|
8
|
+
import { declaredFilters } from './list-filters';
|
|
9
|
+
import type { AdminEntity, AdminFilter, AdminRepo, AdminRow } from './registry';
|
|
10
|
+
import type { WidgetContext } from './widget-value';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The path segment a resource's lookup is mounted under: `/admin/<resource>/lookup`. A literal
|
|
14
|
+
* segment outranks `:id`, exactly as `new` does, so no row can be shadowed by it.
|
|
15
|
+
*/
|
|
16
|
+
export const LOOKUP_SEGMENT = 'lookup';
|
|
17
|
+
|
|
18
|
+
/** Predicates, or predicates of the acting actor (`mine`, `my team's`). */
|
|
19
|
+
export type AdminScopeWhere =
|
|
20
|
+
| readonly AdminFilter[]
|
|
21
|
+
| ((actor: AdminActor) => readonly AdminFilter[]);
|
|
22
|
+
|
|
23
|
+
export interface AdminScopeOptions {
|
|
24
|
+
readonly where: AdminScopeWhere;
|
|
25
|
+
/** The scope a bare list URL applies. At most one; with none, the bare list is unscoped. */
|
|
26
|
+
readonly default?: boolean;
|
|
27
|
+
/** Show the row count on the tab. Opt-in: a count is a query, on a big table a slow one. */
|
|
28
|
+
readonly count?: boolean;
|
|
29
|
+
/** Omitted: `admin.<entity>.scope.<name>`. */
|
|
30
|
+
readonly labelKey?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A declared scope, normalised. `where` is checked on every call — it may depend on the actor. */
|
|
34
|
+
export interface AdminScope {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly labelKey: string;
|
|
37
|
+
readonly default: boolean;
|
|
38
|
+
readonly count: boolean;
|
|
39
|
+
where(actor: AdminActor): readonly AdminFilter[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which rows of a resource one actor may see at all. One declaration, applied by every read the
|
|
44
|
+
* admin issues for the resource — list, search, detail, lookup, label, the MCP tools — so a second
|
|
45
|
+
* audience is a function of the actor and never a second copy of the resource.
|
|
46
|
+
*/
|
|
47
|
+
export type AdminRowScope = (actor: AdminActor) => readonly AdminFilter[];
|
|
48
|
+
|
|
49
|
+
/** The closed set. An app needing a seventh passes a component for that column. */
|
|
50
|
+
export const ADMIN_RENDERERS = [
|
|
51
|
+
'badge',
|
|
52
|
+
'relative-time',
|
|
53
|
+
'money',
|
|
54
|
+
'truncate',
|
|
55
|
+
'link',
|
|
56
|
+
'json',
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export type AdminRenderer = (typeof ADMIN_RENDERERS)[number];
|
|
60
|
+
|
|
61
|
+
export interface AdminColumnRenderProps<Row extends AdminRow = AdminRow> {
|
|
62
|
+
readonly value: unknown;
|
|
63
|
+
readonly row: Row;
|
|
64
|
+
readonly ctx: WidgetContext;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type AdminColumnComponent<Row extends AdminRow = AdminRow> = (
|
|
68
|
+
props: AdminColumnRenderProps<Row>,
|
|
69
|
+
) => JSX.Element;
|
|
70
|
+
|
|
71
|
+
export interface AdminComputedColumnOptions<Row extends AdminRow = AdminRow> {
|
|
72
|
+
/**
|
|
73
|
+
* The cell's value, from the row. The row it is handed carries no sealed column: a computed
|
|
74
|
+
* column is rendered, and a sealed value is never rendered.
|
|
75
|
+
*/
|
|
76
|
+
readonly value: (row: Row) => unknown;
|
|
77
|
+
readonly render: AdminRenderer | AdminColumnComponent<Row>;
|
|
78
|
+
/** Omitted: `admin.<entity>.column.<name>`. */
|
|
79
|
+
readonly labelKey?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A declared column, normalised — and typed over `AdminRow`, never the app's own `Row`: a resource
|
|
84
|
+
* that carried `(row: Row) => …` would be invariant in `Row`, and every function taking "any
|
|
85
|
+
* resource" would stop accepting one. The narrowing back happens once, in `computedColumnsOf`.
|
|
86
|
+
*/
|
|
87
|
+
export interface AdminComputedColumn extends AdminComputedColumnOptions<AdminRow> {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly labelKey: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Declared {
|
|
93
|
+
readonly name: string;
|
|
94
|
+
readonly entity: AdminEntity;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The list URL's spelling of "no scope, not even the default" — `?scope=*`, what a related card's
|
|
99
|
+
* "all" link opens. Reserved: a scope declared under it is refused, so the word means one thing.
|
|
100
|
+
*/
|
|
101
|
+
export const NO_SCOPE = '*';
|
|
102
|
+
|
|
103
|
+
/** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
|
|
104
|
+
export function scopesOf(
|
|
105
|
+
resource: Declared,
|
|
106
|
+
scopes: Readonly<Record<string, AdminScopeOptions>> | undefined,
|
|
107
|
+
repo: AdminRepo<AdminRow> | undefined,
|
|
108
|
+
): readonly AdminScope[] {
|
|
109
|
+
const entries = Object.entries(scopes ?? {});
|
|
110
|
+
const names = entries.map(([name]) => name);
|
|
111
|
+
const refuse = (asked: string, cause: string): never => {
|
|
112
|
+
throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
|
|
113
|
+
};
|
|
114
|
+
if (Object.hasOwn(scopes ?? {}, NO_SCOPE)) {
|
|
115
|
+
refuse(`scopes.${NO_SCOPE}`, `is the list URL's word for "no scope" and cannot name one`);
|
|
116
|
+
}
|
|
117
|
+
const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
|
|
118
|
+
if (defaults.length > 1) {
|
|
119
|
+
refuse(
|
|
120
|
+
`scopes: ${defaults.join(', ')}`,
|
|
121
|
+
'marks more than one scope `default: true`; a bare list URL applies exactly one',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return entries.map(([name, scope]) => {
|
|
125
|
+
const declaredAs = `scopes.${name}.where`;
|
|
126
|
+
// A tab's count is `AdminRepo.count`, which a hand-written repo may leave out: refused where
|
|
127
|
+
// the scope is declared, rather than drawn as a tab whose number silently never appears.
|
|
128
|
+
if (scope.count === true && repo !== undefined && repo.count === undefined) {
|
|
129
|
+
refuse(
|
|
130
|
+
`scopes.${name}.count`,
|
|
131
|
+
'asks for a count, and the repo this resource reads through has no count()',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const where = scope.where;
|
|
135
|
+
if (typeof where !== 'function') declaredFilters(resource, where, declaredAs);
|
|
136
|
+
return {
|
|
137
|
+
name,
|
|
138
|
+
labelKey: scope.labelKey ?? `admin.${resource.name}.scope.${name}`,
|
|
139
|
+
default: scope.default === true,
|
|
140
|
+
count: scope.count === true,
|
|
141
|
+
where: (actor) =>
|
|
142
|
+
typeof where === 'function' ? declaredFilters(resource, where(actor), declaredAs) : where,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `rows`, checked on every call. Absent stays absent: an unscoped resource asks no predicate. */
|
|
148
|
+
export function rowScopeOf(
|
|
149
|
+
resource: Declared,
|
|
150
|
+
rows: AdminRowScope | undefined,
|
|
151
|
+
): AdminRowScope | undefined {
|
|
152
|
+
if (rows === undefined) return undefined;
|
|
153
|
+
return (actor) => declaredFilters(resource, rows(actor), 'rows');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const isRenderer = (value: unknown): value is AdminRenderer =>
|
|
157
|
+
(ADMIN_RENDERERS as readonly unknown[]).includes(value);
|
|
158
|
+
|
|
159
|
+
/** Declaration order, after the entity's own list fields. */
|
|
160
|
+
export function computedColumnsOf<Row extends AdminRow>(
|
|
161
|
+
resource: Declared,
|
|
162
|
+
columns: Readonly<Record<string, AdminComputedColumnOptions<Row>>> | undefined,
|
|
163
|
+
): readonly AdminComputedColumn[] {
|
|
164
|
+
return Object.entries(columns ?? {}).map(([name, column]) => {
|
|
165
|
+
if (Object.hasOwn(resource.entity.$columns, name)) {
|
|
166
|
+
throw new AdminFieldUnsupportedError({
|
|
167
|
+
entity: resource.name,
|
|
168
|
+
field: name,
|
|
169
|
+
cause: 'is declared in `columns:` and is also a column of the entity',
|
|
170
|
+
fix: `rename the computed column in resources.${resource.name}.columns — e.g. ${name}Label — or drop it and let the admin render the column itself`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (typeof column.render !== 'function' && !isRenderer(column.render)) {
|
|
174
|
+
throw new AdminFieldUnsupportedError({
|
|
175
|
+
entity: resource.name,
|
|
176
|
+
field: name,
|
|
177
|
+
cause: `names a renderer the admin does not have (it has: ${ADMIN_RENDERERS.join(', ')})`,
|
|
178
|
+
fix: `set resources.${resource.name}.columns.${name}.render to one of those, or pass a component: render: (props) => <YourCell {...props} />`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const render = column.render;
|
|
182
|
+
return {
|
|
183
|
+
name,
|
|
184
|
+
labelKey: column.labelKey ?? `admin.${resource.name}.column.${name}`,
|
|
185
|
+
// The rows a resource's list hands these ARE its `Row`s: the declaration's own type is
|
|
186
|
+
// restored here, at the one place the two meet.
|
|
187
|
+
value: (row) => column.value(row as Row),
|
|
188
|
+
render:
|
|
189
|
+
typeof render === 'function'
|
|
190
|
+
? (props) => render({ ...props, row: props.row as Row })
|
|
191
|
+
: render,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
package/src/resource.ts
CHANGED
|
@@ -4,25 +4,33 @@
|
|
|
4
4
|
// field, but the zero-config result is the one the generator emits and the one the docs show.
|
|
5
5
|
|
|
6
6
|
import { finiteCount } from '@ultimat3/core';
|
|
7
|
-
import { type AdminColumnFacts, adminColumnsOf } from './entity-columns';
|
|
7
|
+
import { type AdminColumnFacts, adminColumnsOf, adminSealedColumnsOf } from './entity-columns';
|
|
8
8
|
import {
|
|
9
9
|
AdminEntityUnknownError,
|
|
10
10
|
AdminFieldUnsupportedError,
|
|
11
11
|
AdminPolicyMissingError,
|
|
12
|
+
AdminRepoUnboundError,
|
|
12
13
|
} from './errors';
|
|
13
|
-
import {
|
|
14
|
-
type AdminField,
|
|
15
|
-
type AdminFieldType,
|
|
16
|
-
type AdminWidget,
|
|
17
|
-
fieldTypeFromColumn,
|
|
18
|
-
filterable,
|
|
19
|
-
listable,
|
|
20
|
-
searchable,
|
|
21
|
-
sortable,
|
|
22
|
-
widgetFor,
|
|
23
|
-
} from './fields';
|
|
14
|
+
import type { AdminField } from './fields';
|
|
24
15
|
import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
|
|
25
16
|
import type { AdminAction, AdminEntity, AdminRepo, AdminRow, AdminSort } from './registry';
|
|
17
|
+
import { type AdminFieldOverride, deriveField, secretField } from './resource-fields';
|
|
18
|
+
import {
|
|
19
|
+
type AdminFormGroupOptions,
|
|
20
|
+
type AdminSection,
|
|
21
|
+
type AdminSectionOptions,
|
|
22
|
+
layoutOf,
|
|
23
|
+
} from './resource-layout';
|
|
24
|
+
import {
|
|
25
|
+
type AdminComputedColumn,
|
|
26
|
+
type AdminComputedColumnOptions,
|
|
27
|
+
type AdminRowScope,
|
|
28
|
+
type AdminScope,
|
|
29
|
+
type AdminScopeOptions,
|
|
30
|
+
computedColumnsOf,
|
|
31
|
+
rowScopeOf,
|
|
32
|
+
scopesOf,
|
|
33
|
+
} from './resource-list';
|
|
26
34
|
|
|
27
35
|
/** Six columns is what fits a laptop viewport without horizontal scroll. */
|
|
28
36
|
const MAX_LIST_FIELDS = 6;
|
|
@@ -30,27 +38,15 @@ const DEFAULT_PAGE_SIZE = 25;
|
|
|
30
38
|
const LABEL_CANDIDATES = ['name', 'title', 'slug', 'label', 'email'] as const;
|
|
31
39
|
const SORT_CANDIDATES = ['createdAt', 'created_at', 'updatedAt', 'updated_at'] as const;
|
|
32
40
|
|
|
33
|
-
export interface AdminFieldOverride {
|
|
34
|
-
readonly type?: AdminFieldType;
|
|
35
|
-
readonly widget?: AdminWidget;
|
|
36
|
-
readonly labelKey?: string;
|
|
37
|
-
/** Out of every surface: list, detail, form, MCP schema. */
|
|
38
|
-
readonly hidden?: boolean;
|
|
39
|
-
readonly inList?: boolean;
|
|
40
|
-
readonly readOnly?: boolean;
|
|
41
|
-
readonly required?: boolean;
|
|
42
|
-
readonly sensitive?: boolean;
|
|
43
|
-
readonly filterable?: boolean;
|
|
44
|
-
readonly sortable?: boolean;
|
|
45
|
-
readonly searchable?: boolean;
|
|
46
|
-
readonly currency?: string;
|
|
47
|
-
readonly values?: readonly string[];
|
|
48
|
-
readonly relation?: { readonly entity: string; readonly labelField?: string };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
41
|
export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
52
42
|
readonly repo?: AdminRepo<Row>;
|
|
53
43
|
readonly path?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The noun this resource's permissions are named after — `<permission>:read|write|delete`.
|
|
46
|
+
* Default: the entity's name. Several resources that are one subject to an operator share one
|
|
47
|
+
* (the jobs screens are all `job:read`), so a role map grants a subject once, not per table.
|
|
48
|
+
*/
|
|
49
|
+
readonly permission?: string;
|
|
54
50
|
readonly titleKey?: string;
|
|
55
51
|
readonly group?: string;
|
|
56
52
|
/**
|
|
@@ -62,6 +58,35 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
|
62
58
|
readonly fields?: Readonly<Record<string, AdminFieldOverride>>;
|
|
63
59
|
/** Explicit list columns, in order. Omit to let the derivation pick. */
|
|
64
60
|
readonly listFields?: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* Computed list columns, drawn after `listFields` in declaration order:
|
|
63
|
+
* `age: { value: (row) => row.createdAt, render: 'relative-time' }`. `render` is one of the six
|
|
64
|
+
* built-in renderers or a component. A name that is also an entity column is refused.
|
|
65
|
+
*/
|
|
66
|
+
readonly columns?: Readonly<Record<string, AdminComputedColumnOptions<Row>>>;
|
|
67
|
+
/**
|
|
68
|
+
* Named predicates, drawn as tabs: `open: { where: [...] }`, `mine: { where: (actor) => [...] }`.
|
|
69
|
+
* At most one `default: true`; `count: true` is one extra query per tab that asks for it.
|
|
70
|
+
*/
|
|
71
|
+
readonly scopes?: Readonly<Record<string, AdminScopeOptions>>;
|
|
72
|
+
/**
|
|
73
|
+
* The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
|
|
74
|
+
* detail, lookup, labels, MCP — so it is the one place a second audience is declared.
|
|
75
|
+
*/
|
|
76
|
+
readonly rows?: AdminRowScope;
|
|
77
|
+
/**
|
|
78
|
+
* The detail page, as titled groups of fields. A field named in none falls into a default
|
|
79
|
+
* section drawn last, so a column added to the entity is never hidden by this list.
|
|
80
|
+
*/
|
|
81
|
+
readonly sections?: readonly AdminSectionOptions[];
|
|
82
|
+
/** The same arrangement for the create and edit form. Independent of `sections`. */
|
|
83
|
+
readonly formGroups?: readonly AdminFormGroupOptions[];
|
|
84
|
+
/**
|
|
85
|
+
* `hasMany` relations of the entity, by name — `['comments']` — drawn on the detail page as the
|
|
86
|
+
* RELATED resource's own list (its columns, its row scope, its policy) filtered to this row.
|
|
87
|
+
* The names are `@ultimat3/entity`'s: the one a preload names.
|
|
88
|
+
*/
|
|
89
|
+
readonly related?: readonly string[];
|
|
65
90
|
readonly defaultSort?: AdminSort;
|
|
66
91
|
readonly pageSize?: number;
|
|
67
92
|
readonly operations?: readonly AdminOperation[];
|
|
@@ -81,6 +106,8 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
81
106
|
* the framework can own (axiom 8) — `path:` is how an app spells its own.
|
|
82
107
|
*/
|
|
83
108
|
readonly path: string;
|
|
109
|
+
/** The permission noun — `<permission>:read` lists it. The entity's name unless declared. */
|
|
110
|
+
readonly permission: string;
|
|
84
111
|
readonly titleKey: string;
|
|
85
112
|
readonly group: string;
|
|
86
113
|
readonly idField: string;
|
|
@@ -90,8 +117,31 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
90
117
|
readonly fields: readonly AdminField[];
|
|
91
118
|
readonly listFields: readonly AdminField[];
|
|
92
119
|
readonly formFields: readonly AdminField[];
|
|
120
|
+
/**
|
|
121
|
+
* The entity's `.sealed()` columns, as WRITE-ONLY inputs: a form posts them, nothing reads them.
|
|
122
|
+
* Deliberately a list of its own and in none of the others — `fields`, `listFields`,
|
|
123
|
+
* `formFields`, `filters` and `searchFields` are all read back by column name somewhere, and a
|
|
124
|
+
* sealed property answers that read with its plaintext.
|
|
125
|
+
*/
|
|
126
|
+
readonly secretFields: readonly AdminField[];
|
|
127
|
+
/**
|
|
128
|
+
* The label field first — a list's search box is its `contains` filter — then every field the
|
|
129
|
+
* derivation offers. What a URL's `f.<field>` may name, and nothing else.
|
|
130
|
+
*/
|
|
93
131
|
readonly filters: readonly AdminField[];
|
|
94
132
|
readonly searchFields: readonly AdminField[];
|
|
133
|
+
/** Computed list columns, in declaration order. */
|
|
134
|
+
readonly columns: readonly AdminComputedColumn[];
|
|
135
|
+
/** Declared scopes, in tab order. Empty for a resource that declares none. */
|
|
136
|
+
readonly scopes: readonly AdminScope[];
|
|
137
|
+
/** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
|
|
138
|
+
readonly rowScope?: AdminRowScope;
|
|
139
|
+
/** The detail page's groups. Always at least one: undeclared fields have a default section. */
|
|
140
|
+
readonly sections: readonly AdminSection[];
|
|
141
|
+
/** The form's groups, over `formFields` and `secretFields` both. */
|
|
142
|
+
readonly formGroups: readonly AdminSection[];
|
|
143
|
+
/** Declared `related` relation names, in order. `related.ts` resolves each against the admin. */
|
|
144
|
+
readonly related: readonly string[];
|
|
95
145
|
readonly defaultSort: AdminSort;
|
|
96
146
|
readonly pageSize: number;
|
|
97
147
|
readonly operations: readonly AdminOperation[];
|
|
@@ -100,48 +150,6 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
100
150
|
field(name: string): AdminField;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
|
-
function deriveField(
|
|
104
|
-
entity: AdminEntity,
|
|
105
|
-
column: AdminColumnFacts,
|
|
106
|
-
override: AdminFieldOverride | undefined,
|
|
107
|
-
): AdminField {
|
|
108
|
-
const name = column.name;
|
|
109
|
-
const type = override?.type ?? fieldTypeFromColumn(entity.$name, name, column);
|
|
110
|
-
const widget = override?.widget ?? widgetFor(type);
|
|
111
|
-
const generated = column.generated || column.primaryKey;
|
|
112
|
-
// An entity has no notion of a secret column, and a currency belongs to the value rather
|
|
113
|
-
// than the column, so both are admin-side declarations or they are absent. Never guessed.
|
|
114
|
-
const sensitive = override?.sensitive ?? false;
|
|
115
|
-
const currency = override?.currency;
|
|
116
|
-
const values = override?.values ?? column.values;
|
|
117
|
-
const relation =
|
|
118
|
-
override?.relation ??
|
|
119
|
-
// The FK's value IS the target column's value, so that column is the honest default label.
|
|
120
|
-
(column.references === undefined
|
|
121
|
-
? undefined
|
|
122
|
-
: { entity: column.references.entity, labelField: column.references.column });
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
entity: entity.$name,
|
|
126
|
-
name,
|
|
127
|
-
type,
|
|
128
|
-
widget,
|
|
129
|
-
labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
|
|
130
|
-
required: override?.required ?? (!column.nullable && !generated),
|
|
131
|
-
readOnly: override?.readOnly ?? generated,
|
|
132
|
-
sensitive,
|
|
133
|
-
inList: override?.inList ?? (listable(type) && !sensitive),
|
|
134
|
-
filterable: override?.filterable ?? filterable(type, column),
|
|
135
|
-
sortable: override?.sortable ?? sortable(type, column),
|
|
136
|
-
// Generated columns are excluded from search: an id is found by exact lookup, and a
|
|
137
|
-
// `contains` over a uuid column is a scan that returns nothing useful.
|
|
138
|
-
searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
|
|
139
|
-
...(values === undefined ? {} : { values }),
|
|
140
|
-
...(currency === undefined ? {} : { currency }),
|
|
141
|
-
...(relation === undefined ? {} : { relation }),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
153
|
/**
|
|
146
154
|
* The declared key wins — but a composite one is refused outright, never silently reduced to
|
|
147
155
|
* its first member: a route id and an `AdminRepo` id both carry exactly one string, so two rows
|
|
@@ -230,6 +238,18 @@ function pickListFields(
|
|
|
230
238
|
return [...label, ...rest].slice(0, MAX_LIST_FIELDS);
|
|
231
239
|
}
|
|
232
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The label first, whether or not it is indexed: a list's search box IS the label's `contains`
|
|
243
|
+
* filter, and a list an operator cannot search by name is the one they leave for a SQL console.
|
|
244
|
+
* Only a text label qualifies — a `contains` over a uuid is a database error, not an empty result.
|
|
245
|
+
*/
|
|
246
|
+
function filtersOf(fields: readonly AdminField[], labelField: string): readonly AdminField[] {
|
|
247
|
+
const label = fields.filter(
|
|
248
|
+
(field) => field.name === labelField && field.filterKind === 'text' && !field.sensitive,
|
|
249
|
+
);
|
|
250
|
+
return [...label, ...fields.filter((field) => field.filterable && !label.includes(field))];
|
|
251
|
+
}
|
|
252
|
+
|
|
233
253
|
function assertActionsHavePolicies(actions: readonly AdminAction[]): void {
|
|
234
254
|
for (const action of actions) {
|
|
235
255
|
// Widened because the registry is JSON at the boundary: a hand-written action object
|
|
@@ -264,10 +284,18 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
264
284
|
const labelField = labelFieldOf(entity, fields, idField, opts.labelField);
|
|
265
285
|
const actions = opts.actions ?? [];
|
|
266
286
|
assertActionsHavePolicies(actions);
|
|
287
|
+
const declared = { name: entity.$name, entity };
|
|
288
|
+
const rowScope = rowScopeOf(declared, opts.rows);
|
|
289
|
+
|
|
290
|
+
const formFields = fields.filter((field) => !field.readOnly && !field.sensitive);
|
|
291
|
+
const secretFields = adminSealedColumnsOf(entity)
|
|
292
|
+
.filter((column) => overrides[column.name]?.hidden !== true)
|
|
293
|
+
.map((column) => secretField(entity, column, overrides[column.name]));
|
|
267
294
|
|
|
268
295
|
const resource: AdminResource<Row> = {
|
|
269
296
|
name: entity.$name,
|
|
270
297
|
path: opts.path ?? `/${entity.$name}`,
|
|
298
|
+
permission: opts.permission ?? entity.$name,
|
|
271
299
|
titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
|
|
272
300
|
group: opts.group ?? 'admin.group.data',
|
|
273
301
|
idField,
|
|
@@ -275,9 +303,35 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
275
303
|
entity,
|
|
276
304
|
fields,
|
|
277
305
|
listFields: pickListFields(fields, labelField, opts.listFields, entity.$name),
|
|
278
|
-
formFields
|
|
279
|
-
|
|
306
|
+
formFields,
|
|
307
|
+
secretFields,
|
|
308
|
+
filters: filtersOf(fields, labelField),
|
|
280
309
|
searchFields: fields.filter((field) => field.searchable),
|
|
310
|
+
columns: computedColumnsOf(declared, opts.columns),
|
|
311
|
+
scopes: scopesOf(declared, opts.scopes, opts.repo),
|
|
312
|
+
...(rowScope === undefined ? {} : { rowScope }),
|
|
313
|
+
sections: layoutOf(
|
|
314
|
+
{
|
|
315
|
+
entity: entity.$name,
|
|
316
|
+
option: 'sections',
|
|
317
|
+
// What the detail page draws: every field but a `sensitive` one. A sealed column is in no
|
|
318
|
+
// list a reader iterates, so it cannot be named here either.
|
|
319
|
+
fields: fields.filter((field) => !field.sensitive),
|
|
320
|
+
excluded:
|
|
321
|
+
'is not a field the detail page draws (hidden, sensitive, sealed, or not a column)',
|
|
322
|
+
},
|
|
323
|
+
opts.sections,
|
|
324
|
+
),
|
|
325
|
+
formGroups: layoutOf(
|
|
326
|
+
{
|
|
327
|
+
entity: entity.$name,
|
|
328
|
+
option: 'formGroups',
|
|
329
|
+
fields: [...formFields, ...secretFields],
|
|
330
|
+
excluded: 'is not a form input (read-only, generated, sensitive, hidden, or not a column)',
|
|
331
|
+
},
|
|
332
|
+
opts.formGroups,
|
|
333
|
+
),
|
|
334
|
+
related: opts.related ?? [],
|
|
281
335
|
defaultSort: opts.defaultSort ?? defaultSortOf(fields, idField),
|
|
282
336
|
// Refused here rather than at the first listing, because `pagination.ts` clamps with
|
|
283
337
|
// `Math.max(1, Math.min(x, 200))` and neither of those validates: a `NaN` survives both, so
|
|
@@ -306,11 +360,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
306
360
|
/** The bound repo, or the one error that says which resource forgot it. */
|
|
307
361
|
export function repoOf<Row extends AdminRow>(resource: AdminResource<Row>): AdminRepo<Row> {
|
|
308
362
|
if (resource.repo === undefined) {
|
|
309
|
-
throw new
|
|
310
|
-
entity: resource.name,
|
|
311
|
-
known: [],
|
|
312
|
-
cause: `resource "${resource.name}" has no repo bound, so the admin cannot read or write rows`,
|
|
313
|
-
});
|
|
363
|
+
throw new AdminRepoUnboundError({ entity: resource.name, handles: [] });
|
|
314
364
|
}
|
|
315
365
|
return resource.repo;
|
|
316
366
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The one bridge from the admin's route table to @ultimat3/render's: the `defineRoute` config
|
|
2
|
+
// every admin route is served under, and the declaration that puts those routes in the framework's
|
|
3
|
+
// one route list. No screen is reached from here, so `defineAdmin()` can declare its routes
|
|
4
|
+
// without loading a view — `routes.ts` binds each route to its screen.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import {
|
|
8
|
+
defineRoute,
|
|
9
|
+
type RouteConfig,
|
|
10
|
+
type RouteGuard,
|
|
11
|
+
registerMountedRoutes,
|
|
12
|
+
} from '@ultimat3/render';
|
|
13
|
+
import type { AdminRoute } from './admin';
|
|
14
|
+
import { AdminPageUnguardedError } from './errors';
|
|
15
|
+
|
|
16
|
+
/** What `RouteDescriptor.file` reads for an admin route: the package, since no app file is one. */
|
|
17
|
+
export const ADMIN_ROUTE_FILE = '@ultimat3/admin';
|
|
18
|
+
|
|
19
|
+
export interface AdminRouteDefinition {
|
|
20
|
+
/** The coarse gate — the SAME object `config.policy` carries. */
|
|
21
|
+
readonly policy: RouteGuard;
|
|
22
|
+
readonly config: RouteConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
|
|
27
|
+
* which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
|
|
28
|
+
* there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
|
|
29
|
+
* request, in `crud.ts` for a resource screen and in `guardedScreen()` for every other one.
|
|
30
|
+
* An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
|
|
31
|
+
*/
|
|
32
|
+
export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
|
|
33
|
+
const permission = route.permissions[0];
|
|
34
|
+
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
35
|
+
const policy: RouteGuard = { permission };
|
|
36
|
+
return {
|
|
37
|
+
policy,
|
|
38
|
+
config: defineRoute({
|
|
39
|
+
// One mode for every admin route, never an author's choice. `ssr` renders the rows behind
|
|
40
|
+
// the guard, once per request, and `hydrate: 'never'` means the screen ships no JS at all:
|
|
41
|
+
// every control on an admin screen is a link or a native form, so there is nothing to
|
|
42
|
+
// hydrate. `never` is a REFUSAL and not just a default — an `island()` rendered on this
|
|
43
|
+
// route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s `island-collector.ts`), and no
|
|
44
|
+
// admin module declares one.
|
|
45
|
+
render: 'ssr',
|
|
46
|
+
offline: 'network-only',
|
|
47
|
+
hydrate: 'never',
|
|
48
|
+
policy,
|
|
49
|
+
// Never indexed and never followed, whatever the screen answers — a list, a refusal, a 404:
|
|
50
|
+
// an admin URL in a search result is a row id handed to a crawler.
|
|
51
|
+
meta: () => ({ title: t(route.titleKey), robots: { index: false, follow: false } }),
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Declare an admin's routes to the framework's route table — as MOUNTED routes, each with every
|
|
58
|
+
* permission that gates it — so `x routes`, the `/_x` routes panel, the manifest and `sw.js` list
|
|
59
|
+
* them from the one projection everything else is listed from. Keyed by base path: re-declaring
|
|
60
|
+
* the admin (a save under `x dev`) replaces its set.
|
|
61
|
+
*/
|
|
62
|
+
export function mountAdminRoutes(basePath: string, routes: readonly AdminRoute[]): void {
|
|
63
|
+
registerMountedRoutes(
|
|
64
|
+
{ key: basePath, by: 'defineAdmin', file: ADMIN_ROUTE_FILE, surface: 'app' },
|
|
65
|
+
routes.map((route) => ({
|
|
66
|
+
path: route.path,
|
|
67
|
+
config: adminRouteDefinition(route).config,
|
|
68
|
+
permissions: route.permissions,
|
|
69
|
+
})),
|
|
70
|
+
);
|
|
71
|
+
}
|