@terpjs/react-core 0.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/README.md +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { CSSProperties, FormEvent } from "react";
|
|
3
|
+
|
|
4
|
+
import { TerpMark } from "./icons";
|
|
5
|
+
import { useAuth, useSso } from "./TerpProvider";
|
|
6
|
+
import { useStrings } from "./uiText";
|
|
7
|
+
import { Button } from "./ui/Button";
|
|
8
|
+
import { Input } from "./ui/Input";
|
|
9
|
+
import type { SsoProvider } from "./sso";
|
|
10
|
+
|
|
11
|
+
const pageStyle: CSSProperties = {
|
|
12
|
+
minHeight: "100vh",
|
|
13
|
+
display: "grid",
|
|
14
|
+
placeItems: "center",
|
|
15
|
+
padding: "var(--space-6)",
|
|
16
|
+
background: "var(--color-neutral-50)",
|
|
17
|
+
fontFamily: "var(--font-family-sans)",
|
|
18
|
+
color: "var(--color-neutral-900)",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const cardStyle: CSSProperties = {
|
|
22
|
+
width: "100%",
|
|
23
|
+
maxWidth: "24rem",
|
|
24
|
+
display: "grid",
|
|
25
|
+
gap: "var(--space-4)",
|
|
26
|
+
padding: "var(--space-6)",
|
|
27
|
+
background: "var(--color-neutral-0)",
|
|
28
|
+
border: "1px solid var(--color-neutral-200)",
|
|
29
|
+
borderRadius: "var(--radius-lg)",
|
|
30
|
+
boxShadow: "var(--shadow-md)",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const brandStyle: CSSProperties = {
|
|
34
|
+
display: "flex",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
gap: "var(--space-2)",
|
|
37
|
+
color: "var(--color-neutral-900)",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const brandTitleStyle: CSSProperties = {
|
|
41
|
+
margin: 0,
|
|
42
|
+
fontSize: "var(--font-size-xl)",
|
|
43
|
+
fontWeight: "var(--font-weight-bold)" as CSSProperties["fontWeight"],
|
|
44
|
+
letterSpacing: 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const formStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
|
|
48
|
+
|
|
49
|
+
const fullWidthStyle: CSSProperties = { width: "100%" };
|
|
50
|
+
|
|
51
|
+
const separatorStyle: CSSProperties = {
|
|
52
|
+
display: "flex",
|
|
53
|
+
alignItems: "center",
|
|
54
|
+
gap: "var(--space-2)",
|
|
55
|
+
color: "var(--color-neutral-500)",
|
|
56
|
+
fontSize: "var(--font-size-xs)",
|
|
57
|
+
textTransform: "uppercase",
|
|
58
|
+
letterSpacing: "0.06em",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const separatorRuleStyle: CSSProperties = {
|
|
62
|
+
flex: 1,
|
|
63
|
+
borderTop: "1px solid var(--color-neutral-200)",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const errorStyle: CSSProperties = {
|
|
67
|
+
margin: 0,
|
|
68
|
+
color: "var(--color-status-danger)",
|
|
69
|
+
fontSize: "var(--font-size-sm)",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface LoginViewProps {
|
|
73
|
+
/**
|
|
74
|
+
* SSO providers to offer alongside the credentials form (ADR 0058). Each renders a
|
|
75
|
+
* "Continue with {label}" button that opens the provider flow through the session's
|
|
76
|
+
* SSO seam; the redirect/callback completion is handled by `TerpProvider`.
|
|
77
|
+
*/
|
|
78
|
+
ssoProviders?: readonly SsoProvider[];
|
|
79
|
+
/**
|
|
80
|
+
* Development-only convenience: when set, the form offers a button that fills these
|
|
81
|
+
* credentials (typically the seeded dev admin). Gate it on the build, e.g.
|
|
82
|
+
* `import.meta.env.DEV ? { email, password } : undefined`, so the credentials are
|
|
83
|
+
* statically stripped from production bundles — never pass real secrets.
|
|
84
|
+
*/
|
|
85
|
+
devCredentials?: DevCredentials;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The credentials the dev-only fill button enters — see {@link LoginViewProps.devCredentials}. */
|
|
89
|
+
export interface DevCredentials {
|
|
90
|
+
email: string;
|
|
91
|
+
password: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The default signed-out screen: collects credentials and calls the auth session. Used by
|
|
96
|
+
* {@link renderTerpApp}/`RequireAuth` unless an app supplies its own login view. Pass
|
|
97
|
+
* {@link LoginViewProps.ssoProviders} to add SSO provider buttons under the form.
|
|
98
|
+
*/
|
|
99
|
+
export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
|
|
100
|
+
const auth = useAuth();
|
|
101
|
+
const sso = useSso();
|
|
102
|
+
const strings = useStrings();
|
|
103
|
+
const [email, setEmail] = useState("");
|
|
104
|
+
const [password, setPassword] = useState("");
|
|
105
|
+
const [error, setError] = useState<string | null>(null);
|
|
106
|
+
const [busy, setBusy] = useState(false);
|
|
107
|
+
|
|
108
|
+
async function onSubmit(event: FormEvent) {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
setError(null);
|
|
111
|
+
setBusy(true);
|
|
112
|
+
try {
|
|
113
|
+
await auth.login({ email, password });
|
|
114
|
+
} catch {
|
|
115
|
+
setError(strings.signInFailed);
|
|
116
|
+
} finally {
|
|
117
|
+
setBusy(false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function onSso(provider: SsoProvider) {
|
|
122
|
+
setError(null);
|
|
123
|
+
setBusy(true);
|
|
124
|
+
try {
|
|
125
|
+
// Navigates away on success; only a failed authorize fetch reaches the catch.
|
|
126
|
+
await sso.begin(provider.name);
|
|
127
|
+
} catch {
|
|
128
|
+
setError(strings.ssoFailed);
|
|
129
|
+
setBusy(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ssoError = sso.error !== null && sso.error !== undefined ? strings.ssoFailed : null;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<main style={pageStyle}>
|
|
137
|
+
<div style={cardStyle}>
|
|
138
|
+
<div style={brandStyle}>
|
|
139
|
+
<TerpMark />
|
|
140
|
+
<h1 style={brandTitleStyle}>{strings.signIn}</h1>
|
|
141
|
+
</div>
|
|
142
|
+
<form style={formStyle} onSubmit={onSubmit}>
|
|
143
|
+
<Input
|
|
144
|
+
type="email"
|
|
145
|
+
placeholder={strings.email}
|
|
146
|
+
value={email}
|
|
147
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
148
|
+
required
|
|
149
|
+
/>
|
|
150
|
+
<Input
|
|
151
|
+
type="password"
|
|
152
|
+
placeholder={strings.password}
|
|
153
|
+
value={password}
|
|
154
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
155
|
+
required
|
|
156
|
+
/>
|
|
157
|
+
<Button type="submit" disabled={busy} style={fullWidthStyle}>
|
|
158
|
+
{busy ? strings.signingIn : strings.signIn}
|
|
159
|
+
</Button>
|
|
160
|
+
{devCredentials ? (
|
|
161
|
+
<Button
|
|
162
|
+
type="button"
|
|
163
|
+
variant="secondary"
|
|
164
|
+
disabled={busy}
|
|
165
|
+
style={fullWidthStyle}
|
|
166
|
+
onClick={() => {
|
|
167
|
+
setEmail(devCredentials.email);
|
|
168
|
+
setPassword(devCredentials.password);
|
|
169
|
+
setError(null);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{strings.fillDevCredentials}
|
|
173
|
+
</Button>
|
|
174
|
+
) : null}
|
|
175
|
+
</form>
|
|
176
|
+
{ssoProviders.length > 0 ? (
|
|
177
|
+
<>
|
|
178
|
+
<div style={separatorStyle} aria-hidden="true">
|
|
179
|
+
<span style={separatorRuleStyle} />
|
|
180
|
+
<span>{strings.orSeparator}</span>
|
|
181
|
+
<span style={separatorRuleStyle} />
|
|
182
|
+
</div>
|
|
183
|
+
<div style={formStyle}>
|
|
184
|
+
{ssoProviders.map((provider) => (
|
|
185
|
+
<Button
|
|
186
|
+
key={provider.name}
|
|
187
|
+
type="button"
|
|
188
|
+
variant="secondary"
|
|
189
|
+
disabled={busy}
|
|
190
|
+
style={fullWidthStyle}
|
|
191
|
+
onClick={() => void onSso(provider)}
|
|
192
|
+
>
|
|
193
|
+
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
194
|
+
</Button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</>
|
|
198
|
+
) : null}
|
|
199
|
+
{error ?? ssoError ? <p style={errorStyle}>{error ?? ssoError}</p> : null}
|
|
200
|
+
</div>
|
|
201
|
+
</main>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
Outlet,
|
|
4
|
+
RouterProvider,
|
|
5
|
+
createMemoryHistory,
|
|
6
|
+
createRootRoute,
|
|
7
|
+
createRoute,
|
|
8
|
+
createRouter,
|
|
9
|
+
} from "@tanstack/react-router";
|
|
10
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { ModuleNav } from "./ModuleNav";
|
|
14
|
+
import { UiTextProvider } from "./uiText";
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup);
|
|
17
|
+
|
|
18
|
+
function renderWithRouter(initialPath: string) {
|
|
19
|
+
const rootRoute = createRootRoute({
|
|
20
|
+
component: () => (
|
|
21
|
+
<>
|
|
22
|
+
<ModuleNav
|
|
23
|
+
items={[
|
|
24
|
+
{ label: "Overview", to: "/tickets" },
|
|
25
|
+
{ label: "Projects", to: "/tickets/projects" },
|
|
26
|
+
]}
|
|
27
|
+
/>
|
|
28
|
+
<Outlet />
|
|
29
|
+
</>
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
const overviewRoute = createRoute({
|
|
33
|
+
getParentRoute: () => rootRoute,
|
|
34
|
+
path: "/tickets",
|
|
35
|
+
component: () => <p>Overview page</p>,
|
|
36
|
+
});
|
|
37
|
+
const projectsRoute = createRoute({
|
|
38
|
+
getParentRoute: () => rootRoute,
|
|
39
|
+
path: "/tickets/projects",
|
|
40
|
+
component: () => <p>Projects page</p>,
|
|
41
|
+
});
|
|
42
|
+
const router = createRouter({
|
|
43
|
+
routeTree: rootRoute.addChildren([overviewRoute, projectsRoute]),
|
|
44
|
+
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
render(<RouterProvider router={router} />);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("ModuleNav", () => {
|
|
51
|
+
it("renders exact-route links and marks the active route", async () => {
|
|
52
|
+
renderWithRouter("/tickets/projects");
|
|
53
|
+
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(screen.getByRole("navigation", { name: "Module navigation" })).toBeInTheDocument(),
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("href", "/tickets");
|
|
58
|
+
expect(screen.getByRole("link", { name: "Projects" })).toHaveAttribute(
|
|
59
|
+
"aria-current",
|
|
60
|
+
"page",
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns nothing for an empty tab list", () => {
|
|
66
|
+
const rootRoute = createRootRoute({ component: () => <ModuleNav items={[]} /> });
|
|
67
|
+
const router = createRouter({
|
|
68
|
+
routeTree: rootRoute,
|
|
69
|
+
history: createMemoryHistory({ initialEntries: ["/"] }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const { container } = render(<RouterProvider router={router} />);
|
|
73
|
+
|
|
74
|
+
expect(container).toBeEmptyDOMElement();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("localises the navigation landmark label", async () => {
|
|
78
|
+
const rootRoute = createRootRoute({
|
|
79
|
+
component: () => (
|
|
80
|
+
<UiTextProvider strings={{ moduleNavigationLabel: "Module navigatie" }}>
|
|
81
|
+
<ModuleNav items={[{ label: "Overzicht", to: "/" }]} />
|
|
82
|
+
</UiTextProvider>
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
const router = createRouter({
|
|
86
|
+
routeTree: rootRoute,
|
|
87
|
+
history: createMemoryHistory({ initialEntries: ["/"] }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
render(<RouterProvider router={router} />);
|
|
91
|
+
|
|
92
|
+
await waitFor(() =>
|
|
93
|
+
expect(screen.getByRole("navigation", { name: "Module navigatie" })).toBeInTheDocument(),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
import { useStrings, useUiText } from "./uiText";
|
|
5
|
+
import type { UiText } from "./uiText";
|
|
6
|
+
|
|
7
|
+
export interface ModuleNavTab {
|
|
8
|
+
/** Display label. */
|
|
9
|
+
label: UiText;
|
|
10
|
+
/** Router path for an exact sub-page route. */
|
|
11
|
+
to: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ModuleNavProps {
|
|
15
|
+
items: readonly ModuleNavTab[];
|
|
16
|
+
/** Accessible label for the secondary navigation landmark. */
|
|
17
|
+
ariaLabel?: UiText;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const navStyle: CSSProperties = {
|
|
21
|
+
borderBottom: "1px solid var(--color-neutral-200)",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const listStyle: CSSProperties = {
|
|
25
|
+
listStyle: "none",
|
|
26
|
+
margin: 0,
|
|
27
|
+
padding: 0,
|
|
28
|
+
display: "flex",
|
|
29
|
+
flexWrap: "wrap",
|
|
30
|
+
gap: "var(--space-3)",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const linkStyle: CSSProperties = {
|
|
34
|
+
display: "inline-flex",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
padding: "var(--space-2) 0",
|
|
37
|
+
color: "var(--color-neutral-600)",
|
|
38
|
+
textDecoration: "none",
|
|
39
|
+
borderBottom: "2px solid transparent",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const activeLinkStyle: CSSProperties = {
|
|
43
|
+
color: "var(--color-neutral-900)",
|
|
44
|
+
borderBottomColor: "var(--color-brand-primary)",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Secondary horizontal navigation for intra-module sub-pages.
|
|
49
|
+
*
|
|
50
|
+
* Each tab links to a real TanStack Router route so sub-pages keep their own URL,
|
|
51
|
+
* lazy loading, and back-button behavior. The active tab is matched exactly.
|
|
52
|
+
*/
|
|
53
|
+
export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
54
|
+
const strings = useStrings();
|
|
55
|
+
const resolve = useUiText();
|
|
56
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
57
|
+
|
|
58
|
+
if (items.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<nav
|
|
64
|
+
aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)}
|
|
65
|
+
data-terp="module-nav"
|
|
66
|
+
style={navStyle}
|
|
67
|
+
>
|
|
68
|
+
<ul style={listStyle}>
|
|
69
|
+
{items.map((item) => {
|
|
70
|
+
const label = resolve(item.label);
|
|
71
|
+
const isActive = pathname === item.to;
|
|
72
|
+
return (
|
|
73
|
+
<li key={item.to}>
|
|
74
|
+
<Link
|
|
75
|
+
to={item.to}
|
|
76
|
+
activeOptions={{ exact: true }}
|
|
77
|
+
aria-current={isActive ? "page" : undefined}
|
|
78
|
+
style={{ ...linkStyle, ...(isActive ? activeLinkStyle : undefined) }}
|
|
79
|
+
>
|
|
80
|
+
{label}
|
|
81
|
+
</Link>
|
|
82
|
+
</li>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</ul>
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { BreadcrumbItem } from "./Breadcrumbs";
|
|
4
|
+
import { Page } from "./Page";
|
|
5
|
+
import type { PageProps } from "./Page";
|
|
6
|
+
import { LayoutSlotContext } from "./layoutContract";
|
|
7
|
+
|
|
8
|
+
export interface OverviewPageProps extends Omit<PageProps, "breadcrumbs"> {
|
|
9
|
+
/** Optional parent layers for an overview nested below a hub or another overview. */
|
|
10
|
+
parents?: readonly (BreadcrumbItem & { to: string })[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The overview (level-2) page archetype: a module's top-level listing screen. It is a `Page`
|
|
15
|
+
* without a redundant current-page-only crumb; detail pages under it link back here — so every
|
|
16
|
+
* module's overview is constructed the same. Compose the body from `ResourceList` (or any listing UI).
|
|
17
|
+
* With a layout contract active (ADR 0079), the body slot accepts only the contract's
|
|
18
|
+
* listing components (e.g. `DataView` / `ResourceList`) — refused fail closed otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export function OverviewPage({ parents, ...page }: OverviewPageProps): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<LayoutSlotContext.Provider value="OverviewPage">
|
|
23
|
+
<Page breadcrumbs={parents} {...page} />
|
|
24
|
+
</LayoutSlotContext.Provider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { DetailPage } from "./DetailPage";
|
|
6
|
+
import { OverviewPage } from "./OverviewPage";
|
|
7
|
+
import { Page } from "./Page";
|
|
8
|
+
import { ApiError } from "./unwrap";
|
|
9
|
+
import { Button } from "./ui/Button";
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
describe("Page", () => {
|
|
14
|
+
it("renders the h1 title, the actions slot, and the body", () => {
|
|
15
|
+
render(
|
|
16
|
+
<Page title="Tasks" actions={<Button>New</Button>}>
|
|
17
|
+
<p>body</p>
|
|
18
|
+
</Page>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toHaveStyle({
|
|
23
|
+
fontSize: "var(--font-size-lg)",
|
|
24
|
+
letterSpacing: "0",
|
|
25
|
+
});
|
|
26
|
+
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
27
|
+
const title = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
28
|
+
const action = screen.getByRole("button", { name: "New" });
|
|
29
|
+
expect(title.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
30
|
+
expect(screen.getByText("body")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not repeat a root page title as a current-page-only breadcrumb", () => {
|
|
34
|
+
render(<Page title="Tasks">x</Page>);
|
|
35
|
+
|
|
36
|
+
expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
|
|
37
|
+
expect(screen.getAllByText("Tasks")).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("appends its own crumb to the supplied trail", () => {
|
|
41
|
+
render(
|
|
42
|
+
<Page title="Fix the door" breadcrumbs={[{ label: "Tasks", to: "/tasks" }]}>
|
|
43
|
+
x
|
|
44
|
+
</Page>,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
49
|
+
expect(screen.getByText("Fix the door", { selector: "span" })).toHaveAttribute(
|
|
50
|
+
"aria-current",
|
|
51
|
+
"page",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("replaces the body with the loading state while keeping the header", () => {
|
|
56
|
+
render(
|
|
57
|
+
<Page title="Tasks" isLoading>
|
|
58
|
+
<p>body</p>
|
|
59
|
+
</Page>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
|
64
|
+
expect(screen.queryByText("body")).not.toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("surfaces an error instead of the body, winning over isLoading", () => {
|
|
68
|
+
render(
|
|
69
|
+
<Page title="Tasks" isLoading error="Not found">
|
|
70
|
+
<p>body</p>
|
|
71
|
+
</Page>,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const alert = screen.getByRole("alert");
|
|
75
|
+
expect(alert).toHaveTextContent("Something went wrong.");
|
|
76
|
+
expect(alert).toHaveTextContent("Not found");
|
|
77
|
+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
|
|
78
|
+
expect(screen.queryByText("body")).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("maps a caught ApiError's stable code to the registered copy", () => {
|
|
82
|
+
render(
|
|
83
|
+
<Page
|
|
84
|
+
title="Tasks"
|
|
85
|
+
error={new ApiError("row was updated concurrently", { code: "stale_data", status: 409 })}
|
|
86
|
+
>
|
|
87
|
+
<p>body</p>
|
|
88
|
+
</Page>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
92
|
+
"This item was changed by someone else. Refresh and try again.",
|
|
93
|
+
);
|
|
94
|
+
expect(screen.queryByText("row was updated concurrently")).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders the custom loading and error slots when supplied", () => {
|
|
98
|
+
const { rerender } = render(
|
|
99
|
+
<Page title="Tasks" isLoading loadingState={<p>spinner</p>}>
|
|
100
|
+
body
|
|
101
|
+
</Page>,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByText("spinner")).toBeInTheDocument();
|
|
104
|
+
|
|
105
|
+
rerender(
|
|
106
|
+
<Page title="Tasks" error="boom" errorState={<p>custom error</p>}>
|
|
107
|
+
body
|
|
108
|
+
</Page>,
|
|
109
|
+
);
|
|
110
|
+
expect(screen.getByText("custom error")).toBeInTheDocument();
|
|
111
|
+
expect(screen.queryByText("boom")).not.toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("OverviewPage", () => {
|
|
116
|
+
it("is a root-level Page with one title and no redundant breadcrumb", () => {
|
|
117
|
+
render(<OverviewPage title="Tasks">list</OverviewPage>);
|
|
118
|
+
|
|
119
|
+
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
120
|
+
expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
|
|
121
|
+
expect(screen.getAllByText("Tasks")).toHaveLength(1);
|
|
122
|
+
expect(screen.getByText("list")).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("DetailPage", () => {
|
|
127
|
+
it("always breadcrumbs back to its parents, ending on the record's crumb", () => {
|
|
128
|
+
render(
|
|
129
|
+
<DetailPage
|
|
130
|
+
title="Fix the door"
|
|
131
|
+
parents={[{ label: "Tasks", to: "/tasks" }]}
|
|
132
|
+
renderLink={(item) => <a href={item.to}>{item.label}</a>}
|
|
133
|
+
>
|
|
134
|
+
detail
|
|
135
|
+
</DetailPage>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
140
|
+
expect(screen.getByRole("heading", { level: 1, name: "Fix the door" })).toBeInTheDocument();
|
|
141
|
+
expect(screen.getByText("Fix the door", { selector: "span" })).toHaveAttribute(
|
|
142
|
+
"aria-current",
|
|
143
|
+
"page",
|
|
144
|
+
);
|
|
145
|
+
expect(screen.getByText("detail")).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
});
|