@terpjs/react-core 0.7.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/PageActions.tsx +5 -10
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +15 -15
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +7 -4
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +354 -25
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +81 -18
- package/src/sso.test.tsx +6 -3
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +855 -6
- package/src/styles.ts +3049 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +19 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/router.test.tsx
CHANGED
|
@@ -5,7 +5,13 @@ import { useEffect, useState } from "react";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { ModuleManifest } from "@terpjs/contract";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildAppRouter,
|
|
10
|
+
useRouteParam,
|
|
11
|
+
useRouteParams,
|
|
12
|
+
useRouteSearch,
|
|
13
|
+
useTerpNavigate,
|
|
14
|
+
} from "./router";
|
|
9
15
|
import { Page } from "./Page";
|
|
10
16
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
11
17
|
|
|
@@ -303,6 +309,190 @@ describe("buildAppRouter", () => {
|
|
|
303
309
|
);
|
|
304
310
|
});
|
|
305
311
|
|
|
312
|
+
it("useRouteSearch reads the route's declared query-string keys, absent ones as undefined", async () => {
|
|
313
|
+
// The hole this closes: a list screen's filters live in the query string, so before
|
|
314
|
+
// search was declarable EVERY filtered screen left the typed seam for the router's own
|
|
315
|
+
// useSearch — losing path and param checking too, on the majority of screens.
|
|
316
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
317
|
+
|
|
318
|
+
function ListView() {
|
|
319
|
+
const { status, page } = useRouteSearch("/records");
|
|
320
|
+
return (
|
|
321
|
+
<Page title="Records">
|
|
322
|
+
<p>{`status=${status ?? "-"} page=${page ?? "-"}`}</p>
|
|
323
|
+
</Page>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
render(
|
|
327
|
+
<TerpProvider baseUrl="https://api.test">
|
|
328
|
+
<LogInOnMount />
|
|
329
|
+
<RouterProvider
|
|
330
|
+
router={buildAppRouter(
|
|
331
|
+
[
|
|
332
|
+
{
|
|
333
|
+
name: "records",
|
|
334
|
+
routes: [{ path: "/records", view: "List", search: ["status", "page"] }],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
{
|
|
338
|
+
views: { List: ListView },
|
|
339
|
+
title: "Terp",
|
|
340
|
+
history: createMemoryHistory({ initialEntries: ["/records?status=open"] }),
|
|
341
|
+
},
|
|
342
|
+
)}
|
|
343
|
+
/>
|
|
344
|
+
</TerpProvider>,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// `status` came from the URL; `page` is declared but unset, which is `undefined` rather
|
|
348
|
+
// than a missing key a screen has to guard.
|
|
349
|
+
expect(await screen.findByText("status=open page=-")).toBeInTheDocument();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("useRouteSearch returns only declared keys, so a stray URL key cannot reach a screen", async () => {
|
|
353
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
354
|
+
|
|
355
|
+
function ListView() {
|
|
356
|
+
const search = useRouteSearch("/records") as Record<string, string | undefined>;
|
|
357
|
+
return <Page title="Records">{`keys=${Object.keys(search).join(",") || "none"}`}</Page>;
|
|
358
|
+
}
|
|
359
|
+
render(
|
|
360
|
+
<TerpProvider baseUrl="https://api.test">
|
|
361
|
+
<LogInOnMount />
|
|
362
|
+
<RouterProvider
|
|
363
|
+
router={buildAppRouter(
|
|
364
|
+
[{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
|
|
365
|
+
{
|
|
366
|
+
views: { List: ListView },
|
|
367
|
+
title: "Terp",
|
|
368
|
+
history: createMemoryHistory({
|
|
369
|
+
initialEntries: ["/records?status=open&smuggled=yes"],
|
|
370
|
+
}),
|
|
371
|
+
},
|
|
372
|
+
)}
|
|
373
|
+
/>
|
|
374
|
+
</TerpProvider>,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
expect(await screen.findByText("keys=status")).toBeInTheDocument();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("useRouteSearch refuses a path the router never mounted, naming what is mounted", async () => {
|
|
381
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
382
|
+
|
|
383
|
+
function ListView() {
|
|
384
|
+
// A silently empty bag would hand the screen `undefined` for every key it asked
|
|
385
|
+
// for, which reads as "no filters applied" — the failure this refusal replaces.
|
|
386
|
+
let message = "no refusal";
|
|
387
|
+
try {
|
|
388
|
+
(useRouteSearch as (path: string) => unknown)("/typo");
|
|
389
|
+
} catch (error) {
|
|
390
|
+
message = (error as Error).message;
|
|
391
|
+
}
|
|
392
|
+
return <Page title="Records">{message}</Page>;
|
|
393
|
+
}
|
|
394
|
+
render(
|
|
395
|
+
<TerpProvider baseUrl="https://api.test">
|
|
396
|
+
<LogInOnMount />
|
|
397
|
+
<RouterProvider
|
|
398
|
+
router={buildAppRouter([{ name: "records", routes: [{ path: "/records", view: "List" }] }], {
|
|
399
|
+
views: { List: ListView },
|
|
400
|
+
title: "Terp",
|
|
401
|
+
history: createMemoryHistory({ initialEntries: ["/records"] }),
|
|
402
|
+
})}
|
|
403
|
+
/>
|
|
404
|
+
</TerpProvider>,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(await screen.findByText(/is not a mounted route/)).toBeInTheDocument();
|
|
408
|
+
expect(screen.getByText(/mounted: \/records/)).toBeInTheDocument();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("useTerpNavigate carries search onto the URL, and clearing a key removes it", async () => {
|
|
412
|
+
// Replace, not merge (ADR 0096): clearing a filter means sending the key as
|
|
413
|
+
// undefined, and a merge would keep the old value — so "clear" would not clear.
|
|
414
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
415
|
+
|
|
416
|
+
function ListView() {
|
|
417
|
+
const navigate = useTerpNavigate();
|
|
418
|
+
const { status } = useRouteSearch("/records");
|
|
419
|
+
return (
|
|
420
|
+
<Page title="Records">
|
|
421
|
+
<p>{`status=${status ?? "-"}`}</p>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => void navigate({ to: "/records", search: { status: "open" } })}
|
|
425
|
+
>
|
|
426
|
+
filter open
|
|
427
|
+
</button>
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
onClick={() => void navigate({ to: "/records", search: { status: undefined } })}
|
|
431
|
+
>
|
|
432
|
+
clear
|
|
433
|
+
</button>
|
|
434
|
+
</Page>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
render(
|
|
438
|
+
<TerpProvider baseUrl="https://api.test">
|
|
439
|
+
<LogInOnMount />
|
|
440
|
+
<RouterProvider
|
|
441
|
+
router={buildAppRouter(
|
|
442
|
+
[{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
|
|
443
|
+
{
|
|
444
|
+
views: { List: ListView },
|
|
445
|
+
title: "Terp",
|
|
446
|
+
history: createMemoryHistory({ initialEntries: ["/records"] }),
|
|
447
|
+
},
|
|
448
|
+
)}
|
|
449
|
+
/>
|
|
450
|
+
</TerpProvider>,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
fireEvent.click(await screen.findByRole("button", { name: "filter open" }));
|
|
454
|
+
await waitFor(() => expect(screen.getByText("status=open")).toBeInTheDocument());
|
|
455
|
+
|
|
456
|
+
fireEvent.click(screen.getByRole("button", { name: "clear" }));
|
|
457
|
+
await waitFor(() => expect(screen.getByText("status=-")).toBeInTheDocument());
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("a second composed router does not inherit the first one's declared searches", async () => {
|
|
461
|
+
// The declarations are published per router through a context, not a module-level
|
|
462
|
+
// table: a shared table would let one app embedding another (or one test process
|
|
463
|
+
// composing two) read routes it never mounted.
|
|
464
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
465
|
+
buildAppRouter([{ name: "a", routes: [{ path: "/a", view: "V", search: ["x"] }] }], {
|
|
466
|
+
views: { V: () => <Page title="A">a</Page> },
|
|
467
|
+
title: "Terp",
|
|
468
|
+
history: createMemoryHistory({ initialEntries: ["/a"] }),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
function BView() {
|
|
472
|
+
let message = "no refusal";
|
|
473
|
+
try {
|
|
474
|
+
(useRouteSearch as (path: string) => unknown)("/a");
|
|
475
|
+
} catch (error) {
|
|
476
|
+
message = (error as Error).message;
|
|
477
|
+
}
|
|
478
|
+
return <Page title="B">{message}</Page>;
|
|
479
|
+
}
|
|
480
|
+
render(
|
|
481
|
+
<TerpProvider baseUrl="https://api.test">
|
|
482
|
+
<LogInOnMount />
|
|
483
|
+
<RouterProvider
|
|
484
|
+
router={buildAppRouter([{ name: "b", routes: [{ path: "/b", view: "V" }] }], {
|
|
485
|
+
views: { V: BView },
|
|
486
|
+
title: "Terp",
|
|
487
|
+
history: createMemoryHistory({ initialEntries: ["/b"] }),
|
|
488
|
+
})}
|
|
489
|
+
/>
|
|
490
|
+
</TerpProvider>,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
expect(await screen.findByText(/"\/a" is not a mounted route/)).toBeInTheDocument();
|
|
494
|
+
});
|
|
495
|
+
|
|
306
496
|
it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
|
|
307
497
|
// A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
|
|
308
498
|
// page reload, silently, with nothing to catch it. Inside a Terp router the default
|
package/src/router.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useNavigate,
|
|
8
8
|
useParams,
|
|
9
9
|
useRouter,
|
|
10
|
+
useSearch,
|
|
10
11
|
type AnyRoute,
|
|
11
12
|
type RouterHistory,
|
|
12
13
|
} from "@tanstack/react-router";
|
|
@@ -21,11 +22,18 @@ import type {
|
|
|
21
22
|
TerpRouteParamName,
|
|
22
23
|
TerpRouteParams,
|
|
23
24
|
TerpRoutePath,
|
|
25
|
+
TerpRouteSearch,
|
|
24
26
|
} from "./routeTypes";
|
|
25
27
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
26
28
|
import { visibleNav } from "./nav";
|
|
27
29
|
import { NavLinkContext } from "./navLink";
|
|
28
30
|
import { PageMarkerContext } from "./pageMarker";
|
|
31
|
+
import {
|
|
32
|
+
RouteSearchContext,
|
|
33
|
+
declaredSearchKeys,
|
|
34
|
+
indexSearchKeys,
|
|
35
|
+
useRouteSearchIndex,
|
|
36
|
+
} from "./routeSearch";
|
|
29
37
|
import { useAuth } from "./TerpProvider";
|
|
30
38
|
import { UserMenu } from "./UserMenu";
|
|
31
39
|
import { useStrings } from "./uiText";
|
|
@@ -169,9 +177,56 @@ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void>
|
|
|
169
177
|
// runtime TanStack types `params` as a reducer (or `true`), and merging over the
|
|
170
178
|
// previous params is also the honest semantic for an in-place param change.
|
|
171
179
|
params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
|
|
180
|
+
// Search is REPLACED, not merged (ADR 0096). Merging reads as convenient and is the
|
|
181
|
+
// wrong default for the case this exists to serve: clearing a filter means sending
|
|
182
|
+
// the key as undefined, and a merge would keep the old value instead — so "clear"
|
|
183
|
+
// would silently not clear. A screen that wants to keep other keys passes them,
|
|
184
|
+
// which is also the only form that stays checkable against the declared key set.
|
|
185
|
+
search: dropUndefined(target.search),
|
|
172
186
|
});
|
|
173
187
|
}
|
|
174
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Drop `undefined` values so a cleared filter leaves the URL instead of appearing as
|
|
191
|
+
* `?status=undefined`, and an all-cleared search yields a bare path.
|
|
192
|
+
*/
|
|
193
|
+
function dropUndefined(
|
|
194
|
+
search: Record<string, string | undefined> | undefined,
|
|
195
|
+
): Record<string, string> {
|
|
196
|
+
const kept: Record<string, string> = {};
|
|
197
|
+
for (const [key, value] of Object.entries(search ?? {})) {
|
|
198
|
+
if (value !== undefined) {
|
|
199
|
+
kept[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return kept;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read the current route's declared query-string keys (ADR 0096).
|
|
207
|
+
*
|
|
208
|
+
* ```tsx
|
|
209
|
+
* const { status, page } = useRouteSearch("/records");
|
|
210
|
+
* ```
|
|
211
|
+
*
|
|
212
|
+
* Every key is `string | undefined`, because a query parameter is text and is absent
|
|
213
|
+
* until someone sets it — so a screen destructures with defaults rather than branching on
|
|
214
|
+
* a bag of `unknown`. Reading a key the route did not declare is a typecheck error once
|
|
215
|
+
* `terp routes` has generated; before that the shape is loose, exactly like the params
|
|
216
|
+
* helpers. Undeclared keys present in the URL are **not** returned: the declaration is the
|
|
217
|
+
* surface, so a stray key someone hand-typed cannot leak into a screen's logic.
|
|
218
|
+
*/
|
|
219
|
+
export function useRouteSearch<P extends TerpRoutePath>(path: P): TerpRouteSearch<P> {
|
|
220
|
+
const search = useSearch({ strict: false }) as Record<string, unknown>;
|
|
221
|
+
const declared = declaredSearchKeys(useRouteSearchIndex(), path);
|
|
222
|
+
const resolved: Record<string, string | undefined> = {};
|
|
223
|
+
for (const name of declared) {
|
|
224
|
+
const value = search[name];
|
|
225
|
+
resolved[name] = typeof value === "string" ? value : undefined;
|
|
226
|
+
}
|
|
227
|
+
return resolved as TerpRouteSearch<P>;
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
export interface BuildAppRouterOptions {
|
|
176
231
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
177
232
|
views: Record<string, ComponentType>;
|
|
@@ -251,6 +306,15 @@ export function buildAppRouter(
|
|
|
251
306
|
);
|
|
252
307
|
}
|
|
253
308
|
|
|
309
|
+
// The runtime half of the search declaration (ADR 0096): the generated table is types
|
|
310
|
+
// only, so `useRouteSearch` reads the keys from the manifests this router was built from,
|
|
311
|
+
// published per router through a context (never a module-level table, which every router
|
|
312
|
+
// in the process would share).
|
|
313
|
+
const searchKeys = new Map(indexSearchKeys(manifests));
|
|
314
|
+
if (!searchKeys.has(PROFILE_PATH)) {
|
|
315
|
+
searchKeys.set(PROFILE_PATH, []);
|
|
316
|
+
}
|
|
317
|
+
|
|
254
318
|
function Shell() {
|
|
255
319
|
const router = useRouter();
|
|
256
320
|
const rank = useAuth().currentUser()?.role_rank ?? null;
|
|
@@ -265,18 +329,15 @@ export function buildAppRouter(
|
|
|
265
329
|
logo={options.logo}
|
|
266
330
|
footer={options.footer}
|
|
267
331
|
nav={nav}
|
|
268
|
-
renderBrandLink={({ to, children
|
|
269
|
-
<Link to={to} data-terp="appshell-brand"
|
|
332
|
+
renderBrandLink={({ to, children }) => (
|
|
333
|
+
<Link to={to} data-terp="appshell-brand">
|
|
270
334
|
{children}
|
|
271
335
|
</Link>
|
|
272
336
|
)}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
activeProps={{ style: { ...context.style, ...context.activeStyle } }}
|
|
278
|
-
activeOptions={{ exact: item.to === "/" }}
|
|
279
|
-
>
|
|
337
|
+
// No style objects and no activeProps: the shell's stylesheet owns the link
|
|
338
|
+
// geometry and keys the active route on aria-current="page", which Link sets.
|
|
339
|
+
renderLink={(item, children) => (
|
|
340
|
+
<Link to={item.to} activeOptions={{ exact: item.to === "/" }}>
|
|
280
341
|
{children}
|
|
281
342
|
</Link>
|
|
282
343
|
)}
|
|
@@ -333,15 +394,17 @@ export function buildAppRouter(
|
|
|
333
394
|
return <Unauthorized />;
|
|
334
395
|
}
|
|
335
396
|
return (
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
397
|
+
<RouteSearchContext.Provider value={searchKeys}>
|
|
398
|
+
<LayoutContractContext.Provider value={layoutContract}>
|
|
399
|
+
<PageMarkerContext.Provider
|
|
400
|
+
value={() => {
|
|
401
|
+
marked.current = true;
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<View />
|
|
405
|
+
</PageMarkerContext.Provider>
|
|
406
|
+
</LayoutContractContext.Provider>
|
|
407
|
+
</RouteSearchContext.Provider>
|
|
345
408
|
);
|
|
346
409
|
}
|
|
347
410
|
return createRoute({
|
package/src/sso.test.tsx
CHANGED
|
@@ -120,9 +120,12 @@ describe("SSO login (ADR 0058)", () => {
|
|
|
120
120
|
</TerpProvider>,
|
|
121
121
|
);
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// Announced, not merely displayed. This is the one screen whose user is not signed in
|
|
124
|
+
// yet, so a failure they cannot see leaves them with no signal at all that the sign-in
|
|
125
|
+
// did not happen — the form simply sits there. ResourceList's error has carried
|
|
126
|
+
// role="alert" since it existed; this one carried nothing.
|
|
127
|
+
const failure = await screen.findByRole("alert");
|
|
128
|
+
expect(failure).toHaveTextContent("Single sign-on failed. Try again.");
|
|
126
129
|
expect(window.location.pathname).toBe("/");
|
|
127
130
|
});
|
|
128
131
|
});
|
package/src/ssr.test.tsx
CHANGED
|
@@ -24,9 +24,7 @@ describe("server rendering", () => {
|
|
|
24
24
|
<AppShell
|
|
25
25
|
title="Terp"
|
|
26
26
|
nav={[{ label: "Home", to: "/", icon: "home" }]}
|
|
27
|
-
renderLink={(item, children
|
|
28
|
-
<a href={item.to} style={context.style}>{children}</a>
|
|
29
|
-
)}
|
|
27
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
30
28
|
>
|
|
31
29
|
<p>content</p>
|
|
32
30
|
</AppShell>,
|