@terpjs/react-core 0.9.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 +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- 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 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- 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/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- 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 +25 -5
- 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/router.test.tsx +575 -2
- package/src/router.tsx +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +69 -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/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
|
});
|
package/src/nav.ts
CHANGED
|
@@ -1,13 +1,187 @@
|
|
|
1
|
-
import type { ModuleManifest, NavItem } from "@terpjs/contract";
|
|
1
|
+
import type { ModuleManifest, NavGroup, NavItem } from "@terpjs/contract";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* What a manifest's declared visibility is resolved against.
|
|
5
|
+
*
|
|
6
|
+
* Built from what `/me` actually returns, which is the whole reason this type is two fields and
|
|
7
|
+
* not four. ADR 0097 §5 specified a context of module grants, per-module role ranks, superuser
|
|
8
|
+
* and internal-versus-external; `CurrentUser` carries none of them. It carries `role_rank`,
|
|
9
|
+
* `role_name` and `permissions`, and the last of those is the general mechanism the decision
|
|
10
|
+
* never named. A context built from what is real is honest; one built from that list would be
|
|
11
|
+
* vocabulary with no source behind it.
|
|
12
|
+
*/
|
|
13
|
+
export interface NavVisibilityContext {
|
|
14
|
+
/** Whether the caller clears a named role's floor — the existing `role` gate, unchanged. */
|
|
15
|
+
canSeeRole: (role: string | undefined) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The caller's named grants, from `CurrentUser.permissions`.
|
|
18
|
+
*
|
|
19
|
+
* Empty when signed out and empty for an app that mounts no grant capability, which is what
|
|
20
|
+
* makes the gate fail closed in both cases rather than failing open in the second.
|
|
21
|
+
*/
|
|
22
|
+
permissions: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether one declared item is visible to the caller.
|
|
27
|
+
*
|
|
28
|
+
* `role` and `permission` are **ANDed**, which is deliberately what the server does: a `Policy`
|
|
29
|
+
* carrying a `Permission` enforces the permission's role floor *and* the grant, so a client that
|
|
30
|
+
* checked either alone would disagree with the endpoint in one direction or the other. It is the
|
|
31
|
+
* same composition `Authorized` already ships, and reusing it means the navigation gains the gate
|
|
32
|
+
* the buttons already have rather than a second vocabulary for the same idea.
|
|
33
|
+
*
|
|
34
|
+
* Exported because routes need it too. A nav-only gate would hide the link and leave the route
|
|
35
|
+
* reachable by URL — `role` has never had that asymmetry, since it is declared on both
|
|
36
|
+
* `NavItem` and `ModuleRoute`, and `permission` must not introduce one.
|
|
37
|
+
*/
|
|
38
|
+
export function isDeclarationVisible(
|
|
39
|
+
declaration: { role?: string; permission?: string },
|
|
40
|
+
context: NavVisibilityContext,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (!context.canSeeRole(declaration.role)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
declaration.permission === undefined ||
|
|
47
|
+
context.permissions.includes(declaration.permission)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flatten the nav of every module manifest into one ordered sidebar list, keeping only the items
|
|
53
|
+
* the current user may see.
|
|
54
|
+
*
|
|
55
|
+
* The context is a **required** argument, and that is a breaking change taken on purpose. Making
|
|
56
|
+
* it optional would have been source-compatible and silently wrong: an existing two-argument
|
|
57
|
+
* caller would fail closed on every item that gained a `permission`, removing links from a
|
|
58
|
+
* sidebar with no error anywhere. A required parameter turns that into a typecheck error at the
|
|
59
|
+
* one call site each app has.
|
|
7
60
|
*/
|
|
8
61
|
export function visibleNav(
|
|
9
62
|
manifests: readonly ModuleManifest[],
|
|
10
|
-
|
|
63
|
+
context: NavVisibilityContext,
|
|
11
64
|
): NavItem[] {
|
|
12
|
-
return manifests
|
|
65
|
+
return manifests
|
|
66
|
+
.flatMap((manifest) => manifest.nav ?? [])
|
|
67
|
+
.filter((item) => isDeclarationVisible(item, context));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One rendered section of the primary navigation: a declared {@link NavGroup} with the items that
|
|
72
|
+
* named it, or the default headerless group with everything that named nothing.
|
|
73
|
+
*
|
|
74
|
+
* The group is flattened into `id` and `label` rather than carried whole, and that is what lets
|
|
75
|
+
* one shape describe both cases. The default group has no declaration to carry — it is not a
|
|
76
|
+
* `NavGroup` an app wrote, it is the absence of one — so a section holding an optional `NavGroup`
|
|
77
|
+
* would force every reader through `section.group?.label` to ask a question that has the same
|
|
78
|
+
* answer either way: what, if anything, do I render above this list.
|
|
79
|
+
*/
|
|
80
|
+
export interface NavSection {
|
|
81
|
+
/** The declared group's id, or `null` for the default headerless group. */
|
|
82
|
+
id: string | null;
|
|
83
|
+
/** The label to render above the list, or `null` when the section renders none. */
|
|
84
|
+
label: string | null;
|
|
85
|
+
items: NavItem[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stable ascending sort on `order ?? 0` — see {@link groupNav} for why absent is 0. */
|
|
89
|
+
function byOrder<T extends { order?: number }>(values: T[]): T[] {
|
|
90
|
+
return values.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Arrange visible nav items into the app's declared groups.
|
|
95
|
+
*
|
|
96
|
+
* Groups fix the defect ADR 0097 names in its own Context: sidebar order is "an accident of
|
|
97
|
+
* `import.meta.glob` key order", because the sidebar is one flat list flattened out of whatever
|
|
98
|
+
* sequence the module manifests happened to arrive in. A group spans modules, so no module can
|
|
99
|
+
* own one — the app declares them, modules reference them by id, and this function is where the
|
|
100
|
+
* two halves meet.
|
|
101
|
+
*
|
|
102
|
+
* **Additive by construction.** An app that declares no groups gets exactly one section holding
|
|
103
|
+
* exactly the items it passed in, in the order it passed them: `groups` defaults to empty, every
|
|
104
|
+
* item falls into the default bucket, and a stable sort over keys that are all 0 is the identity.
|
|
105
|
+
* That is not an argument that it *should* be additive — it is the code path, and the shell
|
|
106
|
+
* renders the same list it renders today.
|
|
107
|
+
*
|
|
108
|
+
* Four rules, and each one is a decision:
|
|
109
|
+
*
|
|
110
|
+
* **An unknown group id falls into the default bucket rather than dropping the item.** A group is
|
|
111
|
+
* declared by the app; the item is declared by a module that ships on its own schedule. An id
|
|
112
|
+
* with no declaration yet is therefore the ordinary state of a module the app has not finished
|
|
113
|
+
* adopting, not a corruption — and the two ways to be wrong are not symmetric. An ungrouped link
|
|
114
|
+
* is in the wrong place and works; a dropped link is a screen the user cannot reach, with nothing
|
|
115
|
+
* anywhere saying why. Nothing warns about it either: this is a state the design calls normal,
|
|
116
|
+
* and a diagnostic for a normal state is noise that teaches people to ignore diagnostics.
|
|
117
|
+
*
|
|
118
|
+
* **A section with no items is not emitted.** Reachable on the first render of any app that
|
|
119
|
+
* groups the packaged admin entry's neighbours: `/admin` declares `role: "admin"`, so
|
|
120
|
+
* {@link visibleNav} removes it for everyone else and a group holding only it is left empty. A
|
|
121
|
+
* label over a void is worse than no label, because it names a place the user is being refused
|
|
122
|
+
* and cannot see.
|
|
123
|
+
*
|
|
124
|
+
* **The default bucket is emitted LAST, and it is not part of the group sort.** This is the rule
|
|
125
|
+
* that changed under review, and the case that decided it is the one case every app has. The
|
|
126
|
+
* packaged admin area is appended after the app's own manifests, so its nav entry renders last
|
|
127
|
+
* today. Emit the default bucket first and that entry — which no app authors and therefore no app
|
|
128
|
+
* can annotate with a `group` — jumps to the top of the sidebar the moment the app declares its
|
|
129
|
+
* first group. Emitting last leaves it exactly where it already is. It also puts an item naming
|
|
130
|
+
* an undeclared group at the bottom rather than above the app's own structure, which is the
|
|
131
|
+
* better place for a fail-open result to surface.
|
|
132
|
+
*
|
|
133
|
+
* The trade is real and is worth stating: an app that groups a *minority* of its items sees the
|
|
134
|
+
* ungrouped majority sink below them. That is app-authored, visible on the first render, and
|
|
135
|
+
* fixed in one line by declaring `{ id: "main", label: null, order: -1 }` and pointing those
|
|
136
|
+
* items at it — a group with a `null` label renders no heading, so it is pure positioning. The
|
|
137
|
+
* admin case has no such fix, which is what makes it decisive rather than merely also true.
|
|
138
|
+
*
|
|
139
|
+
* **Absent `order` is 0, everywhere, and the sort is stable.** Not `Infinity`: a single numbered
|
|
140
|
+
* item would then leap above an entire unnumbered list, which breaks the additivity the rest of
|
|
141
|
+
* this function is built on. Absent-is-0 is CSS `order` semantics exactly — positive sorts below
|
|
142
|
+
* its unordered siblings, negative above — which is the vocabulary a framework styled in tokens
|
|
143
|
+
* already speaks, and it means a manifest that numbers nothing is untouched.
|
|
144
|
+
*
|
|
145
|
+
* A duplicate group id is not an error here: the first declaration wins and the second is
|
|
146
|
+
* inert, so this function stays total and a render can never throw. `buildAppRouter` refuses the
|
|
147
|
+
* duplicate at composition time instead, where an authoring error belongs and where it can be
|
|
148
|
+
* reported once rather than on every frame.
|
|
149
|
+
*/
|
|
150
|
+
export function groupNav(
|
|
151
|
+
items: readonly NavItem[],
|
|
152
|
+
groups: readonly NavGroup[] = [],
|
|
153
|
+
): NavSection[] {
|
|
154
|
+
// First declaration wins. A Map also gives the declared ids an O(1) membership test, which is
|
|
155
|
+
// what separates "this item names a real group" from "this item falls open".
|
|
156
|
+
const declared = new Map<string, NavGroup>();
|
|
157
|
+
for (const group of groups) {
|
|
158
|
+
if (!declared.has(group.id)) {
|
|
159
|
+
declared.set(group.id, group);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `null` keys the default bucket. It cannot collide with a declared id: an id is a string, and
|
|
164
|
+
// the empty string — which is a legal id and would collide with any falsy sentinel — is a
|
|
165
|
+
// distinct Map key from null.
|
|
166
|
+
const buckets = new Map<string | null, NavItem[]>([[null, []]]);
|
|
167
|
+
for (const id of declared.keys()) {
|
|
168
|
+
buckets.set(id, []);
|
|
169
|
+
}
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
const key = item.group !== undefined && declared.has(item.group) ? item.group : null;
|
|
172
|
+
buckets.get(key)!.push(item);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sections: NavSection[] = [];
|
|
176
|
+
for (const group of byOrder([...declared.values()])) {
|
|
177
|
+
const bucket = buckets.get(group.id)!;
|
|
178
|
+
if (bucket.length > 0) {
|
|
179
|
+
sections.push({ id: group.id, label: group.label, items: byOrder(bucket) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const ungrouped = buckets.get(null)!;
|
|
183
|
+
if (ungrouped.length > 0) {
|
|
184
|
+
sections.push({ id: null, label: null, items: byOrder(ungrouped) });
|
|
185
|
+
}
|
|
186
|
+
return sections;
|
|
13
187
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { activeNavPath, isNavItemActive } from "./navActive";
|
|
4
|
+
|
|
5
|
+
// The predicate the sidebar and ModuleNav share (ADR 0097 §6, amended in 4e).
|
|
6
|
+
//
|
|
7
|
+
// Every case here is a URL shape where something in the framework got it wrong, or would have.
|
|
8
|
+
// The set-level ones are the point of the file: `activeNavPath` exists because "at most one item
|
|
9
|
+
// is current" cannot be decided one link at a time, and three of the four cases below are
|
|
10
|
+
// invisible to a per-item test.
|
|
11
|
+
|
|
12
|
+
describe("isNavItemActive", () => {
|
|
13
|
+
it("matches a segment-aligned prefix, not a string prefix", () => {
|
|
14
|
+
expect(isNavItemActive("/settings/users", "/settings")).toBe(true);
|
|
15
|
+
// The guard that matters: /settings-users starts with /settings as a STRING.
|
|
16
|
+
expect(isNavItemActive("/settings-users", "/settings")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("normalises a trailing slash on both operands, in both branches", () => {
|
|
20
|
+
// The exact branch had this from the start; the prefix branch did not, and an earlier draft
|
|
21
|
+
// of the rule attached the normalisation to `exact` alone.
|
|
22
|
+
expect(isNavItemActive("/settings/", "/settings")).toBe(true);
|
|
23
|
+
expect(isNavItemActive("/settings", "/settings/")).toBe(true);
|
|
24
|
+
expect(isNavItemActive("/settings/", "/settings", true)).toBe(true);
|
|
25
|
+
expect(isNavItemActive("/settings", "/settings/", true)).toBe(true);
|
|
26
|
+
// ...and the root is not normalised away to the empty string.
|
|
27
|
+
expect(isNavItemActive("/", "/")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("ignores the query string and the hash", () => {
|
|
31
|
+
// A nav tab's identity is its path. Filtering a list must not unhighlight the tab the user
|
|
32
|
+
// is standing on, which is what the router does on an exact link with includeSearch left at
|
|
33
|
+
// its default.
|
|
34
|
+
expect(isNavItemActive("/records", "/records")).toBe(true);
|
|
35
|
+
expect(isNavItemActive("/records", "/records", true)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("treats a `to` that is not an absolute path as inactive", () => {
|
|
39
|
+
// Not tidiness. The router resolves a relative or empty `to` against the CURRENT location,
|
|
40
|
+
// so `to: ""` is a link to wherever you already are — and a raw prefix test would light
|
|
41
|
+
// every item at once, because every path starts with the empty string.
|
|
42
|
+
expect(isNavItemActive("/records", "")).toBe(false);
|
|
43
|
+
expect(isNavItemActive("/records", "records")).toBe(false);
|
|
44
|
+
expect(isNavItemActive("/records", "./records")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("exact refuses the children a prefix would take", () => {
|
|
48
|
+
expect(isNavItemActive("/settings/users", "/settings", true)).toBe(false);
|
|
49
|
+
expect(isNavItemActive("/settings", "/settings", true)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("lets the root claim nothing but itself, with no rule of its own", () => {
|
|
53
|
+
// `/` prefixes every path as a STRING, which is why the router adapter carried a
|
|
54
|
+
// hand-written `exact: item.to === "/"`. Matching on SEGMENTS makes that unnecessary:
|
|
55
|
+
// `/profile`'s second character is not a slash.
|
|
56
|
+
expect(isNavItemActive("/profile", "/")).toBe(false);
|
|
57
|
+
expect(isNavItemActive("/", "/")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("collapses repeated slashes, which is the one shape the segment test would let through", () => {
|
|
61
|
+
// The gate for the normalisation, and it is the only input that distinguishes it: `/` has
|
|
62
|
+
// length 1, so the boundary test asks whether current[1] is a slash — true for exactly
|
|
63
|
+
// `//foo`. The router never emits that (it collapses on the way in), but `activePath` is a
|
|
64
|
+
// plain string and window.location.pathname is not normalised by anyone.
|
|
65
|
+
expect(isNavItemActive("//foo", "/")).toBe(false);
|
|
66
|
+
expect(activeNavPath("//foo", [{ to: "/" }])).toBeUndefined();
|
|
67
|
+
// ...and a doubled slash deeper in the path resolves to the same item either way.
|
|
68
|
+
expect(isNavItemActive("/settings//users", "/settings")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("activeNavPath", () => {
|
|
73
|
+
const NAV = [{ to: "/" }, { to: "/settings" }, { to: "/settings/users" }];
|
|
74
|
+
|
|
75
|
+
it("returns exactly one item where a per-link predicate returns two", () => {
|
|
76
|
+
// The defect this whole file exists for. Both /settings and /settings/users match
|
|
77
|
+
// /settings/users on their own, so a router — which decides per link — marks both current,
|
|
78
|
+
// paints both, and announces "current page" twice.
|
|
79
|
+
expect(NAV.filter((item) => isNavItemActive("/settings/users", item.to))).toHaveLength(2);
|
|
80
|
+
expect(activeNavPath("/settings/users", NAV)).toBe("/settings/users");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps the parent lit on a URL that is not itself a nav item", () => {
|
|
84
|
+
// The other half, and the reason per-item `exact` is not the fix: turn exact on to stop the
|
|
85
|
+
// case above and this one goes dark, leaving a page with nothing current at all.
|
|
86
|
+
expect(activeNavPath("/settings/appearance", NAV)).toBe("/settings");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not let the root item claim an unrelated page", () => {
|
|
90
|
+
// The example app ships `{ label: "Notes", to: "/" }`, and PROFILE_PATH is mounted by
|
|
91
|
+
// buildAppRouter and is in no manifest's nav. A STRING-prefix longest-match would light
|
|
92
|
+
// "Notes" while the user is on their profile; a segment-aligned one does not.
|
|
93
|
+
expect(activeNavPath("/profile", NAV)).toBeUndefined();
|
|
94
|
+
expect(activeNavPath("/", NAV)).toBe("/");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns undefined when nothing matches", () => {
|
|
98
|
+
expect(activeNavPath("/explorer", [{ to: "/settings" }])).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("honours an item's own exact flag", () => {
|
|
102
|
+
const nav = [{ to: "/settings", exact: true }, { to: "/records" }];
|
|
103
|
+
expect(activeNavPath("/settings/users", nav)).toBeUndefined();
|
|
104
|
+
expect(activeNavPath("/settings", nav)).toBe("/settings");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is not fooled by declaration order", () => {
|
|
108
|
+
// Longest wins, not first — otherwise the answer depends on `import.meta.glob` key order,
|
|
109
|
+
// which is the same accident 0097 names as the reason nav needs explicit ordering at all.
|
|
110
|
+
const shallowFirst = [{ to: "/settings" }, { to: "/settings/users" }];
|
|
111
|
+
const deepFirst = [{ to: "/settings/users" }, { to: "/settings" }];
|
|
112
|
+
expect(activeNavPath("/settings/users", shallowFirst)).toBe("/settings/users");
|
|
113
|
+
expect(activeNavPath("/settings/users", deepFirst)).toBe("/settings/users");
|
|
114
|
+
});
|
|
115
|
+
});
|