@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +108 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +140 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +2 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +6 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/mandate.d.ts +23 -1
  84. package/dist/model/mandate.d.ts.map +1 -1
  85. package/dist/model/mandate.js +24 -0
  86. package/dist/model/mandate.js.map +1 -1
  87. package/dist/model/selection.d.ts +15 -0
  88. package/dist/model/selection.d.ts.map +1 -1
  89. package/dist/model/selection.js +18 -1
  90. package/dist/model/selection.js.map +1 -1
  91. package/dist/nav/manifest.d.ts +45 -0
  92. package/dist/nav/manifest.d.ts.map +1 -0
  93. package/dist/nav/manifest.js +77 -0
  94. package/dist/nav/manifest.js.map +1 -0
  95. package/llms.txt +11 -10
  96. package/manifest.json +282 -69
  97. package/package.json +18 -12
  98. package/src/analytics/generated/events.json +1 -1
  99. package/src/api/generated/schema.ts +187 -60
  100. package/src/default/ActiveWorkspace.tsx +97 -0
  101. package/src/default/AuditTrailPane.tsx +309 -0
  102. package/src/default/InvitationsPane.tsx +501 -0
  103. package/src/default/InviteAcceptPage.tsx +187 -90
  104. package/src/default/MembersManager.tsx +832 -222
  105. package/src/default/RoleSelectField.tsx +187 -0
  106. package/src/default/WorkspaceSettings.tsx +540 -125
  107. package/src/default/WorkspacesPage.tsx +387 -0
  108. package/src/default/index.ts +28 -7
  109. package/src/default/parts.tsx +308 -0
  110. package/src/headless/Members.tsx +84 -16
  111. package/src/headless/RoleSelect.tsx +12 -5
  112. package/src/i18n/es.ts +326 -14
  113. package/src/i18n/generated/errors.es.gen.ts +5 -0
  114. package/src/i18n/generated/errors.gen.ts +15 -0
  115. package/src/i18n/generated/errors.json +37 -0
  116. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  117. package/src/i18n/keys.ts +462 -5
  118. package/src/i18n/ru.ts +259 -5
  119. package/src/index.ts +6 -1
  120. package/src/model/format.ts +158 -0
  121. package/src/model/mandate.ts +26 -1
  122. package/src/model/selection.tsx +19 -1
  123. package/src/nav/manifest.ts +120 -0
  124. package/dist/default/ErrorAlert.d.ts +0 -8
  125. package/dist/default/ErrorAlert.d.ts.map +0 -1
  126. package/dist/default/ErrorAlert.js +0 -30
  127. package/dist/default/ErrorAlert.js.map +0 -1
  128. 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
+ };
@@ -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 one page (default 100); a full pager is out of scope
18
- * for this renderless view (bring your own via `useMembers`' `params` for
19
- * anchor pagination).
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
- /** An invite, a role change, or a removal failed. Never the read — that is
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 / removal
47
- * controls over one workspace's members. Wires {@link useMembers},
48
- * {@link useInviteMembers}, {@link useUpdateMemberRole}, and
49
- * {@link useRemoveMember} and hands a {@link MembersBag} to `children`; bring your
50
- * own roster / invite form / role menu. Zero visual opinion (frontend-standard §2).
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 ?? roleMutation.error ?? removeMutation.error ?? null,
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 RAW role key. The raw-name fallback is deliberate: a
19
- * deployment-defined role without a translation must still be pickable,
20
- * never render as a dotted i18n key.
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 its raw name, not `workspaces.role.secretary`).
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
  }