@ultimat3/admin 23.0.0 → 25.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 +32 -30
- package/README.md +51 -16
- package/package.json +17 -17
- package/src/action-gate.ts +33 -42
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +2 -2
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +11 -51
- package/src/crud-input.ts +2 -1
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +5 -8
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +16 -3
- package/src/dev/panel.ts +10 -5
- package/src/dev/server.ts +43 -17
- package/src/dev/sql-runner.ts +31 -0
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +55 -5
- package/src/form.tsx +19 -5
- package/src/index.ts +11 -9
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +5 -5
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +34 -8
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +27 -11
- package/src/pagination.ts +32 -22
- package/src/policy-bridge.ts +16 -14
- package/src/registry.ts +9 -1
- package/src/related.ts +2 -2
- package/src/relations.ts +26 -10
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/resource.ts +10 -0
- package/src/row-version.ts +20 -0
- package/src/screen-home-access.tsx +117 -0
- package/src/screen-home-counts.ts +42 -0
- package/src/screen-home-kpis.tsx +64 -0
- package/src/screen-home.module.scss +169 -0
- package/src/screen-home.tsx +21 -82
- package/src/screen-lookup.tsx +1 -1
- package/src/screen-resource.tsx +60 -12
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +12 -4
- package/src/widgets.tsx +2 -6
- package/src/inert-jsx.ts +0 -182
package/src/screen-resource.tsx
CHANGED
|
@@ -34,8 +34,9 @@ import { type ListLocation, listHref, pageRequestOf } from './list-request';
|
|
|
34
34
|
import { type AdminListRequest, findRow, scopeCounts } from './list-scope';
|
|
35
35
|
import type { AdminRow } from './registry';
|
|
36
36
|
import { relatedLists, relatedOf } from './related';
|
|
37
|
-
import { type RelationNeed,
|
|
37
|
+
import { type RelationNeed, relationDataFor, relationNeeds } from './relations';
|
|
38
38
|
import type { AdminResource } from './resource';
|
|
39
|
+
import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
39
40
|
import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
|
|
40
41
|
import {
|
|
41
42
|
type AdminRouteRequest,
|
|
@@ -126,7 +127,7 @@ export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen
|
|
|
126
127
|
// one, and one read per resource the page references — never one per row.
|
|
127
128
|
const [counts, relations] = await Promise.all([
|
|
128
129
|
scopeCounts(resource, request.ctx.actor),
|
|
129
|
-
|
|
130
|
+
relationDataFor(
|
|
130
131
|
app.resources,
|
|
131
132
|
request.ctx,
|
|
132
133
|
relationNeeds(result.page.rows, resource.listFields, resource.filters),
|
|
@@ -171,7 +172,7 @@ const relationsOf = (
|
|
|
171
172
|
row: AdminRow,
|
|
172
173
|
picking: boolean,
|
|
173
174
|
): Promise<WidgetContext> =>
|
|
174
|
-
|
|
175
|
+
relationDataFor(
|
|
175
176
|
app.resources,
|
|
176
177
|
request.ctx,
|
|
177
178
|
relationNeeds([row], resource.fields, picking ? resource.formFields : []),
|
|
@@ -250,12 +251,15 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
|
|
|
250
251
|
entityId: id,
|
|
251
252
|
changes: true,
|
|
252
253
|
limit: HISTORY_PAGE,
|
|
254
|
+
// The actor's tenant's entries only, as on the audit screen — a row two orgs may open
|
|
255
|
+
// does not show one org who in the other changed it.
|
|
256
|
+
...(request.ctx.actor.orgId === undefined ? {} : { orgId: request.ctx.actor.orgId }),
|
|
253
257
|
...(before === undefined ? {} : { before }),
|
|
254
258
|
}),
|
|
255
259
|
]);
|
|
256
260
|
// ONE label read per referenced target for the whole page — this row's references and every
|
|
257
261
|
// related row's together.
|
|
258
|
-
const relations = await
|
|
262
|
+
const relations = await relationDataFor(
|
|
259
263
|
app.resources,
|
|
260
264
|
request.ctx,
|
|
261
265
|
mergedNeeds([
|
|
@@ -310,6 +314,7 @@ async function form(
|
|
|
310
314
|
mode: 'create' | 'edit',
|
|
311
315
|
values: Readonly<Record<string, unknown>>,
|
|
312
316
|
issues: readonly ValidationIssue[],
|
|
317
|
+
version: string | null,
|
|
313
318
|
): Promise<JSX.Element> {
|
|
314
319
|
const list = listHref(app.basePath, resource);
|
|
315
320
|
return (
|
|
@@ -323,10 +328,18 @@ async function form(
|
|
|
323
328
|
ctx={await relationsOf(app, resource, request, values, true)}
|
|
324
329
|
action={new URL(request.url).pathname}
|
|
325
330
|
cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
|
|
331
|
+
version={version}
|
|
326
332
|
/>
|
|
327
333
|
);
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
/** The form-level issue a write against a stale version is re-rendered with. */
|
|
337
|
+
const ROW_CHANGED_ISSUE: ValidationIssue = {
|
|
338
|
+
path: VERSION_FIELD,
|
|
339
|
+
message: 'the row was changed after this form was opened',
|
|
340
|
+
messageKey: ROW_CHANGED_REASON,
|
|
341
|
+
};
|
|
342
|
+
|
|
330
343
|
export function createScreen(
|
|
331
344
|
app: AdminApp,
|
|
332
345
|
route: AdminRoute,
|
|
@@ -340,7 +353,7 @@ export function createScreen(
|
|
|
340
353
|
app,
|
|
341
354
|
request,
|
|
342
355
|
resource.titleKey,
|
|
343
|
-
await form(app, resource, request, 'create', {}, []),
|
|
356
|
+
await form(app, resource, request, 'create', {}, [], null),
|
|
344
357
|
);
|
|
345
358
|
}
|
|
346
359
|
const input = decodeForm(resource, request.form ?? {});
|
|
@@ -350,13 +363,14 @@ export function createScreen(
|
|
|
350
363
|
return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
351
364
|
}
|
|
352
365
|
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
353
|
-
|
|
366
|
+
// A create has no row to have gone missing or stale: neither is an answer it can give.
|
|
367
|
+
if (result.kind !== 'invalid') return notFound(app, resource, request);
|
|
354
368
|
// 422, with what was typed: a refused form that came back empty is a form filled in twice.
|
|
355
369
|
return framed(
|
|
356
370
|
app,
|
|
357
371
|
request,
|
|
358
372
|
resource.titleKey,
|
|
359
|
-
await form(app, resource, request, 'create', input, result.issues),
|
|
373
|
+
await form(app, resource, request, 'create', input, result.issues, null),
|
|
360
374
|
422,
|
|
361
375
|
);
|
|
362
376
|
};
|
|
@@ -365,8 +379,8 @@ export function createScreen(
|
|
|
365
379
|
export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
|
|
366
380
|
return async (request) => {
|
|
367
381
|
const id = idOf(request);
|
|
382
|
+
const row = await findRow(resource, request.ctx.actor, id);
|
|
368
383
|
if (request.method === 'GET') {
|
|
369
|
-
const row = await findRow(resource, request.ctx.actor, id);
|
|
370
384
|
const decision = decideOperation(resource, 'update', request.ctx, id, row);
|
|
371
385
|
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
372
386
|
if (row === null) return notFound(app, resource, request);
|
|
@@ -374,19 +388,53 @@ export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminReso
|
|
|
374
388
|
app,
|
|
375
389
|
request,
|
|
376
390
|
resource.titleKey,
|
|
377
|
-
await form(app, resource, request, 'edit', row, []),
|
|
391
|
+
await form(app, resource, request, 'edit', row, [], rowVersion(row)),
|
|
378
392
|
);
|
|
379
393
|
}
|
|
380
|
-
const
|
|
381
|
-
|
|
394
|
+
const submitted = request.form ?? {};
|
|
395
|
+
// Decoded against the row as it is: an instant posted back as the minute it was drawn at is
|
|
396
|
+
// left out, not rewritten to that minute. A row that moved on since the form was rendered is
|
|
397
|
+
// refused below on its version, so "as it is" and "as it was drawn" are the same row here.
|
|
398
|
+
const input = decodeForm(resource, submitted, row ?? undefined);
|
|
399
|
+
// A post with no version is not a form this admin rendered: it is refused as stale, never run
|
|
400
|
+
// as an unversioned write — the version is how the form says which row it was looking at.
|
|
401
|
+
const version = posted(submitted, VERSION_FIELD) ?? '';
|
|
402
|
+
const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input, { version });
|
|
382
403
|
if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
383
404
|
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
384
405
|
if (result.kind === 'missing') return notFound(app, resource, request);
|
|
406
|
+
if (result.kind === 'stale') {
|
|
407
|
+
// What they typed, over the row as it is NOW, under its current version: saving again is a
|
|
408
|
+
// decision taken with the other edit on screen, not an accident.
|
|
409
|
+
return framed(
|
|
410
|
+
app,
|
|
411
|
+
request,
|
|
412
|
+
resource.titleKey,
|
|
413
|
+
await form(
|
|
414
|
+
app,
|
|
415
|
+
resource,
|
|
416
|
+
request,
|
|
417
|
+
'edit',
|
|
418
|
+
{ ...result.row, ...input },
|
|
419
|
+
[ROW_CHANGED_ISSUE],
|
|
420
|
+
result.version,
|
|
421
|
+
),
|
|
422
|
+
409,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
385
425
|
return framed(
|
|
386
426
|
app,
|
|
387
427
|
request,
|
|
388
428
|
resource.titleKey,
|
|
389
|
-
await form(
|
|
429
|
+
await form(
|
|
430
|
+
app,
|
|
431
|
+
resource,
|
|
432
|
+
request,
|
|
433
|
+
'edit',
|
|
434
|
+
{ ...(row ?? {}), ...input },
|
|
435
|
+
result.issues,
|
|
436
|
+
version,
|
|
437
|
+
),
|
|
390
438
|
422,
|
|
391
439
|
);
|
|
392
440
|
};
|
package/src/screen-system.tsx
CHANGED
|
@@ -14,8 +14,14 @@ import { adminSearch } from './search';
|
|
|
14
14
|
const AUDIT_PAGE = 100;
|
|
15
15
|
|
|
16
16
|
export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
17
|
-
return guardedScreen(app, route, async () => {
|
|
18
|
-
|
|
17
|
+
return guardedScreen(app, route, async (request) => {
|
|
18
|
+
// The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
|
|
19
|
+
// their ids or what they touched. An actor with no org — the platform — reads every tenant's.
|
|
20
|
+
const orgId = request.ctx.actor.orgId;
|
|
21
|
+
const entries = await app.audit.entries({
|
|
22
|
+
limit: AUDIT_PAGE,
|
|
23
|
+
...(orgId === undefined ? {} : { orgId }),
|
|
24
|
+
});
|
|
19
25
|
return (
|
|
20
26
|
<Card>
|
|
21
27
|
{entries.length === 0 ? (
|
package/src/search.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { finiteCount } from '@ultimat3/core';
|
|
|
6
6
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
7
7
|
import { type AuditEntry, deniedDraft } from './audit';
|
|
8
8
|
import type { AdminActor } from './authz';
|
|
9
|
-
import {
|
|
9
|
+
import { adminDenied } from './authz';
|
|
10
10
|
import type { CrudCtx } from './crud';
|
|
11
11
|
import { decideOperation } from './crud';
|
|
12
12
|
import { rowWhere } from './list-scope';
|
|
@@ -105,6 +105,7 @@ async function searchResource(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
|
|
108
|
+
const SKIPPED_FAILED = 'admin.search.skipped.failed';
|
|
108
109
|
|
|
109
110
|
export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
|
|
110
111
|
const { ctx } = input;
|
|
@@ -144,7 +145,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
144
145
|
entityId: null,
|
|
145
146
|
// A resource that does not OFFER search was refused by the registry, not by a policy,
|
|
146
147
|
// and the decision object would otherwise read `allow` on a denied entry.
|
|
147
|
-
decision: offered ? decision :
|
|
148
|
+
decision: offered ? decision : adminDenied(decision.permission, SKIPPED_FORBIDDEN),
|
|
148
149
|
}),
|
|
149
150
|
),
|
|
150
151
|
);
|
|
@@ -160,7 +161,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
160
161
|
skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
|
|
161
162
|
continue;
|
|
162
163
|
}
|
|
163
|
-
searched.push(resource.name);
|
|
164
164
|
// Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
|
|
165
165
|
// asked for — `audit.ts`: if it isn't logged, it didn't happen.
|
|
166
166
|
audit.push(
|
|
@@ -176,7 +176,15 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
176
176
|
reason: decision.reason,
|
|
177
177
|
}),
|
|
178
178
|
);
|
|
179
|
-
|
|
179
|
+
try {
|
|
180
|
+
hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
|
|
181
|
+
searched.push(resource.name);
|
|
182
|
+
} catch {
|
|
183
|
+
// One resource whose repo fails is a resource this search could not read — listed, so the
|
|
184
|
+
// operator is never silently shown a subset — never a 500 for every other one. Nothing is
|
|
185
|
+
// read off the thrown value: a reason is a key.
|
|
186
|
+
skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
return { term, hits, searched, skipped, audit };
|
package/src/widgets.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '@ultimat3/ui';
|
|
18
18
|
import type { JSX } from 'solid-js';
|
|
19
19
|
import type { AdminField } from './fields';
|
|
20
|
-
import { currencyFieldOf } from './form-decode';
|
|
20
|
+
import { currencyFieldOf, datetimeInputValue } from './form-decode';
|
|
21
21
|
import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
|
|
22
22
|
|
|
23
23
|
export interface WidgetInput {
|
|
@@ -70,10 +70,6 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
|
|
|
70
70
|
dateStyle: 'medium',
|
|
71
71
|
}).format(at);
|
|
72
72
|
|
|
73
|
-
/** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
|
|
74
|
-
const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
|
|
75
|
-
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
76
|
-
|
|
77
73
|
/**
|
|
78
74
|
* An enum value's label: its translation when the catalog has one, the value itself when it does
|
|
79
75
|
* not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
|
|
@@ -238,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
238
234
|
{...shared}
|
|
239
235
|
name={props.field}
|
|
240
236
|
type={props.precision === 'date' ? 'date' : 'datetime-local'}
|
|
241
|
-
value={props.value === null ? '' :
|
|
237
|
+
value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
|
|
242
238
|
suffix={props.precision === 'date' ? undefined : 'UTC'}
|
|
243
239
|
disabled={disabled}
|
|
244
240
|
onInput={(event) => emit(input, event.currentTarget.value)}
|
package/src/inert-jsx.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// TEST-ONLY. Calls an admin `.tsx` view as the plain function it is and walks what came back, so
|
|
2
|
-
// a test can assert on the tree a screen produced rather than on a value it also constructed.
|
|
3
|
-
//
|
|
4
|
-
// Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
|
|
5
|
-
// installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import (`routes.ts` imports it,
|
|
6
|
-
// so any sibling suite can win the race) and `bun test` is one process. Both factories build the
|
|
7
|
-
// same shape — a `type`, a `props`, children under `props.children` — so this walker recognises
|
|
8
|
-
// both and the run's file order stops deciding. Recognising neither is what falls through to
|
|
9
|
-
// `String(value)` and asserts against `"[object Object]"`.
|
|
10
|
-
|
|
11
|
-
// The subpath, never the barrel: this is a test harness and the design system's `index.ts` is its
|
|
12
|
-
// semver'd contract (`packages/ui/CLAUDE.md`). It is imported for `globalThis.React`'s ONE counter.
|
|
13
|
-
import { probe, unprobe } from '@ultimat3/ui/jsx-probe';
|
|
14
|
-
|
|
15
|
-
/** `@ultimat3/render`'s node brand, read off the GLOBAL symbol registry rather than imported. */
|
|
16
|
-
const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
|
|
17
|
-
|
|
18
|
-
export type InertComponent = (props: Record<string, unknown>) => unknown;
|
|
19
|
-
|
|
20
|
-
export interface InertNode {
|
|
21
|
-
readonly type: string | InertComponent;
|
|
22
|
-
readonly props: Record<string, unknown>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function isInertNode(value: unknown): value is InertNode {
|
|
26
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
27
|
-
return 'inert' in value || RENDER_NODE in value;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Build a node by hand, the shape the installed factory builds. NOT the installed factory — that is
|
|
32
|
-
* `@ultimat3/ui`'s, so there is one owner of `globalThis.React` (see `installFactory` below). This
|
|
33
|
-
* is what a test uses to construct a tree it did not render.
|
|
34
|
-
*/
|
|
35
|
-
export function h(
|
|
36
|
-
type: string | InertComponent,
|
|
37
|
-
props: Record<string, unknown> | null,
|
|
38
|
-
...children: readonly unknown[]
|
|
39
|
-
): InertNode {
|
|
40
|
-
const base = { ...(props ?? {}) };
|
|
41
|
-
if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
|
|
42
|
-
return { inert: true, type, props: base } as unknown as InertNode;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Install the classic-factory global, through `@ultimat3/ui`'s ONE counter over `globalThis.React`.
|
|
47
|
-
*
|
|
48
|
-
* This package kept its own `depth`/`saved` pair until 2026-08-22, and two counters over one
|
|
49
|
-
* property restore in the wrong order: admin installs (saving the real binding), ui installs
|
|
50
|
-
* (saving ADMIN's factory), admin restores (its counter hits 0 → the real binding is back), ui
|
|
51
|
-
* restores (its counter hits 0 → admin's factory goes back over it). `bun test` is one process and
|
|
52
|
-
* `packages/ui` and `packages/admin` are one run, so the interleave is reachable, and the global
|
|
53
|
-
* was left holding a harness the run had already torn down. `admin` is tier 5 and `ui` is tier 4,
|
|
54
|
-
* so importing down is the fix; the two factories build the same `{ inert, type, props }` shape,
|
|
55
|
-
* which is what `isInertNode` recognises.
|
|
56
|
-
*/
|
|
57
|
-
export function installFactory(): void {
|
|
58
|
-
probe();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Undo the matching `installFactory()`. Unbalanced calls are inert. */
|
|
62
|
-
export function restoreFactory(): void {
|
|
63
|
-
unprobe();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Every node in the tree, depth first, with nested components CALLED and thunks invoked — a
|
|
68
|
-
* component that reads a prop inside one renders nothing until something asks.
|
|
69
|
-
*
|
|
70
|
-
* Component nodes are KEPT as well as expanded, so a test can assert on the props a view handed
|
|
71
|
-
* `<Money>` — the admin's contract with the design system — and not only on the markup ui chose
|
|
72
|
-
* to emit for them, which is ui's business and changes without this package changing.
|
|
73
|
-
*/
|
|
74
|
-
export function nodesOf(value: unknown): InertNode[] {
|
|
75
|
-
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
76
|
-
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
77
|
-
if (Array.isArray(value)) return value.flatMap(nodesOf);
|
|
78
|
-
if (isInertNode(value)) {
|
|
79
|
-
if (typeof value.type === 'function') return [value, ...nodesOf(value.type(value.props))];
|
|
80
|
-
return [value, ...nodesOf(value.props['children'])];
|
|
81
|
-
}
|
|
82
|
-
if (typeof value === 'function') return nodesOf((value as () => unknown)());
|
|
83
|
-
return [];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The tree this package itself built: component nodes are kept but NOT called, so the walk stops
|
|
88
|
-
* at the design-system boundary. Use this when a design-system component's own internals would
|
|
89
|
-
* otherwise answer a query about the admin's markup — `<Dialog>` renders a close `<button>`, and a
|
|
90
|
-
* test asking "which buttons did the action bar render" must not be handed ui's.
|
|
91
|
-
*/
|
|
92
|
-
export function shallowNodesOf(value: unknown): InertNode[] {
|
|
93
|
-
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
94
|
-
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
95
|
-
if (Array.isArray(value)) return value.flatMap(shallowNodesOf);
|
|
96
|
-
if (isInertNode(value)) return [value, ...shallowNodesOf(value.props['children'])];
|
|
97
|
-
if (typeof value === 'function') return shallowNodesOf((value as () => unknown)());
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Nodes rendered by the named component — `byComponent(nodes, 'Money')`. */
|
|
102
|
-
export function byComponent(nodes: readonly InertNode[], name: string): InertNode[] {
|
|
103
|
-
return nodes.filter((node) => typeof node.type === 'function' && node.type.name === name);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const SKIPPED_PROPS = new Set(['children', 'ref', 'innerHTML']);
|
|
107
|
-
|
|
108
|
-
function attributes(props: Record<string, unknown>): string {
|
|
109
|
-
return Object.entries(props)
|
|
110
|
-
.filter(([name, value]) => {
|
|
111
|
-
if (SKIPPED_PROPS.has(name) || name.startsWith('on')) return false;
|
|
112
|
-
return value !== undefined && value !== null && value !== false;
|
|
113
|
-
})
|
|
114
|
-
.map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${String(value)}"`))
|
|
115
|
-
.join('');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The tree as markup. Unknown values are `String()`d, which the premise test refuses to allow. */
|
|
119
|
-
export function renderHtml(value: unknown): string {
|
|
120
|
-
if (value === null || value === undefined || typeof value === 'boolean') return '';
|
|
121
|
-
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
122
|
-
if (Array.isArray(value)) return value.map(renderHtml).join('');
|
|
123
|
-
if (isInertNode(value)) {
|
|
124
|
-
if (typeof value.type === 'function') return renderHtml(value.type(value.props));
|
|
125
|
-
const inner = renderHtml(value.props['children']);
|
|
126
|
-
return `<${value.type}${attributes(value.props)}>${inner}</${value.type}>`;
|
|
127
|
-
}
|
|
128
|
-
if (typeof value === 'function') return renderHtml((value as () => unknown)());
|
|
129
|
-
return String(value);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
|
|
133
|
-
export function renderNodes(component: unknown, props: Record<string, unknown> = {}): InertNode[] {
|
|
134
|
-
return nodesOf((component as InertComponent)(props));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* `renderNodes`, stopping at the design-system boundary — the `shallowNodesOf` sibling. The
|
|
139
|
-
* `unknown` parameter is what makes it usable: a screen's props are typed and generic, so a test
|
|
140
|
-
* calling one through `as (props: Record<string, unknown>) => unknown` is a conversion TypeScript
|
|
141
|
-
* refuses outright. One widening here beats one per call site.
|
|
142
|
-
*/
|
|
143
|
-
export function renderShallowNodes(
|
|
144
|
-
component: unknown,
|
|
145
|
-
props: Record<string, unknown> = {},
|
|
146
|
-
): InertNode[] {
|
|
147
|
-
return shallowNodesOf((component as InertComponent)(props));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Render a component to markup. */
|
|
151
|
-
export function renderComponent(component: unknown, props: Record<string, unknown> = {}): string {
|
|
152
|
-
return renderHtml((component as InertComponent)(props));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function byTag(nodes: readonly InertNode[], tag: string): InertNode[] {
|
|
156
|
-
return nodes.filter((node) => node.type === tag);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
|
|
160
|
-
export function withAttr(nodes: readonly InertNode[], name: string, value?: unknown): InertNode[] {
|
|
161
|
-
return nodes.filter((node) =>
|
|
162
|
-
value === undefined ? node.props[name] !== undefined : node.props[name] === value,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
|
|
167
|
-
export function one(nodes: readonly InertNode[], what: string): InertNode {
|
|
168
|
-
const node = nodes[0];
|
|
169
|
-
if (node === undefined || nodes.length !== 1) {
|
|
170
|
-
throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
|
|
171
|
-
}
|
|
172
|
-
return node;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Call an element's event handler prop, e.g. `fire(button, 'onClick', {})`. */
|
|
176
|
-
export function fire(node: InertNode, handler: string, event: unknown): void {
|
|
177
|
-
const fn = node.props[handler];
|
|
178
|
-
if (typeof fn !== 'function') {
|
|
179
|
-
throw new Error(`node <${String(node.type)}> carries no ${handler}`);
|
|
180
|
-
}
|
|
181
|
-
(fn as (e: unknown) => void)(event);
|
|
182
|
-
}
|