@terpjs/react-core 0.8.0 → 0.10.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/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/markers.test.ts
CHANGED
|
@@ -39,6 +39,8 @@ const STYLESHEET = "./styles.ts";
|
|
|
39
39
|
* stylesheet and the layout contract, so it belongs in a release note.
|
|
40
40
|
*/
|
|
41
41
|
const MARKERS = [
|
|
42
|
+
"admin-form",
|
|
43
|
+
"admin-section-title",
|
|
42
44
|
"alert",
|
|
43
45
|
"alert-body",
|
|
44
46
|
"alert-icon",
|
|
@@ -53,10 +55,15 @@ const MARKERS = [
|
|
|
53
55
|
"appshell-header",
|
|
54
56
|
"appshell-header-group",
|
|
55
57
|
"appshell-main",
|
|
58
|
+
"appshell-mark",
|
|
56
59
|
"appshell-nav",
|
|
60
|
+
"appshell-nav-group",
|
|
61
|
+
"appshell-nav-group-label",
|
|
57
62
|
"appshell-nav-label",
|
|
58
63
|
"appshell-nav-list",
|
|
59
64
|
"appshell-sidebar",
|
|
65
|
+
"appshell-skip-link",
|
|
66
|
+
"avatar",
|
|
60
67
|
"badge",
|
|
61
68
|
"breadcrumbs",
|
|
62
69
|
"breadcrumbs-current",
|
|
@@ -77,6 +84,8 @@ const MARKERS = [
|
|
|
77
84
|
"card-heading",
|
|
78
85
|
"card-title",
|
|
79
86
|
"checkbox",
|
|
87
|
+
"code",
|
|
88
|
+
"code-block",
|
|
80
89
|
"combobox",
|
|
81
90
|
"combobox-empty",
|
|
82
91
|
"combobox-field",
|
|
@@ -121,6 +130,7 @@ const MARKERS = [
|
|
|
121
130
|
"dataview-toolbar-spacer",
|
|
122
131
|
"dataview-toolbar-status",
|
|
123
132
|
"detail-list",
|
|
133
|
+
"detail-list-row",
|
|
124
134
|
"detail-list-term",
|
|
125
135
|
"detail-list-value",
|
|
126
136
|
"dialog",
|
|
@@ -128,6 +138,7 @@ const MARKERS = [
|
|
|
128
138
|
"dialog-body",
|
|
129
139
|
"dialog-description",
|
|
130
140
|
"dialog-title",
|
|
141
|
+
"divider",
|
|
131
142
|
"drawer-focus-end",
|
|
132
143
|
"drawer-focus-start",
|
|
133
144
|
"empty-state",
|
|
@@ -143,6 +154,8 @@ const MARKERS = [
|
|
|
143
154
|
"field-hint",
|
|
144
155
|
"field-label",
|
|
145
156
|
"field-label-text",
|
|
157
|
+
"grid",
|
|
158
|
+
"heading",
|
|
146
159
|
"hubcard",
|
|
147
160
|
"hubcard-body",
|
|
148
161
|
"hubcard-description",
|
|
@@ -155,10 +168,21 @@ const MARKERS = [
|
|
|
155
168
|
"icon",
|
|
156
169
|
"iconbutton",
|
|
157
170
|
"input",
|
|
171
|
+
"input-password",
|
|
158
172
|
"language-switcher",
|
|
159
173
|
"language-switcher-label",
|
|
174
|
+
"link",
|
|
160
175
|
"loading-state",
|
|
161
176
|
"loading-state-spinner",
|
|
177
|
+
"login-brand",
|
|
178
|
+
"login-card",
|
|
179
|
+
"login-error",
|
|
180
|
+
"login-form",
|
|
181
|
+
"login-separator",
|
|
182
|
+
"login-separator-rule",
|
|
183
|
+
"login-sso",
|
|
184
|
+
"login-title",
|
|
185
|
+
"login-view",
|
|
162
186
|
"markdown",
|
|
163
187
|
"menu",
|
|
164
188
|
"menu-item",
|
|
@@ -166,23 +190,41 @@ const MARKERS = [
|
|
|
166
190
|
"menu-item-icon",
|
|
167
191
|
"menu-trigger",
|
|
168
192
|
"module-nav",
|
|
193
|
+
"module-nav-link",
|
|
194
|
+
"module-nav-list",
|
|
169
195
|
"nav-icon",
|
|
170
196
|
"nav-icon-fallback",
|
|
197
|
+
"page",
|
|
171
198
|
"page-actions",
|
|
199
|
+
"page-breadcrumbs",
|
|
200
|
+
"page-header",
|
|
201
|
+
"page-heading",
|
|
202
|
+
"page-title",
|
|
172
203
|
"popover",
|
|
173
204
|
"popover-panel",
|
|
205
|
+
"profile-card",
|
|
206
|
+
"profile-email",
|
|
207
|
+
"profile-role",
|
|
174
208
|
"radio",
|
|
175
209
|
"radio-group",
|
|
176
210
|
"radio-group-legend",
|
|
177
211
|
"radio-group-options",
|
|
178
212
|
"resource-list",
|
|
213
|
+
"resource-list-create",
|
|
214
|
+
"resource-list-empty",
|
|
215
|
+
"resource-list-error",
|
|
216
|
+
"resource-list-items",
|
|
217
|
+
"resource-list-row",
|
|
179
218
|
"spinner-ring",
|
|
219
|
+
"splitpage-panes",
|
|
220
|
+
"splitpane",
|
|
180
221
|
"stack",
|
|
181
222
|
"switch",
|
|
182
223
|
"tab",
|
|
183
224
|
"tab-list",
|
|
184
225
|
"tab-panel",
|
|
185
226
|
"tabs",
|
|
227
|
+
"text",
|
|
186
228
|
"theme-toggle",
|
|
187
229
|
"theme-toggle-label",
|
|
188
230
|
"toast",
|
|
@@ -193,7 +235,6 @@ const MARKERS = [
|
|
|
193
235
|
"tooltip",
|
|
194
236
|
"tooltip-anchor",
|
|
195
237
|
"user-menu",
|
|
196
|
-
"user-menu-avatar",
|
|
197
238
|
"user-menu-email",
|
|
198
239
|
"user-menu-header",
|
|
199
240
|
"user-menu-identity",
|
|
@@ -254,6 +295,17 @@ const MARKERS = [
|
|
|
254
295
|
* exactly this. A marker plus a `display: none` rule would have put a component with no visual
|
|
255
296
|
* design into the sheet and offered an app the chance to un-hide it.
|
|
256
297
|
*
|
|
298
|
+
* `DetailPage` and `OverviewPage` have left the same way, and they were on this list by
|
|
299
|
+
* mistake rather than by migration: each is a `LayoutSlotContext.Provider` wrapped around
|
|
300
|
+
* `Page` and renders no element of its own at all, so they are view compositions — which the
|
|
301
|
+
* paragraph above already excludes. There is nothing to mark without inventing a box, and the
|
|
302
|
+
* box is the one thing that must not exist here: `Page`'s slot check reads `article.children`,
|
|
303
|
+
* so a wrapper around the body — `display: contents` included, since that check is a DOM
|
|
304
|
+
* traversal and the node is still in the collection — becomes the sole body-slot child, is in
|
|
305
|
+
* no allow table, and fails every governed page closed. Their archetype identity is a context
|
|
306
|
+
* value, which is the right place for it: `Page` renders the only box either of them has, and
|
|
307
|
+
* it is marked.
|
|
308
|
+
*
|
|
257
309
|
* Worth knowing about the shape of this list, because it flatters two files: it names files
|
|
258
310
|
* with NO marker at all, so one marker on one element exempts the rest of the file. `toast.tsx`
|
|
259
311
|
* and `ConfirmDialog.tsx` were never on it despite styling five and four unreachable elements
|
|
@@ -262,13 +314,14 @@ const MARKERS = [
|
|
|
262
314
|
*
|
|
263
315
|
* `Field` has graduated: it renders a root plus label, label text, hint and error markers,
|
|
264
316
|
* so each part of a form field is addressable from the sheet.
|
|
317
|
+
*
|
|
318
|
+
* THE LIST IS EMPTY. `LoginView` was the last entry, and it is kept rather than deleted for
|
|
319
|
+
* the same reason as the ledger below: this is where a new unmarked styled surface has to
|
|
320
|
+
* argue for itself. Note what an empty list does NOT say, because the shape above already
|
|
321
|
+
* flatters two files — it names modules with NO marker at all, so one marker anywhere in a
|
|
322
|
+
* file exempts the rest of it.
|
|
265
323
|
*/
|
|
266
|
-
const UNMARKED_STYLED_SURFACES = [
|
|
267
|
-
"./DetailPage.tsx",
|
|
268
|
-
"./LoginView.tsx",
|
|
269
|
-
"./OverviewPage.tsx",
|
|
270
|
-
"./ProfileView.tsx",
|
|
271
|
-
];
|
|
324
|
+
const UNMARKED_STYLED_SURFACES: string[] = [];
|
|
272
325
|
|
|
273
326
|
/**
|
|
274
327
|
* How many module-scope base style objects each file still declares — the migration's own
|
|
@@ -291,13 +344,64 @@ const UNMARKED_STYLED_SURFACES = [
|
|
|
291
344
|
* `Popover` positions its panel from a rect it measured — ADR 0094 §3 puts all three on the
|
|
292
345
|
* inline side of the line permanently, so counting them would make this list unable to reach
|
|
293
346
|
* zero and therefore unable to mean anything.
|
|
347
|
+
*
|
|
348
|
+
* IT HAS REACHED ZERO, and the honest reading of that is narrow. The detector matches a
|
|
349
|
+
* module-scope declaration annotated `CSSProperties`, which means a call-site literal and an
|
|
350
|
+
* unannotated module-scope object are both invisible to it. Four of the built-in admin views
|
|
351
|
+
* carried five base styles through this entire migration for exactly that reason — four as
|
|
352
|
+
* call-site object literals (two of them the same `<h2>` declaration, two the same form
|
|
353
|
+
* measure) and one as an unannotated `payloadStyle` — while both ratchets read clean.
|
|
354
|
+
* The next commit widens the detector and migrates them, so that zero means what it looks
|
|
355
|
+
* like it means.
|
|
356
|
+
*/
|
|
357
|
+
const INLINE_BASE_STYLES: Record<string, number> = {};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every module that renders an inline `style={}` at all, and how many times.
|
|
361
|
+
*
|
|
362
|
+
* This closes the gap the ledger above leaves, and the gap was not theoretical. That one
|
|
363
|
+
* matches a module-scope declaration annotated `CSSProperties`, so a call-site literal and
|
|
364
|
+
* an unannotated module-scope object are both invisible to it — and four of the built-in
|
|
365
|
+
* admin views carried five base styles through the entire styling migration for exactly
|
|
366
|
+
* that reason, with both ratchets reading clean the whole time. Widening the annotation
|
|
367
|
+
* pattern would not have found them either. What finds them is counting the SITES.
|
|
368
|
+
*
|
|
369
|
+
* So this list is the complete one, and every entry has to be a value ADR 0094 §3 puts on
|
|
370
|
+
* the inline side permanently. There are only two such kinds, and a new entry is a claim
|
|
371
|
+
* that there is a third:
|
|
372
|
+
*
|
|
373
|
+
* - A measured value the sheet has no business owning. `icons.tsx` and `LoadingState.tsx`
|
|
374
|
+
* size a box from a prop taking any CSS length; `layout.tsx` passes Stack's `align` /
|
|
375
|
+
* `justify`, an open vocabulary CSS already has; `ui/Popover.tsx` positions its panel
|
|
376
|
+
* from a rect measured at runtime; `dataview/DataViewTable.tsx` carries a column's width
|
|
377
|
+
* while it is being dragged.
|
|
378
|
+
* - The caller's own `style` prop, forwarded to a root. `ui/Checkbox.tsx`, `ui/Radio.tsx`,
|
|
379
|
+
* `ui/Switch.tsx` and `ui/Combobox.tsx` do this and nothing else — it is not the
|
|
380
|
+
* component styling itself, it is the escape a framework component owes its caller, and
|
|
381
|
+
* `ui/Button.test.tsx` asserts it keeps working.
|
|
382
|
+
*
|
|
383
|
+
* Exact equality in both directions, like the ledger above: a new site fails, and a removed
|
|
384
|
+
* one fails until this record is corrected. Comments are stripped first, because this file's
|
|
385
|
+
* own prose says `style={}` repeatedly and the measure is otherwise a grep of its own
|
|
386
|
+
* documentation.
|
|
387
|
+
*
|
|
388
|
+
* Counted with a regex rather than by splitting on the literal `style={`, and that is not
|
|
389
|
+
* tidying. JSX permits whitespace around the `=`, so `style = {{ … }}` and a value on the
|
|
390
|
+
* next line are both valid and both invisible to a literal split — either would have let a
|
|
391
|
+
* base style back in past a green gate, which is the exact failure this list exists to
|
|
392
|
+
* prevent. Verified: the two forms count 0 under the split and 1 under the regex, while
|
|
393
|
+
* every file above counts identically either way.
|
|
294
394
|
*/
|
|
295
|
-
const
|
|
296
|
-
"./
|
|
297
|
-
"./
|
|
298
|
-
"./
|
|
299
|
-
"./
|
|
300
|
-
"./
|
|
395
|
+
const INLINE_STYLE_SITES: Record<string, number> = {
|
|
396
|
+
"./LoadingState.tsx": 1,
|
|
397
|
+
"./dataview/DataViewTable.tsx": 1,
|
|
398
|
+
"./icons.tsx": 1,
|
|
399
|
+
"./layout.tsx": 1,
|
|
400
|
+
"./ui/Checkbox.tsx": 1,
|
|
401
|
+
"./ui/Combobox.tsx": 1,
|
|
402
|
+
"./ui/Popover.tsx": 1,
|
|
403
|
+
"./ui/Radio.tsx": 1,
|
|
404
|
+
"./ui/Switch.tsx": 1,
|
|
301
405
|
};
|
|
302
406
|
|
|
303
407
|
/**
|
|
@@ -436,6 +540,21 @@ describe("data-terp markers", () => {
|
|
|
436
540
|
expect(declared).toEqual(INLINE_BASE_STYLES);
|
|
437
541
|
});
|
|
438
542
|
|
|
543
|
+
it("accounts for every inline style site, not only the annotated ones", () => {
|
|
544
|
+
// The measure the admin views escaped. A base style declared as a call-site literal, or
|
|
545
|
+
// as a module-scope object nobody annotated, is a base style either way — and the ledger
|
|
546
|
+
// above cannot see either. This one counts sites, so the only way to stay out of it is to
|
|
547
|
+
// render no inline style at all.
|
|
548
|
+
const sites: Record<string, number> = {};
|
|
549
|
+
for (const [file, text] of production) {
|
|
550
|
+
const count = stripComments(text).match(/style\s*=\s*\{/g)?.length ?? 0;
|
|
551
|
+
if (count > 0) {
|
|
552
|
+
sites[file] = count;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
expect(sites).toEqual(INLINE_STYLE_SITES);
|
|
556
|
+
});
|
|
557
|
+
|
|
439
558
|
it("injects the sheet from every module that owns a rule, or is reachable from one that does", () => {
|
|
440
559
|
// Twelve marker-rendering modules never call injectTerpStyles and do not need to: the
|
|
441
560
|
// package publishes ONE entry point and declares no `sideEffects`, so importing anything
|
|
@@ -443,8 +562,15 @@ describe("data-terp markers", () => {
|
|
|
443
562
|
// property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
|
|
444
563
|
// tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
|
|
445
564
|
// collapsing into one grid item. So the property itself is what this pins.
|
|
446
|
-
|
|
447
|
-
|
|
565
|
+
// One CODE entry point, which is the property. Data subpaths are allowed and do not
|
|
566
|
+
// weaken it: `./layout.manifest.json` is JSON a tool reads out of node_modules, no module
|
|
567
|
+
// imports it, and importing JSON loads no JavaScript at all — so there is still exactly one
|
|
568
|
+
// way into this package's modules. Asserted by extension rather than by key count, so that
|
|
569
|
+
// publishing a second entry point is what fails and publishing another data file is not.
|
|
570
|
+
const codeEntries = Object.entries(manifest.exports).filter(([, target]) =>
|
|
571
|
+
/\.(ts|tsx|js|mjs|cjs)$/.test(target),
|
|
572
|
+
);
|
|
573
|
+
expect(codeEntries).toEqual([[".", "./src/index.ts"]]);
|
|
448
574
|
expect(
|
|
449
575
|
"sideEffects" in manifest,
|
|
450
576
|
"declaring sideEffects would let a bundler drop the modules that inject the stylesheet",
|
package/src/nav.test.ts
CHANGED
|
@@ -1,21 +1,251 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { ModuleManifest } from "@terpjs/contract";
|
|
3
3
|
|
|
4
|
-
import { visibleNav } from "./nav";
|
|
4
|
+
import { groupNav, isDeclarationVisible, visibleNav } from "./nav";
|
|
5
|
+
import type { NavVisibilityContext } from "./nav";
|
|
5
6
|
|
|
6
7
|
const manifests: ModuleManifest[] = [
|
|
7
8
|
{ name: "notes", routes: [], nav: [{ label: "Notes", to: "/notes" }] },
|
|
8
9
|
{ name: "admin", routes: [], nav: [{ label: "Users", to: "/users", role: "admin" }] },
|
|
10
|
+
{
|
|
11
|
+
name: "billing",
|
|
12
|
+
routes: [],
|
|
13
|
+
nav: [{ label: "Export", to: "/export", permission: "billing.export" }],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "audit",
|
|
17
|
+
// Both gates on one item, which is the case the AND exists for.
|
|
18
|
+
routes: [],
|
|
19
|
+
nav: [{ label: "Audit", to: "/audit", role: "admin", permission: "audit.read" }],
|
|
20
|
+
},
|
|
9
21
|
{ name: "noNav", routes: [] },
|
|
10
22
|
];
|
|
11
23
|
|
|
24
|
+
/** A context that says yes to everything, so each test can deny exactly one thing. */
|
|
25
|
+
function allowAll(overrides: Partial<NavVisibilityContext> = {}): NavVisibilityContext {
|
|
26
|
+
return {
|
|
27
|
+
canSeeRole: () => true,
|
|
28
|
+
permissions: ["billing.export", "audit.read"],
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
describe("visibleNav", () => {
|
|
13
34
|
it("flattens nav across manifests when everything is visible", () => {
|
|
14
|
-
expect(visibleNav(manifests, ()
|
|
35
|
+
expect(visibleNav(manifests, allowAll()).map((i) => i.to)).toEqual([
|
|
36
|
+
"/notes",
|
|
37
|
+
"/users",
|
|
38
|
+
"/export",
|
|
39
|
+
"/audit",
|
|
40
|
+
]);
|
|
15
41
|
});
|
|
16
42
|
|
|
17
43
|
it("filters items by their required role", () => {
|
|
18
|
-
const onlyPublic = visibleNav(manifests, (role) => role === undefined);
|
|
19
|
-
|
|
44
|
+
const onlyPublic = visibleNav(manifests, allowAll({ canSeeRole: (role) => role === undefined }));
|
|
45
|
+
// /export declares no role, so the role gate lets it through; /audit declares both.
|
|
46
|
+
expect(onlyPublic.map((i) => i.to)).toEqual(["/notes", "/export"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("filters items by their required permission", () => {
|
|
50
|
+
const noGrants = visibleNav(manifests, allowAll({ permissions: [] }));
|
|
51
|
+
expect(noGrants.map((i) => i.to)).toEqual(["/notes", "/users"]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("isDeclarationVisible", () => {
|
|
56
|
+
it("requires BOTH gates when an item declares both", () => {
|
|
57
|
+
// The composition, stated as a truth table. Either alone is not enough, which is what the
|
|
58
|
+
// server does: a Policy carrying a Permission enforces the permission's role floor AND the
|
|
59
|
+
// grant. A client checking one would disagree with the endpoint in one direction or another.
|
|
60
|
+
const both = { role: "admin", permission: "audit.read" };
|
|
61
|
+
expect(isDeclarationVisible(both, allowAll())).toBe(true);
|
|
62
|
+
expect(isDeclarationVisible(both, allowAll({ canSeeRole: () => false }))).toBe(false);
|
|
63
|
+
expect(isDeclarationVisible(both, allowAll({ permissions: [] }))).toBe(false);
|
|
64
|
+
expect(
|
|
65
|
+
isDeclarationVisible(both, { canSeeRole: () => false, permissions: [] }),
|
|
66
|
+
).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is unchanged for a declaration that names neither gate", () => {
|
|
70
|
+
// The additivity claim. Nothing an existing manifest declares moves, because a missing
|
|
71
|
+
// permission short-circuits to true and `role` keeps its exact meaning.
|
|
72
|
+
expect(isDeclarationVisible({}, allowAll({ permissions: [] }))).toBe(true);
|
|
73
|
+
expect(isDeclarationVisible({}, allowAll({ canSeeRole: () => false }))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("fails closed on a permission nobody granted", () => {
|
|
77
|
+
// Three ways in, one outcome. A misspelled name is indistinguishable from an ungranted one,
|
|
78
|
+
// and that is correct: both mean "the server would refuse this".
|
|
79
|
+
expect(isDeclarationVisible({ permission: "billing.exprot" }, allowAll())).toBe(false);
|
|
80
|
+
// An app that mounts no grant capability has an empty list — which must hide the item, not
|
|
81
|
+
// wave it through. This is the branch a "treat empty as unrestricted" shortcut would break.
|
|
82
|
+
expect(isDeclarationVisible({ permission: "billing.export" }, allowAll({ permissions: [] }))).toBe(
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
// Signed out: `canSeeRole` already answers false for a null rank, before permissions matter.
|
|
86
|
+
expect(
|
|
87
|
+
isDeclarationVisible({ permission: "billing.export" }, {
|
|
88
|
+
canSeeRole: () => false,
|
|
89
|
+
permissions: ["billing.export"],
|
|
90
|
+
}),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// `groupNav` — the app's declared groups meeting the modules' declared items.
|
|
96
|
+
//
|
|
97
|
+
// Every row below names the mutation that turns it red, because two of this phase's earlier
|
|
98
|
+
// gates could not fail and were only found by trying. Two are worth reading before adding a row
|
|
99
|
+
// here. "Sorts stably" cannot be falsified by DELETING the sort: `Array.prototype.sort` is stable
|
|
100
|
+
// by specification, so a tied array comes out in declaration order whether or not it was sorted,
|
|
101
|
+
// and the assertion passes over the mutant. The comparator that does reorder ties is
|
|
102
|
+
// `(a, b) => ((a.order ?? 0) - (b.order ?? 0)) || -1`, which was measured in this repo's node to
|
|
103
|
+
// fully reverse a tied array at every length from 2 upward. And "sorts" is only falsifiable
|
|
104
|
+
// against `?? Infinity` if the fixture carries a POSITIVE order beside an absent one — with only
|
|
105
|
+
// negatives and absents the two rules agree.
|
|
106
|
+
describe("groupNav", () => {
|
|
107
|
+
const items = (...tos: string[]) => tos.map((to) => ({ label: to, to }));
|
|
108
|
+
const tos = (sections: ReturnType<typeof groupNav>) =>
|
|
109
|
+
sections.map((section) => section.items.map((item) => item.to));
|
|
110
|
+
/** Section identity and heading, without the items — asserted separately via `tos`. */
|
|
111
|
+
const shape = (sections: ReturnType<typeof groupNav>) =>
|
|
112
|
+
sections.map((section) => ({ id: section.id, label: section.label }));
|
|
113
|
+
|
|
114
|
+
it("renders one unlabelled section when the app declares no groups", () => {
|
|
115
|
+
// The additivity claim, as a test rather than an argument: same items, same order, one
|
|
116
|
+
// section that renders no heading. Mutation: `return [{ id: "default", label: "", items }]`,
|
|
117
|
+
// which falsifies the null id and the null label together.
|
|
118
|
+
expect(groupNav(items("/a", "/b", "/c"))).toEqual([
|
|
119
|
+
{ id: null, label: null, items: items("/a", "/b", "/c") },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("emits the ungrouped bucket last, after every declared group", () => {
|
|
124
|
+
// The rule that changed under review. The packaged admin entry carries no `group` and no app
|
|
125
|
+
// can give it one, so a first-emitted default bucket would hoist it to the top of the sidebar
|
|
126
|
+
// the moment an app declared its first group. Mutation: emit the ungrouped section first.
|
|
127
|
+
const sections = groupNav(
|
|
128
|
+
[
|
|
129
|
+
{ label: "Loose", to: "/loose" },
|
|
130
|
+
{ label: "Sales", to: "/sales", group: "work" },
|
|
131
|
+
],
|
|
132
|
+
[{ id: "work", label: "Werkruimte" }],
|
|
133
|
+
);
|
|
134
|
+
expect(sections.map((section) => section.id)).toEqual(["work", null]);
|
|
135
|
+
expect(tos(sections)).toEqual([["/sales"], ["/loose"]]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("drops a group left empty rather than rendering a label over nothing", () => {
|
|
139
|
+
// Reachable on a first render: a group holding only the role-gated `/admin` entry is empty
|
|
140
|
+
// for everyone who is not an admin, because `visibleNav` has already removed it.
|
|
141
|
+
// Mutation: push the section regardless of `bucket.length`.
|
|
142
|
+
const sections = groupNav(items("/a"), [
|
|
143
|
+
{ id: "empty", label: "Beheer" },
|
|
144
|
+
{ id: "used", label: "Werk" },
|
|
145
|
+
]);
|
|
146
|
+
expect(sections.map((section) => section.id)).toEqual([null]);
|
|
147
|
+
expect(sections.some((section) => section.label === "Beheer")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls an item naming an undeclared group open into the ungrouped bucket", () => {
|
|
151
|
+
// A module ships before the app declares its group; the link must still be reachable.
|
|
152
|
+
// Mutation: drop the `declared.has(item.group)` half of the key expression, and the item
|
|
153
|
+
// lands in a bucket nothing reads — it vanishes from the sidebar with nothing reporting it.
|
|
154
|
+
const sections = groupNav(
|
|
155
|
+
[{ label: "Orphan", to: "/orphan", group: "not-declared" }],
|
|
156
|
+
[{ id: "work", label: "Werkruimte" }],
|
|
157
|
+
);
|
|
158
|
+
expect(tos(sections)).toEqual([["/orphan"]]);
|
|
159
|
+
expect(sections[0]!.id).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("sorts items on order, treating absent as 0", () => {
|
|
163
|
+
// Mutation: `order ?? Infinity`, which sends both unordered items to the end and yields
|
|
164
|
+
// /up, /down, /a, /b. The POSITIVE order is what makes the two rules disagree.
|
|
165
|
+
const sections = groupNav([
|
|
166
|
+
{ label: "down", to: "/down", order: 1 },
|
|
167
|
+
{ label: "a", to: "/a" },
|
|
168
|
+
{ label: "up", to: "/up", order: -1 },
|
|
169
|
+
{ label: "b", to: "/b" },
|
|
170
|
+
]);
|
|
171
|
+
expect(tos(sections)).toEqual([["/up", "/a", "/b", "/down"]]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps tied items in declaration order", () => {
|
|
175
|
+
// Mutation: `((a.order ?? 0) - (b.order ?? 0)) || -1` — measured to reverse a tied array at
|
|
176
|
+
// every length from 2 up. Deleting the sort does NOT falsify this, which is the point.
|
|
177
|
+
expect(tos(groupNav(items("/first", "/second", "/third")))).toEqual([
|
|
178
|
+
["/first", "/second", "/third"],
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("sorts groups on order, treating absent as 0", () => {
|
|
183
|
+
// Same shape one level up, and the same `?? Infinity` mutation.
|
|
184
|
+
const sections = groupNav(
|
|
185
|
+
[
|
|
186
|
+
{ label: "d", to: "/d", group: "down" },
|
|
187
|
+
{ label: "p", to: "/p", group: "plain" },
|
|
188
|
+
{ label: "u", to: "/u", group: "up" },
|
|
189
|
+
],
|
|
190
|
+
[
|
|
191
|
+
{ id: "down", label: "Down", order: 1 },
|
|
192
|
+
{ id: "plain", label: "Plain" },
|
|
193
|
+
{ id: "up", label: "Up", order: -1 },
|
|
194
|
+
],
|
|
195
|
+
);
|
|
196
|
+
expect(sections.map((section) => section.id)).toEqual(["up", "plain", "down"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps tied groups in declaration order", () => {
|
|
200
|
+
const sections = groupNav(
|
|
201
|
+
[
|
|
202
|
+
{ label: "a", to: "/a", group: "one" },
|
|
203
|
+
{ label: "b", to: "/b", group: "two" },
|
|
204
|
+
{ label: "c", to: "/c", group: "three" },
|
|
205
|
+
],
|
|
206
|
+
[
|
|
207
|
+
{ id: "one", label: "One" },
|
|
208
|
+
{ id: "two", label: "Two" },
|
|
209
|
+
{ id: "three", label: "Three" },
|
|
210
|
+
],
|
|
211
|
+
);
|
|
212
|
+
expect(sections.map((section) => section.id)).toEqual(["one", "two", "three"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("lets the first declaration of a duplicated id win, without duplicating its items", () => {
|
|
216
|
+
// `groupNav` stays total so a render can never throw; `buildAppRouter` refuses the duplicate
|
|
217
|
+
// at composition time instead. Mutation: drop the `declared.has` guard on insertion, and the
|
|
218
|
+
// second declaration overwrites the first, so the label becomes "Second".
|
|
219
|
+
const sections = groupNav([{ label: "a", to: "/a", group: "dup" }], [
|
|
220
|
+
{ id: "dup", label: "First" },
|
|
221
|
+
{ id: "dup", label: "Second" },
|
|
222
|
+
]);
|
|
223
|
+
expect(shape(sections)).toEqual([{ id: "dup", label: "First" }]);
|
|
224
|
+
expect(tos(sections)).toEqual([["/a"]]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("treats the empty string as a real group id, not as the ungrouped bucket", () => {
|
|
228
|
+
// The default bucket is keyed on `null` rather than on a falsy sentinel precisely so that
|
|
229
|
+
// `id: ""` — a legal string — stays a group of its own. Mutation: key the bucket on `""`,
|
|
230
|
+
// and this item's group merges into the ungrouped section and loses its label.
|
|
231
|
+
const sections = groupNav([{ label: "a", to: "/a", group: "" }], [{ id: "", label: "Blank" }]);
|
|
232
|
+
expect(shape(sections)).toEqual([{ id: "", label: "Blank" }]);
|
|
233
|
+
expect(tos(sections)).toEqual([["/a"]]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("renders a null-labelled group as pure positioning", () => {
|
|
237
|
+
// The escape an app uses to place its otherwise-ungrouped items somewhere other than last.
|
|
238
|
+
const sections = groupNav(
|
|
239
|
+
[
|
|
240
|
+
{ label: "main", to: "/main", group: "main" },
|
|
241
|
+
{ label: "loose", to: "/loose" },
|
|
242
|
+
],
|
|
243
|
+
[{ id: "main", label: null }],
|
|
244
|
+
);
|
|
245
|
+
expect(shape(sections)).toEqual([
|
|
246
|
+
{ id: "main", label: null },
|
|
247
|
+
{ id: null, label: null },
|
|
248
|
+
]);
|
|
249
|
+
expect(tos(sections)).toEqual([["/main"], ["/loose"]]);
|
|
20
250
|
});
|
|
21
251
|
});
|