@terpjs/react-core 0.9.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 +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- 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 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- 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/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- 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 +25 -5
- 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/router.test.tsx +575 -2
- package/src/router.tsx +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +69 -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
package/src/UserMenu.test.tsx
CHANGED
|
@@ -66,7 +66,7 @@ describe("UserMenu", () => {
|
|
|
66
66
|
<UserMenu />
|
|
67
67
|
</TerpProvider>,
|
|
68
68
|
);
|
|
69
|
-
expect(screen.queryByRole("button", { name:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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(
|
|
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,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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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,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:
|
|
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(
|
|
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
|
}
|
|
@@ -83,7 +100,7 @@ export function UserCreate() {
|
|
|
83
100
|
>
|
|
84
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>({
|