@ultimat3/admin 24.0.0 → 25.1.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 +14 -11
- package/README.md +39 -8
- package/package.json +17 -17
- package/src/action-gate.ts +18 -1
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +3 -3
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch.ts +3 -8
- package/src/crud-input.ts +2 -1
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +2 -2
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +9 -1
- package/src/dev/panel.ts +6 -3
- package/src/dev/server.ts +42 -13
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/form-decode.ts +13 -4
- package/src/index.ts +8 -7
- package/src/jobs/operator.ts +2 -2
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/layout.tsx +34 -8
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +23 -11
- package/src/pagination.ts +22 -18
- package/src/policy-bridge.ts +6 -6
- package/src/registry.ts +7 -0
- package/src/related.ts +2 -2
- package/src/relations.ts +1 -1
- package/src/resource.ts +10 -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 +4 -4
- package/src/search.ts +2 -2
- package/src/inert-jsx.ts +0 -182
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The `/admin` home's own layer: the KPI band and the access grid under it. Tiles wrap to two
|
|
2
|
+
// across a phone and as many as fit beside the ring on a monitor; the matrix cards pair up only
|
|
3
|
+
// once a card is wide enough that its permission column does not wrap mid-name.
|
|
4
|
+
|
|
5
|
+
@use '@ultimat3/ui/tokens' as t;
|
|
6
|
+
|
|
7
|
+
.band {
|
|
8
|
+
display: grid;
|
|
9
|
+
gap: t.space(4);
|
|
10
|
+
min-inline-size: 0;
|
|
11
|
+
|
|
12
|
+
@include t.respond-to('lg') {
|
|
13
|
+
grid-template-columns: minmax(0, 1fr) minmax(t.rem(320), t.rem(400));
|
|
14
|
+
align-items: start;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.heading {
|
|
19
|
+
@include t.label-caps;
|
|
20
|
+
|
|
21
|
+
grid-column: 1 / -1;
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.kpis {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(auto-fill, minmax(t.rem(150), 1fr));
|
|
29
|
+
gap: t.space(3);
|
|
30
|
+
padding: 0;
|
|
31
|
+
margin: 0;
|
|
32
|
+
list-style: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The whole tile is the link: one target for a pointer and for a keyboard alike, never smaller
|
|
36
|
+
// than the touch target.
|
|
37
|
+
.kpi {
|
|
38
|
+
@include t.focus-ring;
|
|
39
|
+
|
|
40
|
+
display: block;
|
|
41
|
+
block-size: 100%;
|
|
42
|
+
min-block-size: t.touch(target);
|
|
43
|
+
border-radius: t.radius('lg');
|
|
44
|
+
color: inherit;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
|
|
47
|
+
> * {
|
|
48
|
+
block-size: 100%;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
&:hover > * {
|
|
52
|
+
border-color: t.role('accent', 0.6);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.share {
|
|
57
|
+
min-inline-size: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.access {
|
|
61
|
+
display: grid;
|
|
62
|
+
gap: t.space(4);
|
|
63
|
+
min-inline-size: 0;
|
|
64
|
+
|
|
65
|
+
@include t.respond-to('xl') {
|
|
66
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
67
|
+
align-items: start;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One resource's matrix, closed by default: a native disclosure, so it opens with no script and its
|
|
72
|
+
// summary row is one touch target with the name and the allowed-count badge on a line.
|
|
73
|
+
.resource {
|
|
74
|
+
min-inline-size: 0;
|
|
75
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
76
|
+
border-radius: t.radius('lg');
|
|
77
|
+
background: t.role('surface');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.summary {
|
|
81
|
+
@include t.focus-ring;
|
|
82
|
+
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: space-between;
|
|
86
|
+
gap: t.space(3);
|
|
87
|
+
min-block-size: t.touch(target);
|
|
88
|
+
padding: t.space(2) t.space(4);
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
list-style: none;
|
|
91
|
+
|
|
92
|
+
.resource[open] > & {
|
|
93
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `display: flex` drops the native marker, so the disclosure draws its own chevron: pointing along
|
|
98
|
+
// the reading direction while closed, down once open.
|
|
99
|
+
.summary::-webkit-details-marker {
|
|
100
|
+
display: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.summary::before {
|
|
104
|
+
flex: none;
|
|
105
|
+
inline-size: t.rem(8);
|
|
106
|
+
block-size: t.rem(8);
|
|
107
|
+
border-inline-end: t.stroke(thick) solid t.role('fg-muted');
|
|
108
|
+
border-block-end: t.stroke(thick) solid t.role('fg-muted');
|
|
109
|
+
content: '';
|
|
110
|
+
transform: rotate(-45deg);
|
|
111
|
+
|
|
112
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
113
|
+
transition: transform t.duration(fast) t.easing(out);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
[dir='rtl'] & {
|
|
117
|
+
transform: rotate(135deg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.resource[open] > & {
|
|
121
|
+
transform: rotate(45deg);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.name {
|
|
126
|
+
flex: 1;
|
|
127
|
+
min-inline-size: 0;
|
|
128
|
+
font-weight: t.weight('semibold');
|
|
129
|
+
color: t.role('fg-strong');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.body {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: t.space(3);
|
|
135
|
+
padding: t.space(3) t.space(4) t.space(4);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A matrix is three columns of short words and one of permission pairs. On a phone the pair takes
|
|
139
|
+
// a line of its own under the operation and its verdict, so no column is cut at the card's edge;
|
|
140
|
+
// the header row goes to assistive tech only, since each cell already reads in place.
|
|
141
|
+
.matrixScroll {
|
|
142
|
+
min-inline-size: 0;
|
|
143
|
+
|
|
144
|
+
@include t.respond-down('sm') {
|
|
145
|
+
thead {
|
|
146
|
+
@include t.visually-hidden;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
tr {
|
|
150
|
+
display: grid;
|
|
151
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
152
|
+
column-gap: t.space(3);
|
|
153
|
+
padding-block: t.space(2);
|
|
154
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
td {
|
|
158
|
+
padding: 0;
|
|
159
|
+
border: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
td:nth-child(2) {
|
|
163
|
+
grid-column: 1 / -1;
|
|
164
|
+
grid-row: 2;
|
|
165
|
+
overflow-wrap: anywhere;
|
|
166
|
+
color: t.role('fg-muted');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/screen-home.tsx
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// `/admin` — the dashboard:
|
|
2
|
-
// operations, and the app-wide actions. No row is
|
|
3
|
-
//
|
|
1
|
+
// `/admin` — the dashboard: how many rows of each resource this actor may open, the decision behind
|
|
2
|
+
// each of its operations, and the app-wide actions. No row is READ here: a figure is one
|
|
3
|
+
// `count()` per resource (`screen-home-counts.ts`), never a page of rows per entity per visit.
|
|
4
4
|
|
|
5
5
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card } from '@ultimat3/ui';
|
|
7
|
-
import type { JSX } from 'solid-js';
|
|
8
6
|
import { AdminActionForm } from './action-form';
|
|
9
7
|
import { actionButtons, invokeAdminAction } from './action-gate';
|
|
10
8
|
import { decodeActionInput, postedActionInput } from './action-input';
|
|
@@ -12,12 +10,10 @@ import { ACTION_OPERATION, ACTION_PARAM, AdminActions, OPERATION_FIELD } from '.
|
|
|
12
10
|
import type { AdminApp, AdminRoute } from './admin';
|
|
13
11
|
import styles from './admin.module.scss';
|
|
14
12
|
import { decideAll } from './authz';
|
|
15
|
-
import
|
|
16
|
-
import { canOperate, decideOperation, permissionsForOperation } from './crud';
|
|
13
|
+
import { canOperate } from './crud';
|
|
17
14
|
import { posted } from './form-decode';
|
|
18
|
-
import {
|
|
15
|
+
import { confirmationToken } from './permissions';
|
|
19
16
|
import type { AdminAction } from './registry';
|
|
20
|
-
import type { AdminResource } from './resource';
|
|
21
17
|
import {
|
|
22
18
|
type AdminRouteRequest,
|
|
23
19
|
type AdminRouteResponse,
|
|
@@ -26,70 +22,12 @@ import {
|
|
|
26
22
|
refused,
|
|
27
23
|
refusedPage,
|
|
28
24
|
} from './screen-frame';
|
|
25
|
+
import { HomeAccess, operationMatrix } from './screen-home-access';
|
|
26
|
+
import { resourceCounts } from './screen-home-counts';
|
|
27
|
+
import { HomeKpis } from './screen-home-kpis';
|
|
29
28
|
import type { ValidationIssue } from './validate';
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
export interface OperationDecision {
|
|
33
|
-
readonly operation: AdminOperation;
|
|
34
|
-
readonly allowed: boolean;
|
|
35
|
-
readonly permissions: readonly string[];
|
|
36
|
-
readonly reason: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
|
|
41
|
-
* rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
|
|
42
|
-
* the operator should ask for, when no grant opens it.
|
|
43
|
-
*/
|
|
44
|
-
export const operationMatrix = (
|
|
45
|
-
resource: AdminResource,
|
|
46
|
-
ctx: CrudCtx,
|
|
47
|
-
): readonly OperationDecision[] =>
|
|
48
|
-
ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
|
|
49
|
-
(operation) => {
|
|
50
|
-
const decision = decideOperation(resource, operation, ctx);
|
|
51
|
-
return {
|
|
52
|
-
operation,
|
|
53
|
-
allowed: decision.allowed,
|
|
54
|
-
permissions: permissionsForOperation(resource.permission, operation),
|
|
55
|
-
reason: decision.reason,
|
|
56
|
-
};
|
|
57
|
-
},
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
|
|
62
|
-
* refused and WHICH permission refused them — the same decision the call obeys, not a note about
|
|
63
|
-
* a button that was left out.
|
|
64
|
-
*/
|
|
65
|
-
export function OperationMatrix(props: {
|
|
66
|
-
readonly rows: readonly OperationDecision[];
|
|
67
|
-
}): JSX.Element {
|
|
68
|
-
return (
|
|
69
|
-
<table class={styles['matrix']}>
|
|
70
|
-
<thead>
|
|
71
|
-
<tr>
|
|
72
|
-
<th>{t('admin.matrix.operation')}</th>
|
|
73
|
-
<th>{t('admin.matrix.permissions')}</th>
|
|
74
|
-
<th>{t('admin.matrix.verdict')}</th>
|
|
75
|
-
</tr>
|
|
76
|
-
</thead>
|
|
77
|
-
<tbody>
|
|
78
|
-
{props.rows.map((row) => (
|
|
79
|
-
<tr class={row.allowed ? styles['allowed'] : styles['denied']}>
|
|
80
|
-
<td>{t(`admin.operation.${row.operation}`)}</td>
|
|
81
|
-
<td class={styles['mono']}>{row.permissions.join(' + ')}</td>
|
|
82
|
-
<td>
|
|
83
|
-
{row.allowed
|
|
84
|
-
? t('admin.matrix.allowed')
|
|
85
|
-
: `${t('admin.matrix.deniedBy')} ${row.reason}`}
|
|
86
|
-
</td>
|
|
87
|
-
</tr>
|
|
88
|
-
))}
|
|
89
|
-
</tbody>
|
|
90
|
-
</table>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
30
|
+
export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home-access';
|
|
93
31
|
|
|
94
32
|
/** An app-wide action's POST: the same gate, with no row for a subject. */
|
|
95
33
|
async function globalWrite(
|
|
@@ -172,6 +110,8 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
172
110
|
}
|
|
173
111
|
|
|
174
112
|
const open = app.resources.filter((resource) => canOperate(resource, 'list', request.ctx));
|
|
113
|
+
// Counted only over what the actor may list: the size of a table is itself a fact to gate.
|
|
114
|
+
const counts = await resourceCounts(open, request.ctx.actor);
|
|
175
115
|
return framed(
|
|
176
116
|
app,
|
|
177
117
|
request,
|
|
@@ -184,17 +124,16 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
184
124
|
href={app.basePath}
|
|
185
125
|
/>
|
|
186
126
|
{open.length === 0 ? <p class={styles['note']}>{t('admin.actions.none')}</p> : null}
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
))}
|
|
127
|
+
{counts.length === 0 ? null : <HomeKpis basePath={app.basePath} counts={counts} />}
|
|
128
|
+
{open.length === 0 ? null : (
|
|
129
|
+
<HomeAccess
|
|
130
|
+
basePath={app.basePath}
|
|
131
|
+
entries={open.map((resource) => ({
|
|
132
|
+
resource,
|
|
133
|
+
rows: operationMatrix(resource, request.ctx),
|
|
134
|
+
}))}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
198
137
|
</div>,
|
|
199
138
|
);
|
|
200
139
|
};
|
package/src/screen-lookup.tsx
CHANGED
|
@@ -147,7 +147,7 @@ export function lookupScreen(app: AdminApp, resource: AdminResource): AdminScree
|
|
|
147
147
|
</ul>
|
|
148
148
|
)}
|
|
149
149
|
<Pagination
|
|
150
|
-
nextCursor={page.hasMore ?
|
|
150
|
+
nextCursor={page.hasMore ? page.nextCursor : undefined}
|
|
151
151
|
prevCursor={page.prevCursor ?? undefined}
|
|
152
152
|
hrefFor={(cursor) => state(cursor)}
|
|
153
153
|
/>
|
package/src/screen-resource.tsx
CHANGED
|
@@ -34,7 +34,7 @@ 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
39
|
import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
40
40
|
import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
|
|
@@ -127,7 +127,7 @@ export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen
|
|
|
127
127
|
// one, and one read per resource the page references — never one per row.
|
|
128
128
|
const [counts, relations] = await Promise.all([
|
|
129
129
|
scopeCounts(resource, request.ctx.actor),
|
|
130
|
-
|
|
130
|
+
relationDataFor(
|
|
131
131
|
app.resources,
|
|
132
132
|
request.ctx,
|
|
133
133
|
relationNeeds(result.page.rows, resource.listFields, resource.filters),
|
|
@@ -172,7 +172,7 @@ const relationsOf = (
|
|
|
172
172
|
row: AdminRow,
|
|
173
173
|
picking: boolean,
|
|
174
174
|
): Promise<WidgetContext> =>
|
|
175
|
-
|
|
175
|
+
relationDataFor(
|
|
176
176
|
app.resources,
|
|
177
177
|
request.ctx,
|
|
178
178
|
relationNeeds([row], resource.fields, picking ? resource.formFields : []),
|
|
@@ -259,7 +259,7 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
|
|
|
259
259
|
]);
|
|
260
260
|
// ONE label read per referenced target for the whole page — this row's references and every
|
|
261
261
|
// related row's together.
|
|
262
|
-
const relations = await
|
|
262
|
+
const relations = await relationDataFor(
|
|
263
263
|
app.resources,
|
|
264
264
|
request.ctx,
|
|
265
265
|
mergedNeeds([
|
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';
|
|
@@ -145,7 +145,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
145
145
|
entityId: null,
|
|
146
146
|
// A resource that does not OFFER search was refused by the registry, not by a policy,
|
|
147
147
|
// and the decision object would otherwise read `allow` on a denied entry.
|
|
148
|
-
decision: offered ? decision :
|
|
148
|
+
decision: offered ? decision : adminDenied(decision.permission, SKIPPED_FORBIDDEN),
|
|
149
149
|
}),
|
|
150
150
|
),
|
|
151
151
|
);
|
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
|
-
}
|