@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.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- 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(
|
|
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) =>
|
|
24
|
-
meta: { mobileSlot: "date", width:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
128
|
-
meta: { mobileSlot: "date", width:
|
|
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) =>
|
|
147
|
-
meta: { mobileSlot: "date", width:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
372
|
+
{ label: strings.createdColumn, value: formatDateTime(record.created_at) },
|
|
338
373
|
]}
|
|
339
374
|
/>
|
|
340
375
|
)}
|
|
341
376
|
<Stack gap={3}>
|
|
342
|
-
<h2
|
|
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
|
|
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(
|
|
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:
|
|
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) =>
|
|
40
|
-
meta: { mobileSlot: "date", width:
|
|
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
|
|
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>({
|
package/src/admin/UserCreate.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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>
|
package/src/admin/UserDetail.tsx
CHANGED
|
@@ -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:
|
|
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
|
package/src/admin/UsersAdmin.tsx
CHANGED
|
@@ -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(
|
|
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:
|
|
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:
|
|
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) =>
|
|
42
|
-
meta: { mobileSlot: "date", width:
|
|
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
|
|
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>({
|