@terpjs/react-core 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -0,0 +1,150 @@
1
+ import type { ReactNode } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ import { useLayoutContract, verifySlotChildren } from "./layoutContract";
5
+ import { Page } from "./Page";
6
+ import type { PageProps } from "./Page";
7
+ import { injectTerpStyles } from "./styles";
8
+ import { useUiText } from "./uiText";
9
+ import type { UiText } from "./uiText";
10
+
11
+ injectTerpStyles();
12
+
13
+ /** How much of the row the list pane takes — a step, not a length (ADR 0097 §4). */
14
+ export type SplitListWidth = "sm" | "md" | "lg";
15
+
16
+ export type SplitPageProps = Omit<PageProps, "children" | "measure"> & {
17
+ /** Parent trail for a split screen nested below a hub; aliases `Page`'s `breadcrumbs`. */
18
+ parents?: PageProps["breadcrumbs"];
19
+ /**
20
+ * The list pane's track (default `"md"`).
21
+ *
22
+ * Enumerable rather than a percentage or a length, which is the same call `Grid`'s
23
+ * `minColumn` made and for the same reason: a CSS length here would be a measured value and
24
+ * therefore an inline style on the panes element — a tenth entry in a ledger that admits only
25
+ * two permanent kinds (ADR 0094 §3). Three steps cover the cases; an app wanting 37% cannot
26
+ * have it, which is the trade `gap` already makes.
27
+ *
28
+ * A **draggable** divider is deliberately not offered: that is a measured value *and* a
29
+ * per-user preference, so it waits for the preference seam rather than arriving as an inline
30
+ * style with nowhere to persist.
31
+ */
32
+ listWidth?: SplitListWidth;
33
+ /**
34
+ * The two panes, **list first** — a `SplitPane role="list"` then a `SplitPane role="detail"`.
35
+ *
36
+ * Order is load-bearing: the tracks are filled by grid auto-placement, so the first pane takes
37
+ * the narrow one. The contract can see that both children are `SplitPane`s and cannot see
38
+ * which is which, so this is a convention the render makes obvious rather than a rule the
39
+ * runtime enforces.
40
+ */
41
+ children: ReactNode;
42
+ };
43
+
44
+ /**
45
+ * The split archetype: a list beside the record it selects — the master-detail screen.
46
+ *
47
+ * It is structured on `HubPage`, not on `DetailPage`, and that is the decision worth knowing.
48
+ * The obvious shape for two panes is one body slot holding two children, but the layout
49
+ * contract's runtime check takes a single slot owner and reads `article.children`, so two panes
50
+ * would be two entries in one slot with nothing distinguishing them. Teaching the contract two
51
+ * slots per archetype was the invasive option. Instead the panes are the governed thing:
52
+ * `SplitPage` owns the row element and admits `SplitPane` in it and nothing else, exactly as
53
+ * `HubPage` owns its grid and admits `HubCard`. `verifySlotChildren`, the mirrored table's
54
+ * shape and the message builder are all untouched.
55
+ *
56
+ * It therefore provides **no** `LayoutSlotContext`, for the reason `HubPage` provides none: the
57
+ * row element carries a marker that appears in no allow table, so a slot context above it would
58
+ * refuse every split page on its own body.
59
+ *
60
+ * Below the mobile breakpoint the panes stack, in DOM order, list first — so the tab sequence is
61
+ * the reading order in both layouts. That is the property `visual/keyboard.spec.ts` holds.
62
+ */
63
+ export function SplitPage({
64
+ parents,
65
+ breadcrumbs,
66
+ listWidth = "md",
67
+ children,
68
+ ...page
69
+ }: SplitPageProps) {
70
+ // The runtime half of the slot-typed layout contract control (ADR 0079) for the pane row:
71
+ // with a contract active, every rendered child of the row must be a SplitPane (its data-terp
72
+ // marker) — verified one macrotask after mount, refused fail closed. Same shape as HubPage's.
73
+ const contract = useLayoutContract();
74
+ const panesRef = useRef<HTMLDivElement>(null);
75
+ const [slotViolation, setSlotViolation] = useState<string | null>(null);
76
+ useEffect(() => {
77
+ if (contract === null) {
78
+ return;
79
+ }
80
+ const timer = setTimeout(() => {
81
+ const panes = panesRef.current;
82
+ if (panes === null) {
83
+ return;
84
+ }
85
+ setSlotViolation(verifySlotChildren(contract, "SplitPage", [...panes.children]));
86
+ }, 0);
87
+ return () => clearTimeout(timer);
88
+ });
89
+ if (slotViolation !== null) {
90
+ throw new Error(slotViolation);
91
+ }
92
+ return (
93
+ <Page {...page} breadcrumbs={parents ?? breadcrumbs}>
94
+ <div ref={panesRef} data-terp="splitpage-panes" data-list-width={listWidth}>
95
+ {children}
96
+ </div>
97
+ </Page>
98
+ );
99
+ }
100
+
101
+ /** Which half of the split a pane is — the list, or the record it selects. */
102
+ export type SplitPaneRole = "list" | "detail";
103
+
104
+ export interface SplitPaneProps {
105
+ /**
106
+ * Which half this is.
107
+ *
108
+ * It does **not** place the pane. The row's tracks are `minmax(0, <listWidth>) minmax(0, 1fr)`
109
+ * and grid auto-placement fills them in DOM order, so the FIRST pane gets the narrow track
110
+ * whatever its role says — write the list first. A `detail`-first composition renders the
111
+ * record in the narrow column, which is wrong and immediately visible.
112
+ *
113
+ * Placing by role instead was considered and refused: it would render correctly whatever the
114
+ * DOM order, and correct-looking is exactly the wrong failure here. Tab order follows the DOM
115
+ * and CSS cannot change it, so a `detail`-first tree would then read left-to-right and tab
116
+ * right-to-left — the WCAG 1.3.2 / 2.4.3 mismatch, silent. Leaving placement to the DOM makes
117
+ * a mis-ordered split look mis-ordered.
118
+ *
119
+ * What the role does carry: the pane's identity for anything that needs to tell the two apart
120
+ * (`visual/keyboard.spec.ts` asserts the tab order through it), and a hook for a rule should
121
+ * one ever need to distinguish them.
122
+ */
123
+ role: SplitPaneRole;
124
+ /**
125
+ * The pane's accessible name.
126
+ *
127
+ * Required, because each pane is a `<section>` and therefore a landmark: two unnamed regions
128
+ * side by side are two indistinguishable entries in a screen reader's landmark list, which is
129
+ * worse than one region containing both.
130
+ */
131
+ label: UiText;
132
+ children: ReactNode;
133
+ }
134
+
135
+ /**
136
+ * One half of a {@link SplitPage} — the list, or the detail beside it.
137
+ *
138
+ * A named `<section>`, so the two halves are distinguishable landmarks, and the only component
139
+ * the split's row admits. It renders no inline style, and no style at all beyond a
140
+ * `min-width: 0` floor: which track it takes comes from its POSITION, not from `role` — see the
141
+ * prop for why that is the safer of the two.
142
+ */
143
+ export function SplitPane({ role, label, children }: SplitPaneProps) {
144
+ const resolve = useUiText();
145
+ return (
146
+ <section data-terp="splitpane" data-role={role} aria-label={resolve(label)}>
147
+ {children}
148
+ </section>
149
+ );
150
+ }
@@ -66,7 +66,7 @@ describe("UserMenu", () => {
66
66
  <UserMenu />
67
67
  </TerpProvider>,
68
68
  );
69
- expect(screen.queryByRole("button", { name: "Account menu" })).not.toBeInTheDocument();
69
+ expect(screen.queryByRole("button", { name: /jane\.doe@example\.com/ })).not.toBeInTheDocument();
70
70
  });
71
71
 
72
72
  it("shows the avatar initials, email and role, and opens the panel", async () => {
@@ -81,7 +81,7 @@ describe("UserMenu", () => {
81
81
  </LocaleProvider>
82
82
  </ThemeProvider>,
83
83
  );
84
- const trigger = await screen.findByRole("button", { name: "Account menu" });
84
+ const trigger = await screen.findByRole("button", { name: /jane\.doe@example\.com/ });
85
85
  expect(screen.getByText("JD")).toBeInTheDocument();
86
86
  expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
87
87
  expect(screen.getByText("editor")).toBeInTheDocument();
@@ -104,7 +104,7 @@ describe("UserMenu", () => {
104
104
  <UserMenu onSettings={onSettings} />
105
105
  </TerpProvider>,
106
106
  );
107
- fireEvent.click(await screen.findByRole("button", { name: "Account menu" }));
107
+ fireEvent.click(await screen.findByRole("button", { name: /jane\.doe@example\.com/ }));
108
108
  fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
109
109
  expect(onSettings).toHaveBeenCalledTimes(1);
110
110
  expect(screen.queryByRole("menuitem", { name: "Sign out" })).not.toBeInTheDocument();
@@ -118,6 +118,10 @@ describe("UserMenu", () => {
118
118
  <UserMenu collapsed />
119
119
  </TerpProvider>,
120
120
  );
121
+ // Collapsed is the one variant that still carries `triggerLabel`, because its only
122
+ // content is the aria-hidden avatar — without the label the button would have no
123
+ // accessible name at all. Expanded takes its name from the email and role it renders,
124
+ // which is what the other cases in this file now assert.
121
125
  const trigger = await screen.findByRole("button", { name: "Account menu" });
122
126
  expect(screen.getByText("JD")).toBeInTheDocument();
123
127
  expect(screen.queryByText("jane.doe@example.com")).not.toBeInTheDocument();
@@ -137,6 +141,25 @@ describe("UserMenu", () => {
137
141
  expect(screen.getByRole("menu").parentElement).toHaveAttribute("data-owner", "user-menu");
138
142
  });
139
143
 
144
+ it("keeps the trigger inside its own root when expanded, which is how the sign-out helper finds it", async () => {
145
+ // `@terpjs/conformance`'s `logout()` signs a user out of any Terp app through this button, and
146
+ // located it by the accessible name "Account menu" until that name stopped existing here —
147
+ // expanded, the button is named after the user's email and role on purpose (WCAG 2.5.3). The
148
+ // helper reaches it by marker path now, so the DESCENDANT relationship is a cross-package
149
+ // contract; the collapsed case above already pins the marker itself, and this pins the shape
150
+ // of the path in the state the sidebar is actually in by default.
151
+ stubAuthFetch();
152
+ const { container } = render(
153
+ <TerpProvider baseUrl="https://api.test">
154
+ <LogInOnMount />
155
+ <UserMenu />
156
+ </TerpProvider>,
157
+ );
158
+ await screen.findByText("jane.doe@example.com");
159
+ const trigger = container.querySelector('[data-terp="user-menu"] [data-terp="menu-trigger"]');
160
+ expect(trigger?.tagName).toBe("BUTTON");
161
+ });
162
+
140
163
  it("signs out via the menu (revokes the token server-side)", async () => {
141
164
  const fetchMock = stubAuthFetch();
142
165
  render(
@@ -145,7 +168,7 @@ describe("UserMenu", () => {
145
168
  <UserMenu />
146
169
  </TerpProvider>,
147
170
  );
148
- fireEvent.click(await screen.findByRole("button", { name: "Account menu" }));
171
+ fireEvent.click(await screen.findByRole("button", { name: /jane\.doe@example\.com/ }));
149
172
  fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
150
173
  await waitFor(() =>
151
174
  expect(
@@ -164,7 +187,7 @@ describe("UserMenu", () => {
164
187
  <UserMenu />
165
188
  </TerpProvider>,
166
189
  );
167
- const trigger = await screen.findByRole("button", { name: "Account menu" });
190
+ const trigger = await screen.findByRole("button", { name: /jane\.doe@example\.com/ });
168
191
  fireEvent.click(trigger);
169
192
  expect(screen.getByRole("menuitem", { name: "Sign out" })).toBeInTheDocument();
170
193
  fireEvent.keyDown(document, { key: "Escape" });
package/src/UserMenu.tsx CHANGED
@@ -1,18 +1,18 @@
1
1
  import { Icon } from "./icons";
2
2
  import { injectTerpStyles } from "./styles";
3
3
  import { useAuth } from "./TerpProvider";
4
+ import { Avatar } from "./ui/Avatar";
4
5
  import { Menu, MenuItem } from "./ui/Menu";
5
6
  import { useStrings } from "./uiText";
6
7
 
7
8
  injectTerpStyles();
8
9
 
9
- /** Initials for the avatar: the first letters of the email's local-part words. */
10
- export function userInitials(email: string): string {
11
- const local = email.split("@")[0] ?? "";
12
- const words = local.split(/[._+-]+/).filter((word) => word.length > 0);
13
- const initials = words.slice(0, 2).map((word) => word[0]!.toUpperCase());
14
- return initials.join("") || "?";
15
- }
10
+ /**
11
+ * Re-exported from where the tile now lives. It was public under this name before `Avatar`
12
+ * existed, and moving a published export to another module is a breaking change for no
13
+ * reason the tile is what needed one home, not the name.
14
+ */
15
+ export { userInitials } from "./ui/Avatar";
16
16
 
17
17
  export interface UserMenuProps {
18
18
  /** Icon-rail mode: show only the avatar on the trigger (the shell's collapsed state). */
@@ -39,7 +39,7 @@ export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMen
39
39
 
40
40
  const trigger = (
41
41
  <>
42
- <span aria-hidden="true" data-terp="user-menu-avatar">{userInitials(user.email)}</span>
42
+ <Avatar from={user.email} size="sm" />
43
43
  {!collapsed && (
44
44
  <span data-terp="user-menu-identity">
45
45
  <span data-terp="user-menu-email">{user.email}</span>
@@ -52,7 +52,13 @@ export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMen
52
52
  return (
53
53
  <Menu
54
54
  trigger={trigger}
55
- triggerLabel={strings.accountMenu}
55
+ // Only in the icon rail, and the asymmetry is the point. `aria-label` REPLACES the
56
+ // subtree text in the accessible name, so in the expanded trigger — which renders the
57
+ // user's email and role as visible text — naming it "Account menu" hid both from anyone
58
+ // relying on the name, and left a voice-control user with no spoken label that matches
59
+ // what they can see (WCAG 2.5.3, Label in Name). Collapsed there is nothing to hide: the
60
+ // avatar initials are aria-hidden, so without this the button would have no name at all.
61
+ triggerLabel={collapsed ? strings.accountMenu : undefined}
56
62
  placement="top"
57
63
  align="start"
58
64
  defaultOpen={defaultOpen}
@@ -6,6 +6,8 @@ import { useTerpClient } from "../TerpProvider";
6
6
  import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
7
7
  import type { DataViewColumn } from "../dataview";
8
8
  import { DetailList } from "../layout";
9
+ import { Code } from "../typography";
10
+ import { useFormatDateTime } from "../format";
9
11
  import { useStrings } from "../uiText";
10
12
  import type { TerpStrings } from "../uiText";
11
13
  import { unwrap } from "../unwrap";
@@ -14,20 +16,23 @@ import { adminCrumb, renderAdminCrumb } from "./crumbs";
14
16
 
15
17
  type AuditEventRead = components["schemas"]["AuditEventRead"];
16
18
 
17
- function buildColumns(strings: TerpStrings): DataViewColumn<AuditEventRead>[] {
19
+ function buildColumns(
20
+ strings: TerpStrings,
21
+ formatDateTime: (value: string) => string,
22
+ ): DataViewColumn<AuditEventRead>[] {
18
23
  return [
19
24
  {
20
25
  id: "created_at",
21
26
  header: strings.whenColumn,
22
27
  accessor: (e) => e.created_at,
23
- cell: (e) => new Date(e.created_at).toLocaleString(),
24
- meta: { mobileSlot: "date", width: 170 },
28
+ cell: (e) => formatDateTime(e.created_at),
29
+ meta: { mobileSlot: "date", width: "md" },
25
30
  },
26
31
  {
27
32
  id: "action",
28
33
  header: strings.actionColumn,
29
34
  accessor: (e) => e.action,
30
- meta: { mobileSlot: "status", width: 100 },
35
+ meta: { mobileSlot: "status", width: "xs" },
31
36
  },
32
37
  {
33
38
  id: "target",
@@ -41,7 +46,7 @@ function buildColumns(strings: TerpStrings): DataViewColumn<AuditEventRead>[] {
41
46
  header: strings.actorColumn,
42
47
  accessor: (e) => e.actor_id ?? "",
43
48
  cell: (e) => (e.actor_id === null ? "—" : e.actor_id.slice(0, 8)),
44
- meta: { mobileSlot: "subtitle", width: 110 },
49
+ meta: { mobileSlot: "subtitle", width: "sm" },
45
50
  },
46
51
  ];
47
52
  }
@@ -56,7 +61,11 @@ export function AuditLogAdmin() {
56
61
  const strings = useStrings();
57
62
  const serverQuery = useServerDataView({ initialPageSize: 25 });
58
63
 
59
- const columns = useMemo(() => buildColumns(strings), [strings]);
64
+ const formatDateTime = useFormatDateTime();
65
+ const columns = useMemo(
66
+ () => buildColumns(strings, formatDateTime),
67
+ [strings, formatDateTime],
68
+ );
60
69
  const repository = useMemo(
61
70
  () =>
62
71
  new HttpDataViewRepository<AuditEventRead>({
@@ -97,7 +106,12 @@ export function AuditLogAdmin() {
97
106
  ]}
98
107
  />
99
108
  {event.payload !== null && (
100
- <pre data-terp="admin-payload">{JSON.stringify(event.payload, null, 2)}</pre>
109
+ // `Code block`, not a hand-rolled <pre>. The comment that used to sit here cited
110
+ // Code's own rationale for the tabIndex it was copying — a scroll container no
111
+ // keyboard can reach is the SC 2.1.1 failure axe reports as
112
+ // `scrollable-region-focusable` — which is a long way of saying this WAS a Code
113
+ // block, written out again under a second marker. It carries the tabIndex itself.
114
+ <Code block>{JSON.stringify(event.payload, null, 2)}</Code>
101
115
  )}
102
116
  </div>
103
117
  )}
@@ -15,9 +15,13 @@ import { useStrings } from "../uiText";
15
15
  import { unwrap } from "../unwrap";
16
16
 
17
17
  import { adminCrumb, renderAdminCrumb } from "./crumbs";
18
+ import { routeFieldErrors } from "./fieldErrors";
18
19
 
19
20
  const FORM_ID = "terp-admin-group-create";
20
21
 
22
+ /** The inputs this form renders, and so the only reasons it can put anywhere the user will see. */
23
+ const RENDERED_FIELDS = ["name", "description"] as const;
24
+
21
25
  /** Dedicated group-creation page (`/admin/groups/new`). */
22
26
  export function GroupCreate() {
23
27
  const client = useTerpClient();
@@ -27,10 +31,14 @@ export function GroupCreate() {
27
31
  const [name, setName] = useState("");
28
32
  const [description, setDescription] = useState("");
29
33
  const [creating, setCreating] = useState(false);
34
+ const [fieldErrors, setFieldErrors] = useState<Readonly<Record<string, string>>>({});
30
35
 
31
36
  async function onSubmit(event: FormEvent) {
32
37
  event.preventDefault();
33
38
  setCreating(true);
39
+ // Cleared on every attempt: the server re-validates the whole body, so its answer is the
40
+ // complete set of what is wrong, and a stale key would sit under a field already fixed.
41
+ setFieldErrors({});
34
42
  try {
35
43
  const group = unwrap(
36
44
  await client.POST("/api/v1/groups/", { body: { name, description } }),
@@ -41,7 +49,13 @@ export function GroupCreate() {
41
49
  params: { groupId: group.id },
42
50
  });
43
51
  } catch (error) {
44
- toast.warning(error instanceof Error ? error.message : strings.requestFailed);
52
+ // A reason that names a field belongs on that field; anything unattributed, or attributed
53
+ // to a field this form does not render, stays a toast.
54
+ const { shown, leftover } = routeFieldErrors(error, RENDERED_FIELDS);
55
+ setFieldErrors(shown);
56
+ if (Object.keys(shown).length === 0 || leftover) {
57
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
58
+ }
45
59
  } finally {
46
60
  setCreating(false);
47
61
  }
@@ -77,10 +91,10 @@ export function GroupCreate() {
77
91
  >
78
92
  <div data-terp="admin-form">
79
93
  <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
80
- <Field label={strings.groupName}>
94
+ <Field label={strings.groupName} error={fieldErrors.name}>
81
95
  <Input value={name} onChange={(event) => setName(event.target.value)} required />
82
96
  </Field>
83
- <Field label={strings.description}>
97
+ <Field label={strings.description} error={fieldErrors.description}>
84
98
  <Input value={description} onChange={(event) => setDescription(event.target.value)} />
85
99
  </Field>
86
100
  </Stack>
@@ -17,8 +17,9 @@ import { useRecord } from "../useRecord";
17
17
  import { useToast } from "../toast";
18
18
  import { Button } from "../ui/Button";
19
19
  import { Input } from "../ui/Input";
20
+ import { useFormatDate, useFormatDateTime } from "../format";
20
21
  import { useStrings } from "../uiText";
21
- import { unwrap } from "../unwrap";
22
+ import { ApiError, unwrap } from "../unwrap";
22
23
 
23
24
  import { adminCrumb, renderAdminCrumb } from "./crumbs";
24
25
 
@@ -47,12 +48,16 @@ export function GroupDetail() {
47
48
  const strings = useStrings();
48
49
  const suggestionsId = useId();
49
50
 
51
+ const formatDate = useFormatDate();
52
+ const formatDateTime = useFormatDateTime();
50
53
  const [membersVersion, setMembersVersion] = useState(0);
51
54
  const [grantsVersion, setGrantsVersion] = useState(0);
52
55
  const [memberQuery, setMemberQuery] = useState("");
56
+ const [memberError, setMemberError] = useState<string | undefined>(undefined);
53
57
  const [suggestions, setSuggestions] = useState<UserRead[]>([]);
54
58
  const [adding, setAdding] = useState(false);
55
59
  const [permission, setPermission] = useState("");
60
+ const [permissionError, setPermissionError] = useState<string | undefined>(undefined);
56
61
  const [granting, setGranting] = useState(false);
57
62
  const [deleteOpen, setDeleteOpen] = useState(false);
58
63
  const [deleting, setDeleting] = useState(false);
@@ -124,11 +129,11 @@ export function GroupDetail() {
124
129
  id: "created_at",
125
130
  header: strings.createdColumn,
126
131
  accessor: (m) => m.created_at,
127
- cell: (m) => new Date(m.created_at).toLocaleDateString(),
128
- meta: { mobileSlot: "date", width: 120 },
132
+ cell: (m) => formatDate(m.created_at),
133
+ meta: { mobileSlot: "date", width: "sm" },
129
134
  },
130
135
  ],
131
- [strings],
136
+ [strings, formatDate],
132
137
  );
133
138
 
134
139
  const grantColumns: DataViewColumn<GrantRead>[] = useMemo(
@@ -143,11 +148,11 @@ export function GroupDetail() {
143
148
  id: "created_at",
144
149
  header: strings.createdColumn,
145
150
  accessor: (g) => g.created_at,
146
- cell: (g) => new Date(g.created_at).toLocaleDateString(),
147
- meta: { mobileSlot: "date", width: 120 },
151
+ cell: (g) => formatDate(g.created_at),
152
+ meta: { mobileSlot: "date", width: "sm" },
148
153
  },
149
154
  ],
150
- [strings],
155
+ [strings, formatDate],
151
156
  );
152
157
 
153
158
  const membersRepository = useMemo(
@@ -190,11 +195,36 @@ export function GroupDetail() {
190
195
  toast.warning(error instanceof Error ? error.message : strings.requestFailed);
191
196
  }
192
197
 
198
+ /**
199
+ * Route a failure to the input it is about, or to the toast when it is about anything else.
200
+ *
201
+ * The key is named rather than inferred from whichever reason came first, because neither of
202
+ * these two forms submits only what the user typed: adding a member posts a `user_id` resolved
203
+ * from the typed email, and granting posts the group's own `subject_id` beside the permission.
204
+ * A reason about either of those is not something the one visible input can be edited to fix,
205
+ * so it belongs where every other unattributed failure on this screen already goes.
206
+ */
207
+ function failedField(
208
+ error: unknown,
209
+ key: string,
210
+ set: (message: string | undefined) => void,
211
+ ): void {
212
+ if (error instanceof ApiError) {
213
+ const message = error.fields[key];
214
+ if (message !== undefined) {
215
+ set(message);
216
+ return;
217
+ }
218
+ }
219
+ failed(error);
220
+ }
221
+
193
222
  async function onAddMember(event: FormEvent) {
194
223
  event.preventDefault();
195
224
  const needle = memberQuery.trim();
196
225
  if (needle === "") return;
197
226
  setAdding(true);
227
+ setMemberError(undefined);
198
228
  try {
199
229
  // Resolve the typed email to an account: exact match among the current
200
230
  // suggestions first, else one direct directory query.
@@ -208,7 +238,11 @@ export function GroupDetail() {
208
238
  match = page.items.find((user) => user.email === needle);
209
239
  }
210
240
  if (match === undefined) {
211
- toast.warning(strings.userNotFound);
241
+ // On the field for the same reason a 422 now is: "no account matches that email" is
242
+ // about the email the user just typed, and it is the only input on this form. Leaving it
243
+ // a toast would have this one form answer server reasons on the field and client reasons
244
+ // above it, which is a distinction the user has no way to perceive.
245
+ setMemberError(strings.userNotFound);
212
246
  return;
213
247
  }
214
248
  unwrap(
@@ -223,7 +257,7 @@ export function GroupDetail() {
223
257
  setMembersVersion((v) => v + 1);
224
258
  void group.reload();
225
259
  } catch (error) {
226
- failed(error);
260
+ failedField(error, "user_id", setMemberError);
227
261
  } finally {
228
262
  setAdding(false);
229
263
  }
@@ -252,6 +286,7 @@ export function GroupDetail() {
252
286
  async function onGrant(event: FormEvent) {
253
287
  event.preventDefault();
254
288
  setGranting(true);
289
+ setPermissionError(undefined);
255
290
  try {
256
291
  unwrap(
257
292
  await client.POST("/api/v1/access/grants", {
@@ -262,7 +297,7 @@ export function GroupDetail() {
262
297
  setPermission("");
263
298
  setGrantsVersion((v) => v + 1);
264
299
  } catch (error) {
265
- failed(error);
300
+ failedField(error, "permission", setPermissionError);
266
301
  } finally {
267
302
  setGranting(false);
268
303
  }
@@ -334,7 +369,7 @@ export function GroupDetail() {
334
369
  items={[
335
370
  { label: strings.description, value: record.description || "-" },
336
371
  { label: strings.members, value: record.member_count },
337
- { label: strings.createdColumn, value: new Date(record.created_at).toLocaleString() },
372
+ { label: strings.createdColumn, value: formatDateTime(record.created_at) },
338
373
  ]}
339
374
  />
340
375
  )}
@@ -343,7 +378,7 @@ export function GroupDetail() {
343
378
  {strings.members}
344
379
  </h2>
345
380
  <Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onAddMember}>
346
- <Field label={strings.userField}>
381
+ <Field label={strings.userField} error={memberError}>
347
382
  <Input
348
383
  type="email"
349
384
  value={memberQuery}
@@ -380,7 +415,7 @@ export function GroupDetail() {
380
415
  {strings.permissions}
381
416
  </h2>
382
417
  <Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onGrant}>
383
- <Field label={strings.permission}>
418
+ <Field label={strings.permission} error={permissionError}>
384
419
  <Input
385
420
  value={permission}
386
421
  onChange={(event) => setPermission(event.target.value)}
@@ -9,6 +9,7 @@ import { useTerpClient } from "../TerpProvider";
9
9
  import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
10
10
  import type { DataViewColumn } from "../dataview";
11
11
  import { Button } from "../ui/Button";
12
+ import { useFormatDate } from "../format";
12
13
  import { useStrings } from "../uiText";
13
14
  import type { TerpStrings } from "../uiText";
14
15
  import { unwrap } from "../unwrap";
@@ -17,7 +18,10 @@ import { adminCrumb, renderAdminCrumb } from "./crumbs";
17
18
 
18
19
  type GroupRead = components["schemas"]["GroupRead"];
19
20
 
20
- function buildColumns(strings: TerpStrings): DataViewColumn<GroupRead>[] {
21
+ function buildColumns(
22
+ strings: TerpStrings,
23
+ formatDate: (value: string) => string,
24
+ ): DataViewColumn<GroupRead>[] {
21
25
  return [
22
26
  { id: "name", header: strings.groupName, accessor: (g) => g.name, meta: { mobileSlot: "title" } },
23
27
  {
@@ -30,14 +34,14 @@ function buildColumns(strings: TerpStrings): DataViewColumn<GroupRead>[] {
30
34
  id: "member_count",
31
35
  header: strings.members,
32
36
  accessor: (g) => g.member_count,
33
- meta: { mobileSlot: "status", width: 110 },
37
+ meta: { mobileSlot: "status", width: "sm" },
34
38
  },
35
39
  {
36
40
  id: "created_at",
37
41
  header: strings.createdColumn,
38
42
  accessor: (g) => g.created_at,
39
- cell: (g) => new Date(g.created_at).toLocaleDateString(),
40
- meta: { mobileSlot: "date", width: 120 },
43
+ cell: (g) => formatDate(g.created_at),
44
+ meta: { mobileSlot: "date", width: "sm" },
41
45
  },
42
46
  ];
43
47
  }
@@ -53,7 +57,11 @@ export function GroupsAdmin() {
53
57
  const navigate = useNavigate();
54
58
  const serverQuery = useServerDataView({ initialPageSize: 10 });
55
59
 
56
- const columns = useMemo(() => buildColumns(strings), [strings]);
60
+ const formatDate = useFormatDate();
61
+ const columns = useMemo(
62
+ () => buildColumns(strings, formatDate),
63
+ [strings, formatDate],
64
+ );
57
65
  const repository = useMemo(
58
66
  () =>
59
67
  new HttpDataViewRepository<GroupRead>({