@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
package/src/test-ctx.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A `CrudCtx` for a test: an actor, the grants it holds, and an audit log to read back. A custom
|
|
2
|
+
// page's component is typed to need one (`AdminPageProps.ctx`), and until this existed the only
|
|
3
|
+
// thing that minted one was the app's whole admin — so a generated page shipped with no render
|
|
4
|
+
// test. Server-only, like everything in this barrel: never imported by an island.
|
|
5
|
+
|
|
6
|
+
import { memoryAuditLog } from './audit';
|
|
7
|
+
import { type AdminActor, staticAuthz } from './authz';
|
|
8
|
+
import type { CrudCtx } from './crud';
|
|
9
|
+
|
|
10
|
+
export interface AdminTestCtxInput {
|
|
11
|
+
/** Who is acting. Omitted: an actor with no role and no tenant. */
|
|
12
|
+
readonly actor?: AdminActor;
|
|
13
|
+
/** The permissions the actor holds. Omitted: none — every gate refuses, by name. */
|
|
14
|
+
readonly granted?: readonly string[];
|
|
15
|
+
readonly requestId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The handle a page or a CRUD call is given, built from a grant LIST. `staticAuthz` decides it, so
|
|
20
|
+
* the admin's own implications hold (`admin:write` reads) and nothing else does — a test says
|
|
21
|
+
* exactly what its actor may do, and a rule that reads the row or the tenant belongs in a test
|
|
22
|
+
* that builds the app's real authz.
|
|
23
|
+
*/
|
|
24
|
+
export function adminTestCtx(input: AdminTestCtxInput = {}): CrudCtx {
|
|
25
|
+
return {
|
|
26
|
+
actor: input.actor ?? { id: 'test-admin' },
|
|
27
|
+
authz: staticAuthz(input.granted ?? []),
|
|
28
|
+
audit: memoryAuditLog(),
|
|
29
|
+
requestId: input.requestId ?? 'test-admin-request',
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
export interface ValidationIssue {
|
|
6
6
|
readonly path: string;
|
|
7
|
+
/** The schema's own words, or the literal an agent reads for an issue the admin raised. */
|
|
7
8
|
readonly message: string;
|
|
9
|
+
/** Set only on an issue the ADMIN raised: the i18n key a screen renders instead of `message`. */
|
|
10
|
+
readonly messageKey?: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
interface StandardResult {
|
package/src/widget-value.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Money } from '@ultimat3/money';
|
|
|
7
7
|
import { isCurrencyCode } from '@ultimat3/schema';
|
|
8
8
|
import { AdminFieldUnsupportedError } from './errors';
|
|
9
9
|
import type { AdminField } from './fields';
|
|
10
|
+
import type { AdminOption } from './relations';
|
|
10
11
|
|
|
11
12
|
export interface WidgetContext {
|
|
12
13
|
/** IANA zone. Required — there is no "server local time" in Ultimate. */
|
|
@@ -25,6 +26,15 @@ export interface WidgetContext {
|
|
|
25
26
|
* caller that builds this context can. A wrong link is worse than no link.
|
|
26
27
|
*/
|
|
27
28
|
readonly hrefFor?: (entity: string, id: string) => string | null;
|
|
29
|
+
/**
|
|
30
|
+
* The three things a reference widget shows that only the PAGE can know, because each is a read
|
|
31
|
+
* of another resource: a referenced row's label, a small target's whole option list, and where
|
|
32
|
+
* the target's lookup lives. Absent, a reference renders its id and edits as a text box — the
|
|
33
|
+
* widget never reads a row itself, so a cell cannot become a query.
|
|
34
|
+
*/
|
|
35
|
+
readonly labelFor?: (entity: string, id: string) => string | undefined;
|
|
36
|
+
readonly optionsFor?: (entity: string) => readonly AdminOption[] | null;
|
|
37
|
+
readonly lookupHref?: (entity: string) => string | null;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface SelectOption {
|
|
@@ -60,15 +70,25 @@ export type WidgetProps =
|
|
|
60
70
|
readonly field: string;
|
|
61
71
|
readonly value: string | null;
|
|
62
72
|
readonly entity: string;
|
|
63
|
-
|
|
73
|
+
/** The target row's label, when the page read it. `null` renders the id itself. */
|
|
74
|
+
readonly label: string | null;
|
|
75
|
+
/** Every row of a small target — a `<select>`. `null`: the picker is the lookup screen. */
|
|
76
|
+
readonly options: readonly AdminOption[] | null;
|
|
77
|
+
/** Where an operator finds a row of the target, or `null` when it is not an admin resource. */
|
|
78
|
+
readonly lookupHref: string | null;
|
|
64
79
|
}
|
|
65
80
|
| {
|
|
66
81
|
readonly widget: 'upload';
|
|
67
82
|
readonly field: string;
|
|
68
83
|
readonly value: { readonly url: string; readonly name: string } | null;
|
|
69
|
-
}
|
|
84
|
+
}
|
|
85
|
+
/** No `value`, by type: a sealed column is written, never rendered and never prefilled. */
|
|
86
|
+
| { readonly widget: 'secret-input'; readonly field: string };
|
|
70
87
|
|
|
71
|
-
|
|
88
|
+
/** What a value guard names in its refusal — a field, or a computed column standing in for one. */
|
|
89
|
+
export type GuardedField = Pick<AdminField, 'entity' | 'name'> & { readonly currency?: string };
|
|
90
|
+
|
|
91
|
+
const fail = (field: GuardedField, cause: string, fix: string): never => {
|
|
72
92
|
throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
|
|
73
93
|
};
|
|
74
94
|
|
|
@@ -86,7 +106,7 @@ const minorUnits = (value: unknown): number | null => {
|
|
|
86
106
|
};
|
|
87
107
|
|
|
88
108
|
/** `Money = { minor: number; currency: string }` or nothing. A float is a bug, not a value. */
|
|
89
|
-
export function assertMoney(field:
|
|
109
|
+
export function assertMoney(field: GuardedField, value: unknown): Money | null {
|
|
90
110
|
if (value === null || value === undefined) return null;
|
|
91
111
|
if (typeof value === 'number') {
|
|
92
112
|
return fail(
|
|
@@ -207,16 +227,24 @@ export function widgetProps(field: AdminField, value: unknown, ctx: WidgetContex
|
|
|
207
227
|
field: field.name,
|
|
208
228
|
value: value === undefined ? '' : JSON.stringify(value, null, 2),
|
|
209
229
|
};
|
|
210
|
-
case 'reference':
|
|
230
|
+
case 'reference': {
|
|
231
|
+
const entity = field.relation?.entity ?? field.name;
|
|
232
|
+
const id = value === null || value === undefined || value === '' ? null : String(value);
|
|
211
233
|
return {
|
|
212
234
|
widget: 'reference',
|
|
213
235
|
field: field.name,
|
|
214
|
-
value:
|
|
215
|
-
entity
|
|
216
|
-
|
|
236
|
+
value: id,
|
|
237
|
+
entity,
|
|
238
|
+
label: id === null ? null : (ctx.labelFor?.(entity, id) ?? null),
|
|
239
|
+
options: ctx.optionsFor?.(entity) ?? null,
|
|
240
|
+
lookupHref: ctx.lookupHref?.(entity) ?? null,
|
|
217
241
|
};
|
|
242
|
+
}
|
|
218
243
|
case 'upload':
|
|
219
244
|
return { widget: 'upload', field: field.name, value: uploadValue(field, value) };
|
|
245
|
+
case 'secret-input':
|
|
246
|
+
// The row value is not read at all — not even to test it for null.
|
|
247
|
+
return { widget: 'secret-input', field: field.name };
|
|
220
248
|
}
|
|
221
249
|
}
|
|
222
250
|
|
package/src/widgets.tsx
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// every one of those places — there is no second place to get it wrong.
|
|
4
4
|
|
|
5
5
|
import { safeUrl } from '@ultimat3/core';
|
|
6
|
-
import { registeredLocales, t } from '@ultimat3/i18n';
|
|
6
|
+
import { isMiss, registeredLocales, t } from '@ultimat3/i18n';
|
|
7
7
|
import {
|
|
8
|
+
Badge,
|
|
8
9
|
Checkbox,
|
|
9
10
|
DateTime,
|
|
10
11
|
type DateTimeFormatter,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
} from '@ultimat3/ui';
|
|
17
18
|
import type { JSX } from 'solid-js';
|
|
18
19
|
import type { AdminField } from './fields';
|
|
20
|
+
import { currencyFieldOf, datetimeInputValue } from './form-decode';
|
|
19
21
|
import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
|
|
20
22
|
|
|
21
23
|
export interface WidgetInput {
|
|
@@ -68,9 +70,16 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
|
|
|
68
70
|
dateStyle: 'medium',
|
|
69
71
|
}).format(at);
|
|
70
72
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* An enum value's label: its translation when the catalog has one, the value itself when it does
|
|
75
|
+
* not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
|
|
76
|
+
* a `⟦admin.posts.field.audience.option.public⟧` in every cell of a derived list is not, and the
|
|
77
|
+
* key is built at runtime, so no i18n gate could have asked the app for it.
|
|
78
|
+
*/
|
|
79
|
+
export const optionLabel = (field: AdminField, value: string): string => {
|
|
80
|
+
const label = t(`${field.labelKey}.option.${value}`);
|
|
81
|
+
return isMiss(label) ? value : label;
|
|
82
|
+
};
|
|
74
83
|
|
|
75
84
|
/** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
|
|
76
85
|
function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
|
|
@@ -79,7 +88,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
79
88
|
return props.value === null ? (
|
|
80
89
|
<span>{t('admin.value.empty')}</span>
|
|
81
90
|
) : (
|
|
82
|
-
|
|
91
|
+
// The ACTOR's locale, handed over: ui's own default is the document's, and an operator
|
|
92
|
+
// whose dashboard is otherwise in their language read every amount and date in another.
|
|
93
|
+
<Money value={props.value} locale={ctx.locale} />
|
|
83
94
|
);
|
|
84
95
|
case 'datetime':
|
|
85
96
|
return props.value === null ? (
|
|
@@ -87,6 +98,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
87
98
|
) : (
|
|
88
99
|
<DateTime
|
|
89
100
|
value={props.value}
|
|
101
|
+
locale={ctx.locale}
|
|
90
102
|
timeZone={props.timeZone}
|
|
91
103
|
format={props.precision === 'date' ? formatCalendarDate : undefined}
|
|
92
104
|
/>
|
|
@@ -94,21 +106,25 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
94
106
|
case 'checkbox':
|
|
95
107
|
return <span>{t(props.value ? 'admin.value.true' : 'admin.value.false')}</span>;
|
|
96
108
|
case 'select':
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
// A value from a closed set is a state, and a state reads as a badge. Only an enum: a
|
|
110
|
+
// locale or a zone is an identifier, and stays text.
|
|
111
|
+
if (props.value === null) return <span>{t('admin.value.empty')}</span>;
|
|
112
|
+
return field.type === 'enum' ? (
|
|
113
|
+
<Badge>{optionLabel(field, props.value)}</Badge>
|
|
114
|
+
) : (
|
|
115
|
+
<span>{optionLabel(field, props.value)}</span>
|
|
103
116
|
);
|
|
104
117
|
case 'json-editor':
|
|
105
118
|
return <pre class="x-admin-json">{props.value}</pre>;
|
|
106
119
|
case 'reference': {
|
|
107
120
|
if (props.value === null) return <span>{t('admin.value.empty')}</span>;
|
|
121
|
+
// The target's label when the page read it, the id when it did not — a row this actor may
|
|
122
|
+
// not see keeps its id and gains no name.
|
|
123
|
+
const shown = props.label ?? props.value;
|
|
108
124
|
// `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
|
|
109
125
|
// the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
|
|
110
126
|
const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
|
|
111
|
-
return href === null ? <span>{
|
|
127
|
+
return href === null ? <span>{shown}</span> : <a href={href}>{shown}</a>;
|
|
112
128
|
}
|
|
113
129
|
case 'upload':
|
|
114
130
|
return props.value === null ? (
|
|
@@ -116,6 +132,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
116
132
|
) : (
|
|
117
133
|
<a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
|
|
118
134
|
);
|
|
135
|
+
case 'secret-input':
|
|
136
|
+
// Nothing to show, by type: `WidgetProps` carries no value for a sealed column.
|
|
137
|
+
return <span>{t('admin.value.sealed')}</span>;
|
|
119
138
|
default:
|
|
120
139
|
return <span>{String(props.value ?? '')}</span>;
|
|
121
140
|
}
|
|
@@ -155,7 +174,9 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
155
174
|
// Minor units, not a decimal: turning "12,34" into cents is currency- and locale-aware
|
|
156
175
|
// work owned by @ultimat3/money, and the design system has no money input to host it.
|
|
157
176
|
const currency = props.value?.currency ?? input.field.currency ?? '';
|
|
158
|
-
|
|
177
|
+
// The other half of the value, posted beside the amount: a known currency rides hidden, an
|
|
178
|
+
// unknown one is asked for — money is always both.
|
|
179
|
+
return [
|
|
159
180
|
<Input
|
|
160
181
|
{...shared}
|
|
161
182
|
name={props.field}
|
|
@@ -167,8 +188,18 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
167
188
|
const next = event.currentTarget.value;
|
|
168
189
|
emit(input, next === '' ? null : { minor: Number(next), currency });
|
|
169
190
|
}}
|
|
170
|
-
|
|
171
|
-
|
|
191
|
+
/>,
|
|
192
|
+
currency === '' ? (
|
|
193
|
+
<Input
|
|
194
|
+
name={currencyFieldOf(props.field)}
|
|
195
|
+
maxlength={3}
|
|
196
|
+
placeholder={t('admin.value.currency')}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
<input type="hidden" name={currencyFieldOf(props.field)} value={currency} />
|
|
201
|
+
),
|
|
202
|
+
];
|
|
172
203
|
}
|
|
173
204
|
case 'checkbox':
|
|
174
205
|
return (
|
|
@@ -203,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
203
234
|
{...shared}
|
|
204
235
|
name={props.field}
|
|
205
236
|
type={props.precision === 'date' ? 'date' : 'datetime-local'}
|
|
206
|
-
value={props.value === null ? '' :
|
|
237
|
+
value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
|
|
207
238
|
suffix={props.precision === 'date' ? undefined : 'UTC'}
|
|
208
239
|
disabled={disabled}
|
|
209
240
|
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
@@ -242,6 +273,43 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
242
273
|
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
243
274
|
/>
|
|
244
275
|
);
|
|
276
|
+
case 'reference':
|
|
277
|
+
// A small target is a `<select>` of its rows, by label. A large one is the id in a text
|
|
278
|
+
// box, with its label and the target's lookup beside it — both work with scripting off.
|
|
279
|
+
if (props.options !== null) {
|
|
280
|
+
return (
|
|
281
|
+
<Select
|
|
282
|
+
{...shared}
|
|
283
|
+
name={props.field}
|
|
284
|
+
value={props.value ?? ''}
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
options={[
|
|
287
|
+
{ value: '', label: t('admin.value.empty') },
|
|
288
|
+
...props.options.map((option) => ({ value: option.id, label: option.label })),
|
|
289
|
+
]}
|
|
290
|
+
onChange={(event) => emit(input, event.currentTarget.value)}
|
|
291
|
+
/>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return [
|
|
295
|
+
<Input
|
|
296
|
+
{...shared}
|
|
297
|
+
name={props.field}
|
|
298
|
+
value={props.value ?? ''}
|
|
299
|
+
disabled={disabled}
|
|
300
|
+
suffix={props.label ?? undefined}
|
|
301
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
302
|
+
/>,
|
|
303
|
+
props.lookupHref === null ? null : (
|
|
304
|
+
<a href={props.lookupHref} target="_blank" rel="noopener">
|
|
305
|
+
{t('admin.filter.find')}
|
|
306
|
+
</a>
|
|
307
|
+
),
|
|
308
|
+
];
|
|
309
|
+
case 'secret-input':
|
|
310
|
+
// Write-only: never a `value`, and `new-password` so a browser offers no saved credential
|
|
311
|
+
// of the operator's own. Empty on an edit means "unchanged" (`form-decode.ts`).
|
|
312
|
+
return <Input {...shared} name={props.field} type="password" autocomplete="new-password" />;
|
|
245
313
|
default:
|
|
246
314
|
return (
|
|
247
315
|
<Input
|