@terpjs/react-core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  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.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -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,20 +46,11 @@ 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
  }
48
53
 
49
- const payloadStyle = {
50
- margin: 0,
51
- padding: "var(--space-3)",
52
- background: "var(--color-neutral-100)",
53
- borderRadius: "var(--radius-md)",
54
- fontSize: "var(--font-size-sm, 0.875rem)",
55
- overflowX: "auto" as const,
56
- };
57
-
58
54
  /**
59
55
  * The packaged audit-log screen (`/admin/audit`): the append-only trail every
60
56
  * mutation lands in (ADR 0007), newest first as served, read-only by design.
@@ -65,7 +61,11 @@ export function AuditLogAdmin() {
65
61
  const strings = useStrings();
66
62
  const serverQuery = useServerDataView({ initialPageSize: 25 });
67
63
 
68
- const columns = useMemo(() => buildColumns(strings), [strings]);
64
+ const formatDateTime = useFormatDateTime();
65
+ const columns = useMemo(
66
+ () => buildColumns(strings, formatDateTime),
67
+ [strings, formatDateTime],
68
+ );
69
69
  const repository = useMemo(
70
70
  () =>
71
71
  new HttpDataViewRepository<AuditEventRead>({
@@ -106,7 +106,12 @@ export function AuditLogAdmin() {
106
106
  ]}
107
107
  />
108
108
  {event.payload !== null && (
109
- <pre style={payloadStyle}>{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>
110
115
  )}
111
116
  </div>
112
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
  }
@@ -75,12 +89,12 @@ export function GroupCreate() {
75
89
  />
76
90
  }
77
91
  >
78
- <div style={{ maxWidth: "32rem" }}>
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,16 +369,16 @@ 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
  )}
341
376
  <Stack gap={3}>
342
- <h2 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
377
+ <h2 data-terp="admin-section-title">
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}
@@ -376,11 +411,11 @@ export function GroupDetail() {
376
411
  />
377
412
  </Stack>
378
413
  <Stack gap={3}>
379
- <h2 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
414
+ <h2 data-terp="admin-section-title">
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>({
@@ -16,10 +16,14 @@ import { useStrings } from "../uiText";
16
16
  import { unwrap } from "../unwrap";
17
17
 
18
18
  import { adminCrumb, renderAdminCrumb } from "./crumbs";
19
+ import { routeFieldErrors } from "./fieldErrors";
19
20
  import { adminRoleOptions } from "./roles";
20
21
 
21
22
  const FORM_ID = "terp-admin-user-create";
22
23
 
24
+ /** The inputs this form renders, and so the only reasons it can put anywhere the user will see. */
25
+ const RENDERED_FIELDS = ["email", "password", "role"] as const;
26
+
23
27
  /** Dedicated account-provisioning page (`/admin/users/new`). */
24
28
  export function UserCreate() {
25
29
  const client = useTerpClient();
@@ -30,11 +34,16 @@ export function UserCreate() {
30
34
  const [password, setPassword] = useState("");
31
35
  const [role, setRole] = useState("10");
32
36
  const [creating, setCreating] = useState(false);
37
+ const [fieldErrors, setFieldErrors] = useState<Readonly<Record<string, string>>>({});
33
38
  const roles = adminRoleOptions(strings);
34
39
 
35
40
  async function onSubmit(event: FormEvent) {
36
41
  event.preventDefault();
37
42
  setCreating(true);
43
+ // Cleared on every attempt, not merged: the server re-validates the whole body, so its
44
+ // answer is the complete set of what is wrong. Keeping a stale key would leave an error
45
+ // under a field the user just fixed.
46
+ setFieldErrors({});
38
47
  try {
39
48
  const user = unwrap(
40
49
  await client.POST("/api/v1/users/", {
@@ -47,7 +56,15 @@ export function UserCreate() {
47
56
  params: { userId: user.id },
48
57
  });
49
58
  } catch (error) {
50
- toast.warning(error instanceof Error ? error.message : strings.requestFailed);
59
+ // A reason that names a field belongs on that field, not floating above the form in a
60
+ // toast the user has to hold in their head while looking for the input it means. Anything
61
+ // the server did not attribute — or attributed to something this form has no input for —
62
+ // stays a toast, which is where the delete and revoke handlers correctly leave it.
63
+ const { shown, leftover } = routeFieldErrors(error, RENDERED_FIELDS);
64
+ setFieldErrors(shown);
65
+ if (Object.keys(shown).length === 0 || leftover) {
66
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
67
+ }
51
68
  } finally {
52
69
  setCreating(false);
53
70
  }
@@ -81,9 +98,9 @@ export function UserCreate() {
81
98
  />
82
99
  }
83
100
  >
84
- <div style={{ maxWidth: "32rem" }}>
101
+ <div data-terp="admin-form">
85
102
  <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
86
- <Field label={strings.email}>
103
+ <Field label={strings.email} error={fieldErrors.email}>
87
104
  <Input
88
105
  type="email"
89
106
  value={email}
@@ -91,22 +108,34 @@ export function UserCreate() {
91
108
  required
92
109
  />
93
110
  </Field>
94
- <Field label={strings.password}>
111
+ <Field label={strings.password} error={fieldErrors.password}>
95
112
  <Input
96
113
  type="password"
114
+ // The admin is setting someone else's password, so this is never the browser's saved
115
+ // credential: `new-password` stops a manager offering the admin's own.
116
+ autoComplete="new-password"
97
117
  value={password}
98
118
  onChange={(event) => setPassword(event.target.value)}
99
119
  required
100
120
  />
101
121
  </Field>
102
- <Field label={strings.role}>
103
- <Select value={role} onChange={(event) => setRole(event.target.value)}>
104
- {roles.map((option) => (
105
- <option key={option.rank} value={option.rank}>
106
- {option.label}
107
- </option>
108
- ))}
109
- </Select>
122
+ <Field label={strings.role} error={fieldErrors.role}>
123
+ {/* `options` + `onValueChange` rather than an `<option>` per rank and a raw
124
+ change event: the same three rows, as data. No cast is removed here — this file
125
+ never had one, because `role` was already a plain string — and `T` degrades to
126
+ `string` for the same reason, since the option values are `String(rank)` rather
127
+ than members of a declared union. What this conversion buys is the list, not the
128
+ type: the rank is a number in the ladder and a string in the DOM, which is why
129
+ `String(rank)` is the option value and `role` stays a string until the POST body
130
+ coerces it. The typed half has its exhibit in an app that owns an enum. */}
131
+ <Select
132
+ options={roles.map((option) => ({
133
+ value: String(option.rank),
134
+ label: option.label,
135
+ }))}
136
+ value={role}
137
+ onValueChange={setRole}
138
+ />
110
139
  </Field>
111
140
  </Stack>
112
141
  </div>
@@ -13,6 +13,7 @@ import { useRecord } from "../useRecord";
13
13
  import { useToast } from "../toast";
14
14
  import { Button } from "../ui/Button";
15
15
  import { Input } from "../ui/Input";
16
+ import { useFormatDateTime } from "../format";
16
17
  import { useStrings } from "../uiText";
17
18
  import { unwrap } from "../unwrap";
18
19
 
@@ -30,6 +31,7 @@ export function UserDetail() {
30
31
  const userId = useDeclaredParam("userId");
31
32
  const client = useTerpClient();
32
33
  const strings = useStrings();
34
+ const formatDateTime = useFormatDateTime();
33
35
  const toast = useToast();
34
36
  const [pendingLifecycle, setPendingLifecycle] = useState<PendingLifecycle | null>(null);
35
37
  const [mutating, setMutating] = useState(false);
@@ -173,7 +175,7 @@ export function UserDetail() {
173
175
  label: strings.statusColumn,
174
176
  value: record.is_active ? strings.statusActive : strings.statusDeactivated,
175
177
  },
176
- { label: strings.createdColumn, value: new Date(record.created_at).toLocaleString() },
178
+ { label: strings.createdColumn, value: formatDateTime(record.created_at) },
177
179
  ]}
178
180
  />
179
181
  )}
@@ -211,6 +213,7 @@ export function UserDetail() {
211
213
  <Field label={strings.newPassword}>
212
214
  <Input
213
215
  type="password"
216
+ autoComplete="new-password"
214
217
  value={resetPassword}
215
218
  onChange={(event) => setResetPassword(event.target.value)}
216
219
  required
@@ -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";
@@ -18,7 +19,10 @@ import { adminRoleLabel } from "./roles";
18
19
 
19
20
  type UserRead = components["schemas"]["UserRead"];
20
21
 
21
- function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
22
+ function buildColumns(
23
+ strings: TerpStrings,
24
+ formatDate: (value: string) => string,
25
+ ): DataViewColumn<UserRead>[] {
22
26
  return [
23
27
  { id: "email", header: strings.email, accessor: (u) => u.email, meta: { mobileSlot: "title" } },
24
28
  {
@@ -26,20 +30,20 @@ function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
26
30
  header: strings.role,
27
31
  accessor: (u) => u.role,
28
32
  cell: (u) => adminRoleLabel(strings, u.role),
29
- meta: { mobileSlot: "subtitle", width: 100 },
33
+ meta: { mobileSlot: "subtitle", width: "xs" },
30
34
  },
31
35
  {
32
36
  id: "is_active",
33
37
  header: strings.statusColumn,
34
38
  accessor: (u) => (u.is_active ? strings.statusActive : strings.statusDeactivated),
35
- meta: { mobileSlot: "status", width: 110 },
39
+ meta: { mobileSlot: "status", width: "sm" },
36
40
  },
37
41
  {
38
42
  id: "created_at",
39
43
  header: strings.createdColumn,
40
44
  accessor: (u) => u.created_at,
41
- cell: (u) => new Date(u.created_at).toLocaleDateString(),
42
- meta: { mobileSlot: "date", width: 120 },
45
+ cell: (u) => formatDate(u.created_at),
46
+ meta: { mobileSlot: "date", width: "sm" },
43
47
  },
44
48
  ];
45
49
  }
@@ -55,7 +59,11 @@ export function UsersAdmin() {
55
59
  const navigate = useNavigate();
56
60
  const serverQuery = useServerDataView({ initialPageSize: 10 });
57
61
 
58
- const columns = useMemo(() => buildColumns(strings), [strings]);
62
+ const formatDate = useFormatDate();
63
+ const columns = useMemo(
64
+ () => buildColumns(strings, formatDate),
65
+ [strings, formatDate],
66
+ );
59
67
  const repository = useMemo(
60
68
  () =>
61
69
  new HttpDataViewRepository<UserRead>({