@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.
Files changed (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
@@ -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 { buildAppRouter, useRouteParam, useRouteParams, useTerpNavigate } from "./router";
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, style }) => (
269
- <Link to={to} data-terp="appshell-brand" style={style}>
332
+ renderBrandLink={({ to, children }) => (
333
+ <Link to={to} data-terp="appshell-brand">
270
334
  {children}
271
335
  </Link>
272
336
  )}
273
- renderLink={(item, children, context) => (
274
- <Link
275
- to={item.to}
276
- style={context.style}
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
- <LayoutContractContext.Provider value={layoutContract}>
337
- <PageMarkerContext.Provider
338
- value={() => {
339
- marked.current = true;
340
- }}
341
- >
342
- <View />
343
- </PageMarkerContext.Provider>
344
- </LayoutContractContext.Provider>
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
- await waitFor(() =>
124
- expect(screen.getByText("Single sign-on failed. Try again.")).toBeInTheDocument(),
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, context) => (
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>,