@stapel/workspaces-react 0.16.0 → 0.19.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/CHANGELOG.md +194 -0
- package/README.md +39 -8
- package/dist/api/generated/schema.d.ts +187 -60
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ActiveWorkspace.d.ts +43 -0
- package/dist/default/ActiveWorkspace.d.ts.map +1 -0
- package/dist/default/ActiveWorkspace.js +33 -0
- package/dist/default/ActiveWorkspace.js.map +1 -0
- package/dist/default/AuditTrailPane.d.ts +12 -0
- package/dist/default/AuditTrailPane.d.ts.map +1 -0
- package/dist/default/AuditTrailPane.js +154 -0
- package/dist/default/AuditTrailPane.js.map +1 -0
- package/dist/default/InvitationsPane.d.ts +19 -0
- package/dist/default/InvitationsPane.d.ts.map +1 -0
- package/dist/default/InvitationsPane.js +217 -0
- package/dist/default/InvitationsPane.js.map +1 -0
- package/dist/default/InviteAcceptPage.d.ts +7 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -1
- package/dist/default/InviteAcceptPage.js +74 -38
- package/dist/default/InviteAcceptPage.js.map +1 -1
- package/dist/default/MembersManager.d.ts +12 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +320 -84
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/RoleSelectField.d.ts +64 -0
- package/dist/default/RoleSelectField.d.ts.map +1 -0
- package/dist/default/RoleSelectField.js +78 -0
- package/dist/default/RoleSelectField.js.map +1 -0
- package/dist/default/WorkspaceSettings.d.ts +7 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +203 -45
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/default/WorkspacesPage.d.ts +13 -0
- package/dist/default/WorkspacesPage.d.ts.map +1 -0
- package/dist/default/WorkspacesPage.js +108 -0
- package/dist/default/WorkspacesPage.js.map +1 -0
- package/dist/default/index.d.ts +28 -7
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +24 -7
- package/dist/default/index.js.map +1 -1
- package/dist/default/parts.d.ts +104 -0
- package/dist/default/parts.d.ts.map +1 -0
- package/dist/default/parts.js +140 -0
- package/dist/default/parts.js.map +1 -0
- package/dist/headless/Members.d.ts +59 -14
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +35 -10
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +9 -3
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +3 -2
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/i18n/es.d.ts +11 -12
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +267 -14
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +5 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +30 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +15 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +5 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +209 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +410 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +7 -2
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +209 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/model/format.d.ts +56 -0
- package/dist/model/format.d.ts.map +1 -0
- package/dist/model/format.js +125 -0
- package/dist/model/format.js.map +1 -0
- package/dist/model/mandate.d.ts +23 -1
- package/dist/model/mandate.d.ts.map +1 -1
- package/dist/model/mandate.js +24 -0
- package/dist/model/mandate.js.map +1 -1
- package/dist/model/selection.d.ts +15 -0
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +18 -1
- package/dist/model/selection.js.map +1 -1
- package/dist/nav/manifest.d.ts +45 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +77 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +11 -10
- package/manifest.json +282 -69
- package/package.json +18 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +187 -60
- package/src/default/ActiveWorkspace.tsx +97 -0
- package/src/default/AuditTrailPane.tsx +309 -0
- package/src/default/InvitationsPane.tsx +501 -0
- package/src/default/InviteAcceptPage.tsx +187 -90
- package/src/default/MembersManager.tsx +832 -222
- package/src/default/RoleSelectField.tsx +187 -0
- package/src/default/WorkspaceSettings.tsx +540 -125
- package/src/default/WorkspacesPage.tsx +387 -0
- package/src/default/index.ts +28 -7
- package/src/default/parts.tsx +308 -0
- package/src/headless/Members.tsx +84 -16
- package/src/headless/RoleSelect.tsx +12 -5
- package/src/i18n/es.ts +326 -14
- package/src/i18n/generated/errors.es.gen.ts +5 -0
- package/src/i18n/generated/errors.gen.ts +15 -0
- package/src/i18n/generated/errors.json +37 -0
- package/src/i18n/generated/errors.ru.gen.ts +5 -0
- package/src/i18n/keys.ts +462 -5
- package/src/i18n/ru.ts +259 -5
- package/src/index.ts +6 -1
- package/src/model/format.ts +158 -0
- package/src/model/mandate.ts +26 -1
- package/src/model/selection.tsx +19 -1
- package/src/nav/manifest.ts +120 -0
- package/dist/default/ErrorAlert.d.ts +0 -8
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -30
- package/dist/default/ErrorAlert.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -47
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small pieces every screen in this pair's default skin is built from.
|
|
3
|
+
*
|
|
4
|
+
* Not exported from `./index.ts`: these are the skin's own vocabulary, not
|
|
5
|
+
* product surfaces a host mounts. They live in one file because the visual
|
|
6
|
+
* pass found the same three things drawn differently on each screen — a
|
|
7
|
+
* person rendered as a bare email here and a truncated id there, a page walk
|
|
8
|
+
* with no way back, a muted caption at three different sizes.
|
|
9
|
+
*
|
|
10
|
+
* Every dimension is a `@stapel/tokens` step. Colour comes from antd's token
|
|
11
|
+
* layer through `SkinTheme`, never from a literal.
|
|
12
|
+
*/
|
|
13
|
+
import { useId } from "react";
|
|
14
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
15
|
+
import { Avatar, Button, Flex, Tag, Typography } from "antd";
|
|
16
|
+
import { STAPEL_UI_KEYS, useT } from "@stapel/core";
|
|
17
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
18
|
+
import { ErrorAlert } from "@stapel/tokens-antd/skin";
|
|
19
|
+
import { spacing, fontSize } from "@stapel/tokens";
|
|
20
|
+
|
|
21
|
+
/** The muted sentence that sits under a control or beside a value. */
|
|
22
|
+
export function Muted(props: {
|
|
23
|
+
readonly children: ReactNode;
|
|
24
|
+
readonly testId?: string | undefined;
|
|
25
|
+
}): ReactElement {
|
|
26
|
+
return (
|
|
27
|
+
<Typography.Text
|
|
28
|
+
type="secondary"
|
|
29
|
+
style={{ fontSize: fontSize.xs.fontSize }}
|
|
30
|
+
{...(props.testId !== undefined ? { "data-testid": props.testId } : {})}
|
|
31
|
+
>
|
|
32
|
+
{props.children}
|
|
33
|
+
</Typography.Text>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One letter for a person with no picture. Never an id: the monogram of a
|
|
38
|
+
* name, or of the mailbox part of an address, and a neutral glyph when there
|
|
39
|
+
* is neither. */
|
|
40
|
+
function monogram(name: string | null, email: string | null): string {
|
|
41
|
+
const source = (name ?? email ?? "").trim();
|
|
42
|
+
const first = source.charAt(0);
|
|
43
|
+
return first === "" ? "·" : first.toUpperCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A person, drawn the same way on every screen of this pair: monogram, the
|
|
48
|
+
* name they go by, the address underneath.
|
|
49
|
+
*
|
|
50
|
+
* The rule the visual pass forced: an id never reaches the glass. A member
|
|
51
|
+
* whose profile has no name shows the invited ADDRESS as the primary line and
|
|
52
|
+
* says so, rather than showing a truncated UUID or an em dash.
|
|
53
|
+
*/
|
|
54
|
+
export function PersonLine(props: {
|
|
55
|
+
readonly name: string | null | undefined;
|
|
56
|
+
readonly email: string | null | undefined;
|
|
57
|
+
/** Rendered under the address — "Joined 3 days ago", a suspension reason. */
|
|
58
|
+
readonly caption?: ReactNode;
|
|
59
|
+
/** Tags beside the name (suspended, provisioned, two-factor). */
|
|
60
|
+
readonly tags?: ReactNode;
|
|
61
|
+
readonly testId?: string | undefined;
|
|
62
|
+
}): ReactElement {
|
|
63
|
+
const name = props.name?.trim() ? props.name.trim() : null;
|
|
64
|
+
const email = props.email?.trim() ? props.email.trim() : null;
|
|
65
|
+
const primary = name ?? email;
|
|
66
|
+
return (
|
|
67
|
+
<Flex
|
|
68
|
+
gap={spacing["3"]}
|
|
69
|
+
align="flex-start"
|
|
70
|
+
{...(props.testId !== undefined ? { "data-testid": props.testId } : {})}
|
|
71
|
+
>
|
|
72
|
+
<Avatar size="small" aria-hidden>
|
|
73
|
+
{monogram(name, email)}
|
|
74
|
+
</Avatar>
|
|
75
|
+
<Flex vertical gap={spacing["0"]} style={{ minWidth: 0 }}>
|
|
76
|
+
<Flex gap={spacing["2"]} align="center" wrap>
|
|
77
|
+
<Typography.Text strong>{primary}</Typography.Text>
|
|
78
|
+
{props.tags}
|
|
79
|
+
</Flex>
|
|
80
|
+
{name !== null && email !== null && <Muted>{email}</Muted>}
|
|
81
|
+
{props.caption !== undefined && <Muted>{props.caption}</Muted>}
|
|
82
|
+
</Flex>
|
|
83
|
+
</Flex>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A neutral status pill. `tone` picks antd's semantic colour, so it survives
|
|
88
|
+
* the dark swap — a hex here would not. */
|
|
89
|
+
export function StatusTag(props: {
|
|
90
|
+
readonly tone: "neutral" | "success" | "warning" | "danger" | "info";
|
|
91
|
+
readonly children: ReactNode;
|
|
92
|
+
readonly testId?: string | undefined;
|
|
93
|
+
}): ReactElement {
|
|
94
|
+
const color =
|
|
95
|
+
props.tone === "success"
|
|
96
|
+
? "success"
|
|
97
|
+
: props.tone === "warning"
|
|
98
|
+
? "warning"
|
|
99
|
+
: props.tone === "danger"
|
|
100
|
+
? "error"
|
|
101
|
+
: props.tone === "info"
|
|
102
|
+
? "processing"
|
|
103
|
+
: "default";
|
|
104
|
+
return (
|
|
105
|
+
<Tag
|
|
106
|
+
color={color}
|
|
107
|
+
{...(props.testId !== undefined ? { "data-testid": props.testId } : {})}
|
|
108
|
+
>
|
|
109
|
+
{props.children}
|
|
110
|
+
</Tag>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The anchor pager three screens share.
|
|
116
|
+
*
|
|
117
|
+
* Two buttons and a position, and nothing that pretends to be a page NUMBER
|
|
118
|
+
* the contract cannot supply: the wire addresses a page by the previous
|
|
119
|
+
* page's opaque cursor, so "jump to page 7" is not a question this API can be
|
|
120
|
+
* asked. The index shown is the count of steps THIS screen has taken, which
|
|
121
|
+
* is a true statement about the walk rather than about the roster.
|
|
122
|
+
*/
|
|
123
|
+
export function AnchorPager(props: {
|
|
124
|
+
readonly hasPrev: boolean;
|
|
125
|
+
readonly hasNext: boolean;
|
|
126
|
+
readonly onPrev: () => void;
|
|
127
|
+
readonly onNext: () => void;
|
|
128
|
+
readonly prevLabel: string;
|
|
129
|
+
readonly nextLabel: string;
|
|
130
|
+
readonly position: string;
|
|
131
|
+
readonly testId?: string | undefined;
|
|
132
|
+
}): ReactElement | null {
|
|
133
|
+
if (!props.hasPrev && !props.hasNext) return null;
|
|
134
|
+
return (
|
|
135
|
+
<Flex
|
|
136
|
+
justify="space-between"
|
|
137
|
+
align="center"
|
|
138
|
+
gap={spacing["3"]}
|
|
139
|
+
style={{ marginTop: spacing["4"] }}
|
|
140
|
+
{...(props.testId !== undefined ? { "data-testid": props.testId } : {})}
|
|
141
|
+
>
|
|
142
|
+
<Button
|
|
143
|
+
size="small"
|
|
144
|
+
onClick={props.onPrev}
|
|
145
|
+
disabled={!props.hasPrev}
|
|
146
|
+
data-disabled-reason="first page of the walk — there is nothing before it"
|
|
147
|
+
data-analytics="none"
|
|
148
|
+
data-analytics-reason="local-ui-page-walk"
|
|
149
|
+
data-testid="workspaces-pager-prev"
|
|
150
|
+
>
|
|
151
|
+
{props.prevLabel}
|
|
152
|
+
</Button>
|
|
153
|
+
<Muted>{props.position}</Muted>
|
|
154
|
+
<Button
|
|
155
|
+
size="small"
|
|
156
|
+
onClick={props.onNext}
|
|
157
|
+
disabled={!props.hasNext}
|
|
158
|
+
data-disabled-reason="the server says this page is the last one"
|
|
159
|
+
data-analytics="none"
|
|
160
|
+
data-analytics-reason="local-ui-page-walk"
|
|
161
|
+
data-testid="workspaces-pager-next"
|
|
162
|
+
>
|
|
163
|
+
{props.nextLabel}
|
|
164
|
+
</Button>
|
|
165
|
+
</Flex>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One control on a list row, with the verdict that decides whether it opens. */
|
|
170
|
+
export interface RowActionSpec {
|
|
171
|
+
readonly key: string;
|
|
172
|
+
readonly gate: ActionAvailability;
|
|
173
|
+
readonly label: ReactNode;
|
|
174
|
+
readonly onClick: () => void;
|
|
175
|
+
readonly danger?: boolean;
|
|
176
|
+
readonly testId?: string | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A list row's controls, and the refusal behind them stated ONCE.
|
|
181
|
+
*
|
|
182
|
+
* `GatedButton` prints the reason under every control it switches off, which
|
|
183
|
+
* is right for a lone button and wrong for a row: three refused controls print
|
|
184
|
+
* the same sentence three times, and a list of such rows becomes a wall of
|
|
185
|
+
* repeated apology — six copies of "This invitation was already accepted,
|
|
186
|
+
* declined, revoked or expired" on one 390px screen. The reason is a fact
|
|
187
|
+
* about the ROW, not about each button, so it is rendered once, as a footnote
|
|
188
|
+
* spanning the row, with every disabled control's `aria-describedby` pointing
|
|
189
|
+
* at it.
|
|
190
|
+
*
|
|
191
|
+
* Two nodes come back, meant to be direct children of the row's flex
|
|
192
|
+
* container: the controls, and the full-width footnote. Keeping the footnote
|
|
193
|
+
* OUT of the action column is what stops a refused row from growing a second
|
|
194
|
+
* geometry while its neighbours stay on one line.
|
|
195
|
+
*
|
|
196
|
+
* `reason` overrides the collected sentences where the screen has a better
|
|
197
|
+
* single one — a terminal invitation refuses three controls for one reason
|
|
198
|
+
* that the row's own status tag already names.
|
|
199
|
+
*/
|
|
200
|
+
export function RowActions(props: {
|
|
201
|
+
readonly actions: readonly RowActionSpec[];
|
|
202
|
+
readonly reason?: string | undefined;
|
|
203
|
+
readonly testId?: string | undefined;
|
|
204
|
+
}): ReactElement {
|
|
205
|
+
const t = useT();
|
|
206
|
+
const reasonId = useId();
|
|
207
|
+
|
|
208
|
+
const collected: string[] = [];
|
|
209
|
+
if (props.reason !== undefined) {
|
|
210
|
+
collected.push(props.reason);
|
|
211
|
+
} else {
|
|
212
|
+
const seen = new Set<string>();
|
|
213
|
+
for (const action of props.actions) {
|
|
214
|
+
if (action.gate.available) continue;
|
|
215
|
+
if (seen.has(action.gate.block.code)) continue;
|
|
216
|
+
seen.add(action.gate.block.code);
|
|
217
|
+
collected.push(t(action.gate.block.code, action.gate.block.params));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const anyBlocked = props.actions.some((action) => !action.gate.available);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
<Flex gap={spacing["2"]} align="center" wrap>
|
|
225
|
+
{props.actions.map((action) => (
|
|
226
|
+
<Button
|
|
227
|
+
key={action.key}
|
|
228
|
+
type="link"
|
|
229
|
+
size="small"
|
|
230
|
+
danger={action.danger === true}
|
|
231
|
+
disabled={!action.gate.available}
|
|
232
|
+
{...(anyBlocked && !action.gate.available
|
|
233
|
+
? { "aria-describedby": reasonId }
|
|
234
|
+
: {})}
|
|
235
|
+
onClick={action.onClick}
|
|
236
|
+
data-analytics="none"
|
|
237
|
+
data-analytics-reason="passthrough — the caller's onClick carries the tracked action"
|
|
238
|
+
{...(action.testId !== undefined ? { "data-testid": action.testId } : {})}
|
|
239
|
+
>
|
|
240
|
+
{action.label}
|
|
241
|
+
</Button>
|
|
242
|
+
))}
|
|
243
|
+
</Flex>
|
|
244
|
+
{collected.length > 0 && (
|
|
245
|
+
<div
|
|
246
|
+
id={reasonId}
|
|
247
|
+
data-stapel-gated-reason=""
|
|
248
|
+
style={{ flexBasis: "100%", minWidth: 0 }}
|
|
249
|
+
{...(props.testId !== undefined ? { "data-testid": props.testId } : {})}
|
|
250
|
+
>
|
|
251
|
+
{collected.map((sentence) => (
|
|
252
|
+
<div key={sentence}>
|
|
253
|
+
<Muted>{sentence}</Muted>
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A failed read, said once, in a shape a 390px screen can hold.
|
|
264
|
+
*
|
|
265
|
+
* The substrate's `ErrorAlert` puts its retry in antd's `action` slot, which
|
|
266
|
+
* is a column beside the message — at phone width the sentence is squeezed
|
|
267
|
+
* into ~110px and breaks a word per line. Until the substrate stacks it
|
|
268
|
+
* (VC-B6, filed), the retry goes UNDER the alert here, where it has the whole
|
|
269
|
+
* width at any viewport.
|
|
270
|
+
*/
|
|
271
|
+
export function LoadFailure(props: {
|
|
272
|
+
readonly error: unknown;
|
|
273
|
+
readonly onRetry?: (() => void) | undefined;
|
|
274
|
+
readonly testId?: string | undefined;
|
|
275
|
+
}): ReactElement {
|
|
276
|
+
const t = useT();
|
|
277
|
+
return (
|
|
278
|
+
<Flex vertical gap={spacing["3"]} align="flex-start">
|
|
279
|
+
<ErrorAlert
|
|
280
|
+
thrown={props.error}
|
|
281
|
+
style={{ width: "100%" }}
|
|
282
|
+
{...(props.testId !== undefined ? { testId: props.testId } : {})}
|
|
283
|
+
/>
|
|
284
|
+
{props.onRetry !== undefined && (
|
|
285
|
+
<Button
|
|
286
|
+
onClick={props.onRetry}
|
|
287
|
+
data-analytics="none"
|
|
288
|
+
data-analytics-reason="retry of a failed read; the read hook reports its own outcome"
|
|
289
|
+
data-testid={props.testId !== undefined ? `${props.testId}-retry` : undefined}
|
|
290
|
+
>
|
|
291
|
+
{t(STAPEL_UI_KEYS.retry)}
|
|
292
|
+
</Button>
|
|
293
|
+
)}
|
|
294
|
+
</Flex>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The gap between the stacked blocks of a screen. One number, one place. */
|
|
299
|
+
export const SCREEN_STACK: CSSProperties = {
|
|
300
|
+
display: "grid",
|
|
301
|
+
gap: spacing["4"],
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** The gap between the fields inside one block. */
|
|
305
|
+
export const FIELD_STACK: CSSProperties = {
|
|
306
|
+
display: "grid",
|
|
307
|
+
gap: spacing["3"],
|
|
308
|
+
};
|
package/src/headless/Members.tsx
CHANGED
|
@@ -1,30 +1,66 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
3
|
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
4
|
-
import type { Member, MemberInvite } from "../api/types.js";
|
|
4
|
+
import type { Member, MemberInvite, MembersParams } from "../api/types.js";
|
|
5
5
|
import { useMembers } from "../model/queries.js";
|
|
6
6
|
import {
|
|
7
7
|
useInviteMembers,
|
|
8
8
|
useRemoveMember,
|
|
9
|
+
useRenameMember,
|
|
9
10
|
useUpdateMemberRole,
|
|
10
11
|
} from "../model/mutations.js";
|
|
11
|
-
import type { MemberRoleChange } from "../model/mutations.js";
|
|
12
|
+
import type { MemberRename, MemberRoleChange } from "../model/mutations.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where in the anchor walk the current page sits — the half of the roster a
|
|
16
|
+
* pager needs and a count of rows cannot supply.
|
|
17
|
+
*
|
|
18
|
+
* ANCHOR, not offset: a page is addressed by the opaque `next_anchor` of the
|
|
19
|
+
* page before it, so a member removed while an admin reads never skews the
|
|
20
|
+
* next page. There is no "page 3" to jump to, which is why the shape carries
|
|
21
|
+
* two cursors and no index.
|
|
22
|
+
*/
|
|
23
|
+
export interface MembersPageInfo {
|
|
24
|
+
readonly hasNext: boolean;
|
|
25
|
+
readonly hasPrev: boolean;
|
|
26
|
+
/** Pass as `params.anchor` with `direction: "next"` to walk forward. */
|
|
27
|
+
readonly nextAnchor: string | null;
|
|
28
|
+
/** Pass as `params.anchor` with `direction: "prev"` to walk back. */
|
|
29
|
+
readonly prevAnchor: string | null;
|
|
30
|
+
/** Rows on THIS page. Never a count of the roster — see `rosterComplete`. */
|
|
31
|
+
readonly count: number;
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
/** Render-prop bag for {@link Members}. */
|
|
14
35
|
export interface MembersBag {
|
|
15
36
|
/**
|
|
16
37
|
* The roster read, as a state a skin cannot flatten — render it with core's
|
|
17
|
-
* `matchList`. This is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
38
|
+
* `matchList`. This is ONE page: which one is decided by the `params` the
|
|
39
|
+
* caller passes (default: the newest page, limit 100), and {@link page}
|
|
40
|
+
* says whether there is another.
|
|
20
41
|
*
|
|
21
42
|
* Separate from {@link writeError} on purpose: "the roster could not be
|
|
22
43
|
* loaded" and "your invite was rejected" are different sentences, and the
|
|
23
44
|
* old merged `isError` could only produce one of them.
|
|
24
45
|
*/
|
|
25
46
|
readonly state: LoadState<readonly Member[]>;
|
|
26
|
-
/**
|
|
27
|
-
* {@link state}
|
|
47
|
+
/**
|
|
48
|
+
* Whether {@link state} holds the WHOLE roster — the read succeeded and the
|
|
49
|
+
* page reports no `has_next`.
|
|
50
|
+
*
|
|
51
|
+
* A skin may only reason about the roster as a set — "is this the last
|
|
52
|
+
* owner?", "how many admins are there?" — while this is `true`. On one page
|
|
53
|
+
* of a longer roster those counts are counts of a page, and a control gated
|
|
54
|
+
* on one would refuse a removal the backend would have allowed, which is a
|
|
55
|
+
* worse defect than the ungated button it replaced. `false` while the read
|
|
56
|
+
* is loading or failed: an unknown roster is never a complete one.
|
|
57
|
+
*/
|
|
58
|
+
readonly rosterComplete: boolean;
|
|
59
|
+
/** The walk position, or `null` while the page has not arrived. A pager
|
|
60
|
+
* with no page cannot claim there is a next one. */
|
|
61
|
+
readonly page: MembersPageInfo | null;
|
|
62
|
+
/** An invite, a role change, a rename or a removal failed. Never the read —
|
|
63
|
+
* that is {@link state}. */
|
|
28
64
|
readonly writeError: StapelApiError | null;
|
|
29
65
|
/** Invite one or more emails at a role. */
|
|
30
66
|
invite(body: MemberInvite): void;
|
|
@@ -34,6 +70,11 @@ export interface MembersBag {
|
|
|
34
70
|
updateRole(change: MemberRoleChange): void;
|
|
35
71
|
/** A role-change call is in flight. */
|
|
36
72
|
readonly isUpdatingRole: boolean;
|
|
73
|
+
/** Correct a member's display name — the CANONICAL one (stapel-profiles),
|
|
74
|
+
* not a workspace-local note. `displayName: null` clears it. */
|
|
75
|
+
rename(change: MemberRename): void;
|
|
76
|
+
/** A rename call is in flight. */
|
|
77
|
+
readonly isRenaming: boolean;
|
|
37
78
|
/** Remove a member by `userId`. */
|
|
38
79
|
remove(userId: string): void;
|
|
39
80
|
/** A removal call is in flight. */
|
|
@@ -43,30 +84,53 @@ export interface MembersBag {
|
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
/**
|
|
46
|
-
* Headless member roster — a renderless view + invite / role-change /
|
|
47
|
-
* controls over one workspace's members. Wires {@link useMembers},
|
|
48
|
-
* {@link useInviteMembers}, {@link useUpdateMemberRole},
|
|
49
|
-
* {@link
|
|
50
|
-
* own roster / invite form / role
|
|
87
|
+
* Headless member roster — a renderless view + invite / role-change / rename /
|
|
88
|
+
* removal controls over one workspace's members. Wires {@link useMembers},
|
|
89
|
+
* {@link useInviteMembers}, {@link useUpdateMemberRole},
|
|
90
|
+
* {@link useRenameMember} and {@link useRemoveMember} and hands a
|
|
91
|
+
* {@link MembersBag} to `children`; bring your own roster / invite form / role
|
|
92
|
+
* menu. Zero visual opinion (frontend-standard §2).
|
|
93
|
+
*
|
|
94
|
+
* `params` is the anchor page to read — the caller owns the walk, because the
|
|
95
|
+
* cursor belongs to the screen's state, not to this component's. Omitted, it
|
|
96
|
+
* reads the newest page, exactly as before.
|
|
51
97
|
*
|
|
52
98
|
* ```tsx
|
|
53
|
-
* <Members workspaceId={id}>
|
|
54
|
-
* {({ state, invite, updateRole, remove }) => ( ... )}
|
|
99
|
+
* <Members workspaceId={id} params={{ anchor, direction: "next", search }}>
|
|
100
|
+
* {({ state, page, invite, updateRole, remove }) => ( ... )}
|
|
55
101
|
* </Members>
|
|
56
102
|
* ```
|
|
57
103
|
*/
|
|
58
104
|
export function Members(props: {
|
|
59
105
|
workspaceId: string;
|
|
106
|
+
params?: MembersParams;
|
|
60
107
|
children: (bag: MembersBag) => ReactNode;
|
|
61
108
|
}): ReactNode {
|
|
62
|
-
const query = useMembers(props.workspaceId);
|
|
109
|
+
const query = useMembers(props.workspaceId, props.params);
|
|
63
110
|
const inviteMutation = useInviteMembers(props.workspaceId);
|
|
64
111
|
const roleMutation = useUpdateMemberRole(props.workspaceId);
|
|
112
|
+
const renameMutation = useRenameMember(props.workspaceId);
|
|
65
113
|
const removeMutation = useRemoveMember(props.workspaceId);
|
|
114
|
+
const data = query.data;
|
|
66
115
|
return props.children({
|
|
67
116
|
state: mapLoad(loadStateFromQuery(query), (page) => page.items),
|
|
117
|
+
rosterComplete: data !== undefined && !data.has_next && !data.has_prev,
|
|
118
|
+
page:
|
|
119
|
+
data === undefined
|
|
120
|
+
? null
|
|
121
|
+
: {
|
|
122
|
+
hasNext: data.has_next,
|
|
123
|
+
hasPrev: data.has_prev,
|
|
124
|
+
nextAnchor: data.next_anchor ?? null,
|
|
125
|
+
prevAnchor: data.prev_anchor ?? null,
|
|
126
|
+
count: data.count,
|
|
127
|
+
},
|
|
68
128
|
writeError:
|
|
69
|
-
inviteMutation.error ??
|
|
129
|
+
inviteMutation.error ??
|
|
130
|
+
roleMutation.error ??
|
|
131
|
+
renameMutation.error ??
|
|
132
|
+
removeMutation.error ??
|
|
133
|
+
null,
|
|
70
134
|
invite: (body) => {
|
|
71
135
|
inviteMutation.mutate(body);
|
|
72
136
|
},
|
|
@@ -75,6 +139,10 @@ export function Members(props: {
|
|
|
75
139
|
roleMutation.mutate(change);
|
|
76
140
|
},
|
|
77
141
|
isUpdatingRole: roleMutation.isPending,
|
|
142
|
+
rename: (change) => {
|
|
143
|
+
renameMutation.mutate(change);
|
|
144
|
+
},
|
|
145
|
+
isRenaming: renameMutation.isPending,
|
|
78
146
|
remove: (userId) => {
|
|
79
147
|
removeMutation.mutate(userId);
|
|
80
148
|
},
|
|
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { loadStateFromQuery, useI18n } from "@stapel/core";
|
|
3
3
|
import type { LoadState } from "@stapel/core";
|
|
4
4
|
import type { RoleInfo } from "../api/types.js";
|
|
5
|
+
import { titleCaseKey } from "../model/format.js";
|
|
5
6
|
import { useRoles } from "../model/queries.js";
|
|
6
7
|
|
|
7
8
|
/** Render-prop bag for {@link RoleSelect}. */
|
|
@@ -15,9 +16,15 @@ export interface RoleSelectBag {
|
|
|
15
16
|
* Display label for a role key: the i18n key `workspaces.role.<key>` when
|
|
16
17
|
* the current locale's merged bundle carries it (the pair ships the builtin
|
|
17
18
|
* four; a client bundle merges its own — e.g. `workspaces.role.secretary`),
|
|
18
|
-
* else the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* else the role key TITLE-CASED (`secretary` → `Secretary`, `site_admin` →
|
|
20
|
+
* `Site admin`).
|
|
21
|
+
*
|
|
22
|
+
* Two things this fallback is not. It is never the dotted i18n key: a
|
|
23
|
+
* deployment-defined role without a translation must still be pickable. And
|
|
24
|
+
* since the 2026-08-24 visual pass it is no longer the raw token either —
|
|
25
|
+
* `secretary` rendered lowercase in a list of title-cased builtin roles,
|
|
26
|
+
* which reads as broken data rather than as a missing translation. The
|
|
27
|
+
* label is cosmetic; the VALUE stays the registry key everywhere it is sent.
|
|
21
28
|
*/
|
|
22
29
|
labelFor(role: string): string;
|
|
23
30
|
}
|
|
@@ -52,10 +59,10 @@ export function RoleSelect(props: {
|
|
|
52
59
|
const i18n = useI18n();
|
|
53
60
|
// Raw bundle lookup, NOT `t()`: `t` falls back to the key itself, which is
|
|
54
61
|
// exactly the wrong fallback here (a deployment role without a translation
|
|
55
|
-
// must show
|
|
62
|
+
// must show a word, not `workspaces.role.secretary`).
|
|
56
63
|
const bundle = i18n.getBundle();
|
|
57
64
|
return props.children({
|
|
58
65
|
state: loadStateFromQuery(query),
|
|
59
|
-
labelFor: (role) => bundle[`workspaces.role.${role}`] ?? role,
|
|
66
|
+
labelFor: (role) => bundle[`workspaces.role.${role}`] ?? titleCaseKey(role),
|
|
60
67
|
});
|
|
61
68
|
}
|