@stapel/workspaces-react 0.13.0 → 0.14.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 +36 -0
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +20 -6
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +7 -3
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/headless/Can.d.ts +8 -0
- package/dist/headless/Can.d.ts.map +1 -1
- package/dist/headless/Can.js +5 -1
- package/dist/headless/Can.js.map +1 -1
- package/dist/headless/Members.d.ts +14 -13
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +4 -12
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +15 -9
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +12 -8
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/headless/WorkspaceList.d.ts +25 -10
- package/dist/headless/WorkspaceList.d.ts.map +1 -1
- package/dist/headless/WorkspaceList.js +14 -5
- package/dist/headless/WorkspaceList.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +23 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/model/queries.d.ts +13 -7
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +9 -9
- package/dist/model/queries.js.map +1 -1
- package/dist/model/selection.d.ts +20 -7
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +32 -12
- package/dist/model/selection.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/MembersManager.tsx +69 -16
- package/src/default/WorkspaceSettings.tsx +41 -5
- package/src/headless/Can.tsx +13 -1
- package/src/headless/Members.tsx +18 -26
- package/src/headless/RoleSelect.tsx +17 -14
- package/src/headless/WorkspaceList.tsx +31 -14
- package/src/i18n/keys.ts +26 -0
- package/src/i18n/ru.ts +10 -0
- package/src/model/queries.ts +26 -16
- package/src/model/selection.tsx +73 -21
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { useState } from "react";
|
|
17
17
|
import type { ReactElement } from "react";
|
|
18
|
-
import { Button, Card, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
|
|
18
|
+
import { Button, Card, Empty, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
|
|
19
19
|
import type { TableProps } from "antd";
|
|
20
|
-
import { useErrorDisplay, useT } from "@stapel/core";
|
|
20
|
+
import { loadedRowsOrEmpty, matchList, useErrorDisplay, useT } from "@stapel/core";
|
|
21
21
|
import { Members } from "../headless/Members.js";
|
|
22
22
|
import { RoleSelect } from "../headless/RoleSelect.js";
|
|
23
23
|
import type { RoleSelectBag } from "../headless/RoleSelect.js";
|
|
@@ -54,9 +54,15 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
|
|
|
54
54
|
<RoleSelect>
|
|
55
55
|
{(rolesBag: RoleSelectBag) => (
|
|
56
56
|
<Members workspaceId={props.workspaceId}>
|
|
57
|
-
{({
|
|
57
|
+
{({ state, writeError, invite, isInviting, updateRole, remove, refetch }) => {
|
|
58
58
|
const { labelFor } = rolesBag;
|
|
59
|
-
|
|
59
|
+
// The registry read is a SEPARATE load from the roster's, and it
|
|
60
|
+
// gets its own sentence below rather than silently producing an
|
|
61
|
+
// empty picker — a role menu with nothing in it and a role menu
|
|
62
|
+
// that could not be fetched look identical to a person.
|
|
63
|
+
const rolesFailed = rolesBag.state.status === "failed";
|
|
64
|
+
const rolesLoading = rolesBag.state.status === "loading";
|
|
65
|
+
const roleOptions = loadedRowsOrEmpty(rolesBag.state).map((r) => ({
|
|
60
66
|
value: r.role,
|
|
61
67
|
label: labelFor(r.role),
|
|
62
68
|
}));
|
|
@@ -95,7 +101,7 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
|
|
|
95
101
|
<Select<string>
|
|
96
102
|
value={member.role}
|
|
97
103
|
style={{ width: 160 }}
|
|
98
|
-
loading={
|
|
104
|
+
loading={rolesLoading}
|
|
99
105
|
onChange={(next) => updateRole({ userId: member.user_id, role: next })}
|
|
100
106
|
// A member's CURRENT role may be missing from the
|
|
101
107
|
// registry options (a deployment removed an overlay
|
|
@@ -148,17 +154,64 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
|
|
|
148
154
|
)}
|
|
149
155
|
</div>
|
|
150
156
|
|
|
151
|
-
{
|
|
157
|
+
{/* A write that failed — the roster read has its own arm below. */}
|
|
158
|
+
{writeError !== null && (
|
|
159
|
+
<ErrorAlert error={errorDisplay(writeError)} style={{ marginTop: 12 }} />
|
|
160
|
+
)}
|
|
161
|
+
{rolesFailed && (
|
|
162
|
+
<ErrorAlert
|
|
163
|
+
testId="members-roles-error"
|
|
164
|
+
error={{ message: t(WORKSPACES_I18N_KEYS.rolesLoadFailed), detail: undefined }}
|
|
165
|
+
style={{ marginTop: 12 }}
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
152
168
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
{matchList(state, {
|
|
170
|
+
loading: () => (
|
|
171
|
+
<Table<Member>
|
|
172
|
+
style={{ marginTop: 16 }}
|
|
173
|
+
size="small"
|
|
174
|
+
loading
|
|
175
|
+
rowKey={(member) => member.id}
|
|
176
|
+
dataSource={[]}
|
|
177
|
+
columns={columns}
|
|
178
|
+
pagination={false}
|
|
179
|
+
/>
|
|
180
|
+
),
|
|
181
|
+
// An antd Table with no rows renders "No data". On a failed
|
|
182
|
+
// read that is the empty-state lie with a built-in
|
|
183
|
+
// illustration, so the table does not get rendered at all.
|
|
184
|
+
failed: (error) => (
|
|
185
|
+
<div style={{ marginTop: 16 }} data-testid="members-list-error">
|
|
186
|
+
<ErrorAlert error={errorDisplay(error)} />
|
|
187
|
+
<Button
|
|
188
|
+
style={{ marginTop: 12 }}
|
|
189
|
+
onClick={refetch}
|
|
190
|
+
data-analytics="none"
|
|
191
|
+
data-analytics-reason="local-ui-refetch-after-a-stated-read-failure"
|
|
192
|
+
>
|
|
193
|
+
{t(WORKSPACES_I18N_KEYS.retry)}
|
|
194
|
+
</Button>
|
|
195
|
+
</div>
|
|
196
|
+
),
|
|
197
|
+
empty: () => (
|
|
198
|
+
<Empty
|
|
199
|
+
style={{ marginTop: 16 }}
|
|
200
|
+
data-testid="members-list-empty"
|
|
201
|
+
description={t(WORKSPACES_I18N_KEYS.membersEmpty)}
|
|
202
|
+
/>
|
|
203
|
+
),
|
|
204
|
+
ready: (members) => (
|
|
205
|
+
<Table<Member>
|
|
206
|
+
style={{ marginTop: 16 }}
|
|
207
|
+
size="small"
|
|
208
|
+
rowKey={(member) => member.id}
|
|
209
|
+
dataSource={[...members]}
|
|
210
|
+
columns={columns}
|
|
211
|
+
pagination={false}
|
|
212
|
+
/>
|
|
213
|
+
),
|
|
214
|
+
})}
|
|
162
215
|
|
|
163
216
|
<Modal
|
|
164
217
|
title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
|
|
@@ -182,7 +235,7 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
|
|
|
182
235
|
value={inviteRole}
|
|
183
236
|
onChange={setInviteRole}
|
|
184
237
|
style={{ width: "100%" }}
|
|
185
|
-
loading={
|
|
238
|
+
loading={rolesLoading}
|
|
186
239
|
options={inviteRoleOptions}
|
|
187
240
|
/>
|
|
188
241
|
</div>
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
import { useEffect, useState } from "react";
|
|
10
10
|
import type { ReactElement } from "react";
|
|
11
11
|
import { Badge, Button, Card, Input, Popconfirm, Spin, Typography } from "antd";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
actionAvailable,
|
|
14
|
+
actionBlocked,
|
|
15
|
+
firstBlock,
|
|
16
|
+
useActionGate,
|
|
17
|
+
useErrorDisplay,
|
|
18
|
+
useT,
|
|
19
|
+
} from "@stapel/core";
|
|
13
20
|
import { useWorkspace } from "../model/queries.js";
|
|
14
21
|
import { useUpdateWorkspace, useDeleteWorkspace } from "../model/mutations.js";
|
|
15
22
|
import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -35,6 +42,17 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
|
|
|
35
42
|
const workspace = query.data;
|
|
36
43
|
const [name, setName] = useState("");
|
|
37
44
|
|
|
45
|
+
// Two unrelated situations used to share one `disabled={!isOwner ||
|
|
46
|
+
// !name.trim()}` bit, and a person facing either of them saw the same dead
|
|
47
|
+
// button. `firstBlock` keeps them two, in the order you would explain them.
|
|
48
|
+
const isOwner = workspace?.my_role === "owner";
|
|
49
|
+
const saveGate = useActionGate(
|
|
50
|
+
firstBlock(
|
|
51
|
+
isOwner ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNotOwner),
|
|
52
|
+
name.trim() ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNameRequired)
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
|
|
38
56
|
useEffect(() => {
|
|
39
57
|
if (workspace) setName(workspace.name);
|
|
40
58
|
}, [workspace]);
|
|
@@ -66,8 +84,6 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
|
|
|
66
84
|
);
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
const isOwner = workspace.my_role === "owner";
|
|
70
|
-
|
|
71
87
|
return (
|
|
72
88
|
<div data-testid="workspace-settings" style={{ display: "grid", gap: 16 }}>
|
|
73
89
|
<Card>
|
|
@@ -79,7 +95,18 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
|
|
|
79
95
|
<div style={{ display: "grid", gap: 12, maxWidth: 480, marginTop: 16 }}>
|
|
80
96
|
<div>
|
|
81
97
|
<Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldName)}</Typography.Text>
|
|
82
|
-
<Input
|
|
98
|
+
<Input
|
|
99
|
+
value={name}
|
|
100
|
+
onChange={(e) => setName(e.target.value)}
|
|
101
|
+
disabled={!isOwner}
|
|
102
|
+
/>
|
|
103
|
+
{!isOwner && (
|
|
104
|
+
<div>
|
|
105
|
+
<Typography.Text type="secondary" data-testid="workspace-name-blocked">
|
|
106
|
+
{t(WORKSPACES_I18N_KEYS.blockedNotOwner)}
|
|
107
|
+
</Typography.Text>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
83
110
|
</div>
|
|
84
111
|
<div>
|
|
85
112
|
<Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldSlug)}</Typography.Text>
|
|
@@ -101,13 +128,22 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
|
|
|
101
128
|
type="primary"
|
|
102
129
|
style={{ marginTop: 16 }}
|
|
103
130
|
loading={updateMutation.isPending}
|
|
104
|
-
disabled={
|
|
131
|
+
disabled={saveGate.disabled}
|
|
105
132
|
onClick={handleSave}
|
|
106
133
|
data-analytics="none"
|
|
107
134
|
data-analytics-reason="business action — host app wraps with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
|
|
108
135
|
>
|
|
109
136
|
{updateMutation.isPending ? t(WORKSPACES_I18N_KEYS.saving) : t(WORKSPACES_I18N_KEYS.save)}
|
|
110
137
|
</Button>
|
|
138
|
+
{/* Beside the control, as TEXT: a disabled button receives no pointer
|
|
139
|
+
events, so a tooltip on it is a reason nobody can read. */}
|
|
140
|
+
{saveGate.reason !== undefined && (
|
|
141
|
+
<div style={{ marginTop: 8 }}>
|
|
142
|
+
<Typography.Text type="secondary" data-testid="workspace-save-blocked">
|
|
143
|
+
{saveGate.reason}
|
|
144
|
+
</Typography.Text>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
111
147
|
</Card>
|
|
112
148
|
|
|
113
149
|
{isOwner && (
|
package/src/headless/Can.tsx
CHANGED
|
@@ -8,6 +8,14 @@ export interface CanBag {
|
|
|
8
8
|
/** The underlying read is still in flight (verdict is `false` meanwhile —
|
|
9
9
|
* deny-by-default; render a skeleton off this if flicker matters). */
|
|
10
10
|
readonly isLoading: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The read FAILED, so `allowed: false` means "we could not find out",
|
|
13
|
+
* not "you may not". Deny-by-default still holds — a capability check that
|
|
14
|
+
* opened up on an outage would be a security hole — but a skin with
|
|
15
|
+
* somewhere to put a sentence should say which of the two it is rather than
|
|
16
|
+
* silently withholding the control.
|
|
17
|
+
*/
|
|
18
|
+
readonly isUnknown: boolean;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -43,7 +51,11 @@ export function Can(props: {
|
|
|
43
51
|
const caps = useCapabilities(props.workspaceId);
|
|
44
52
|
const allowed = caps.can(props.capability);
|
|
45
53
|
if (typeof props.children === "function") {
|
|
46
|
-
return props.children({
|
|
54
|
+
return props.children({
|
|
55
|
+
allowed,
|
|
56
|
+
isLoading: caps.state.status === "loading",
|
|
57
|
+
isUnknown: caps.state.status === "failed",
|
|
58
|
+
});
|
|
47
59
|
}
|
|
48
60
|
return allowed ? props.children : (props.fallback ?? null);
|
|
49
61
|
}
|
package/src/headless/Members.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
3
4
|
import type { Member, MemberInvite } from "../api/types.js";
|
|
4
5
|
import { useMembers } from "../model/queries.js";
|
|
5
6
|
import {
|
|
@@ -12,18 +13,19 @@ import type { MemberRoleChange } from "../model/mutations.js";
|
|
|
12
13
|
/** Render-prop bag for {@link Members}. */
|
|
13
14
|
export interface MembersBag {
|
|
14
15
|
/**
|
|
15
|
-
* The
|
|
16
|
-
* (default 100)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 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).
|
|
20
|
+
*
|
|
21
|
+
* Separate from {@link writeError} on purpose: "the roster could not be
|
|
22
|
+
* loaded" and "your invite was rejected" are different sentences, and the
|
|
23
|
+
* old merged `isError` could only produce one of them.
|
|
19
24
|
*/
|
|
20
|
-
readonly
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
readonly isError: boolean;
|
|
25
|
-
/** The error, when `isError` (a localizable `StapelApiError`), else null. */
|
|
26
|
-
readonly error: StapelApiError | null;
|
|
25
|
+
readonly state: LoadState<readonly Member[]>;
|
|
26
|
+
/** An invite, a role change, or a removal failed. Never the read — that is
|
|
27
|
+
* {@link state}. */
|
|
28
|
+
readonly writeError: StapelApiError | null;
|
|
27
29
|
/** Invite one or more emails at a role. */
|
|
28
30
|
invite(body: MemberInvite): void;
|
|
29
31
|
/** An invite call is in flight. */
|
|
@@ -49,7 +51,7 @@ export interface MembersBag {
|
|
|
49
51
|
*
|
|
50
52
|
* ```tsx
|
|
51
53
|
* <Members workspaceId={id}>
|
|
52
|
-
* {({
|
|
54
|
+
* {({ state, invite, updateRole, remove }) => ( ... )}
|
|
53
55
|
* </Members>
|
|
54
56
|
* ```
|
|
55
57
|
*/
|
|
@@ -62,19 +64,9 @@ export function Members(props: {
|
|
|
62
64
|
const roleMutation = useUpdateMemberRole(props.workspaceId);
|
|
63
65
|
const removeMutation = useRemoveMember(props.workspaceId);
|
|
64
66
|
return props.children({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
query.isError ||
|
|
69
|
-
inviteMutation.isError ||
|
|
70
|
-
roleMutation.isError ||
|
|
71
|
-
removeMutation.isError,
|
|
72
|
-
error:
|
|
73
|
-
query.error ??
|
|
74
|
-
inviteMutation.error ??
|
|
75
|
-
roleMutation.error ??
|
|
76
|
-
removeMutation.error ??
|
|
77
|
-
null,
|
|
67
|
+
state: mapLoad(loadStateFromQuery(query), (page) => page.items),
|
|
68
|
+
writeError:
|
|
69
|
+
inviteMutation.error ?? roleMutation.error ?? removeMutation.error ?? null,
|
|
78
70
|
invite: (body) => {
|
|
79
71
|
inviteMutation.mutate(body);
|
|
80
72
|
},
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { useI18n } from "@stapel/core";
|
|
3
|
-
import type {
|
|
2
|
+
import { loadStateFromQuery, useI18n } from "@stapel/core";
|
|
3
|
+
import type { LoadState } from "@stapel/core";
|
|
4
4
|
import type { RoleInfo } from "../api/types.js";
|
|
5
5
|
import { useRoles } from "../model/queries.js";
|
|
6
6
|
|
|
7
7
|
/** Render-prop bag for {@link RoleSelect}. */
|
|
8
8
|
export interface RoleSelectBag {
|
|
9
9
|
/** The effective registry (GET /roles): builtin four + the deployment's
|
|
10
|
-
* overlay, rank-descending.
|
|
11
|
-
|
|
10
|
+
* overlay, rank-descending. A state rather than an array: a role picker
|
|
11
|
+
* built from `[]` because the registry read FAILED offers no roles at all
|
|
12
|
+
* and looks exactly like a deployment that defined none. */
|
|
13
|
+
readonly state: LoadState<readonly RoleInfo[]>;
|
|
12
14
|
/**
|
|
13
15
|
* Display label for a role key: the i18n key `workspaces.role.<key>` when
|
|
14
16
|
* the current locale's merged bundle carries it (the pair ships the builtin
|
|
@@ -18,9 +20,6 @@ export interface RoleSelectBag {
|
|
|
18
20
|
* never render as a dotted i18n key.
|
|
19
21
|
*/
|
|
20
22
|
labelFor(role: string): string;
|
|
21
|
-
readonly isLoading: boolean;
|
|
22
|
-
readonly isError: boolean;
|
|
23
|
-
readonly error: StapelApiError | null;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -30,9 +29,16 @@ export interface RoleSelectBag {
|
|
|
30
29
|
*
|
|
31
30
|
* ```tsx
|
|
32
31
|
* <RoleSelect>
|
|
33
|
-
* {({
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* {({ state, labelFor }) =>
|
|
33
|
+
* matchList(state, {
|
|
34
|
+
* loading: () => <Select loading options={[]} />,
|
|
35
|
+
* failed: (error) => <RoleLoadFailed error={error} />,
|
|
36
|
+
* empty: () => <Select disabled options={[]} />,
|
|
37
|
+
* ready: (roles) => (
|
|
38
|
+
* <Select options={roles.map((r) => ({ value: r.role, label: labelFor(r.role) }))} />
|
|
39
|
+
* ),
|
|
40
|
+
* })
|
|
41
|
+
* }
|
|
36
42
|
* </RoleSelect>
|
|
37
43
|
* ```
|
|
38
44
|
*
|
|
@@ -49,10 +55,7 @@ export function RoleSelect(props: {
|
|
|
49
55
|
// must show its raw name, not `workspaces.role.secretary`).
|
|
50
56
|
const bundle = i18n.getBundle();
|
|
51
57
|
return props.children({
|
|
52
|
-
|
|
58
|
+
state: loadStateFromQuery(query),
|
|
53
59
|
labelFor: (role) => bundle[`workspaces.role.${role}`] ?? role,
|
|
54
|
-
isLoading: query.isLoading,
|
|
55
|
-
isError: query.isError,
|
|
56
|
-
error: query.error ?? null,
|
|
57
60
|
});
|
|
58
61
|
}
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
3
4
|
import type { Workspace, WorkspaceCreate } from "../api/types.js";
|
|
4
5
|
import { useWorkspaces } from "../model/queries.js";
|
|
5
6
|
import { useCreateWorkspace } from "../model/mutations.js";
|
|
6
7
|
|
|
7
8
|
/** Render-prop bag for {@link WorkspaceList}. */
|
|
8
9
|
export interface WorkspaceListBag {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The read, as a state a skin cannot flatten: `loading` / `ready` with the
|
|
12
|
+
* rows / `failed` with the error. Render it with core's `matchList`, whose
|
|
13
|
+
* four arms are all required, so "you have no workspaces" can only be said
|
|
14
|
+
* about a load that actually succeeded.
|
|
15
|
+
*
|
|
16
|
+
* This replaced a `workspaces: readonly Workspace[]` field that was `[]` in
|
|
17
|
+
* all three cases. On 2026-08-09 the list endpoint answered 404 for hours
|
|
18
|
+
* and every screen built on that field said the person had no workspaces.
|
|
19
|
+
* The bag carried `isError` beside it the whole time; nothing forced a skin
|
|
20
|
+
* to look, so nothing did.
|
|
21
|
+
*/
|
|
22
|
+
readonly state: LoadState<readonly Workspace[]>;
|
|
17
23
|
/** Create a workspace (owner-seeded). */
|
|
18
24
|
create(body: WorkspaceCreate): void;
|
|
19
25
|
/** A create call is in flight. */
|
|
20
26
|
readonly isCreating: boolean;
|
|
21
27
|
/** The workspace created by the last `create`, else null. */
|
|
22
28
|
readonly created: Workspace | null;
|
|
29
|
+
/** The CREATE's failure — a write, and therefore a different question from
|
|
30
|
+
* {@link state}, which is about the read. */
|
|
31
|
+
readonly createError: StapelApiError | null;
|
|
23
32
|
/** Refetch the list from the server. */
|
|
24
33
|
refetch(): void;
|
|
25
34
|
}
|
|
@@ -32,7 +41,14 @@ export interface WorkspaceListBag {
|
|
|
32
41
|
*
|
|
33
42
|
* ```tsx
|
|
34
43
|
* <WorkspaceList>
|
|
35
|
-
* {({
|
|
44
|
+
* {({ state, create }) =>
|
|
45
|
+
* matchList(state, {
|
|
46
|
+
* loading: () => <Spinner />,
|
|
47
|
+
* failed: (error) => <ErrorPanel error={error} onRetry={refetch} />,
|
|
48
|
+
* empty: () => <FirstWorkspacePrompt onCreate={create} />,
|
|
49
|
+
* ready: (workspaces) => <List items={workspaces} />,
|
|
50
|
+
* })
|
|
51
|
+
* }
|
|
36
52
|
* </WorkspaceList>
|
|
37
53
|
* ```
|
|
38
54
|
*/
|
|
@@ -42,15 +58,16 @@ export function WorkspaceList(props: {
|
|
|
42
58
|
const query = useWorkspaces();
|
|
43
59
|
const mutation = useCreateWorkspace();
|
|
44
60
|
return props.children({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
// `workspaces` is optional in the wire schema, so a SUCCESSFUL response
|
|
62
|
+
// may legitimately carry none. That is an empty list, not a failed load —
|
|
63
|
+
// the distinction this whole field exists for is already made above it.
|
|
64
|
+
state: mapLoad(loadStateFromQuery(query), (list) => list.workspaces ?? []),
|
|
49
65
|
create: (body) => {
|
|
50
66
|
mutation.mutate(body);
|
|
51
67
|
},
|
|
52
68
|
isCreating: mutation.isPending,
|
|
53
69
|
created: mutation.data ?? null,
|
|
70
|
+
createError: mutation.error ?? null,
|
|
54
71
|
refetch: () => {
|
|
55
72
|
void query.refetch();
|
|
56
73
|
},
|
package/src/i18n/keys.ts
CHANGED
|
@@ -12,13 +12,26 @@ import { workspacesErrorBundleEn } from "./generated/errors.gen.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export const WORKSPACES_I18N_KEYS = {
|
|
14
14
|
unknownError: "workspaces.error.unknown",
|
|
15
|
+
// Retry affordance beside a stated failure — an error a person cannot act
|
|
16
|
+
// on is only half the message.
|
|
17
|
+
retry: "workspaces.retry",
|
|
15
18
|
// Workspace list (WorkspaceList headless)
|
|
16
19
|
listLoading: "workspaces.list.loading",
|
|
17
20
|
listEmpty: "workspaces.list.empty",
|
|
21
|
+
/**
|
|
22
|
+
* "We could not load your workspaces" — the sentence whose ABSENCE was the
|
|
23
|
+
* 2026-08-09 incident. Deliberately about US failing to load, never about
|
|
24
|
+
* the person having none, and never a 404's "no longer available" (which
|
|
25
|
+
* asserts the thing does not exist — tracker #211).
|
|
26
|
+
*/
|
|
27
|
+
listLoadFailed: "workspaces.list.load_failed",
|
|
18
28
|
listCreate: "workspaces.list.create",
|
|
19
29
|
listCreating: "workspaces.list.creating",
|
|
20
30
|
// Members (Members headless)
|
|
21
31
|
membersLoading: "workspaces.members.loading",
|
|
32
|
+
membersEmpty: "workspaces.members.empty",
|
|
33
|
+
membersLoadFailed: "workspaces.members.load_failed",
|
|
34
|
+
rolesLoadFailed: "workspaces.roles.load_failed",
|
|
22
35
|
membersInvite: "workspaces.members.invite",
|
|
23
36
|
membersInviting: "workspaces.members.inviting",
|
|
24
37
|
membersUpdateRole: "workspaces.members.update_role",
|
|
@@ -34,6 +47,9 @@ export const WORKSPACES_I18N_KEYS = {
|
|
|
34
47
|
fieldSlug: "workspaces.settings.field.slug",
|
|
35
48
|
fieldType: "workspaces.settings.field.type",
|
|
36
49
|
save: "workspaces.settings.save",
|
|
50
|
+
// Why a settings control is switched off (core's `useActionGate`).
|
|
51
|
+
blockedNotOwner: "workspaces.settings.blocked.not_owner",
|
|
52
|
+
blockedNameRequired: "workspaces.settings.blocked.name_required",
|
|
37
53
|
saving: "workspaces.settings.saving",
|
|
38
54
|
dangerZoneTitle: "workspaces.settings.danger_zone.title",
|
|
39
55
|
deleteWorkspace: "workspaces.settings.danger_zone.delete",
|
|
@@ -102,11 +118,18 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
102
118
|
|
|
103
119
|
// workspaces-react UI
|
|
104
120
|
"workspaces.error.unknown": "Something went wrong. Please try again.",
|
|
121
|
+
"workspaces.retry": "Try again",
|
|
105
122
|
"workspaces.list.loading": "Loading workspaces…",
|
|
106
123
|
"workspaces.list.empty": "No workspaces yet.",
|
|
124
|
+
"workspaces.list.load_failed":
|
|
125
|
+
"We could not load your workspaces. This is a problem on our side, not a sign that you have none.",
|
|
107
126
|
"workspaces.list.create": "Create workspace",
|
|
108
127
|
"workspaces.list.creating": "Creating…",
|
|
109
128
|
"workspaces.members.loading": "Loading members…",
|
|
129
|
+
"workspaces.members.empty": "No members yet.",
|
|
130
|
+
"workspaces.members.load_failed": "We could not load the member list.",
|
|
131
|
+
"workspaces.roles.load_failed":
|
|
132
|
+
"We could not load the role list, so the role picker is empty. It is not a workspace without roles.",
|
|
110
133
|
"workspaces.members.invite": "Invite",
|
|
111
134
|
"workspaces.members.inviting": "Inviting…",
|
|
112
135
|
"workspaces.members.update_role": "Change role",
|
|
@@ -120,6 +143,9 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
120
143
|
"workspaces.settings.field.slug": "URL slug",
|
|
121
144
|
"workspaces.settings.field.type": "Type",
|
|
122
145
|
"workspaces.settings.save": "Save changes",
|
|
146
|
+
"workspaces.settings.blocked.not_owner":
|
|
147
|
+
"Only the workspace owner can change these settings.",
|
|
148
|
+
"workspaces.settings.blocked.name_required": "Enter a workspace name.",
|
|
123
149
|
"workspaces.settings.saving": "Saving…",
|
|
124
150
|
"workspaces.settings.danger_zone.title": "Danger zone",
|
|
125
151
|
"workspaces.settings.danger_zone.delete": "Delete workspace",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -25,11 +25,18 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
25
25
|
|
|
26
26
|
// workspaces-react UI (hand-written ru mirror of the en copy in keys.ts)
|
|
27
27
|
"workspaces.error.unknown": "Что-то пошло не так. Попробуйте ещё раз.",
|
|
28
|
+
"workspaces.retry": "Попробовать снова",
|
|
28
29
|
"workspaces.list.loading": "Загрузка рабочих пространств…",
|
|
29
30
|
"workspaces.list.empty": "Пока нет рабочих пространств.",
|
|
31
|
+
"workspaces.list.load_failed":
|
|
32
|
+
"Не удалось загрузить ваши рабочие пространства. Это сбой на нашей стороне, а не признак того, что их у вас нет.",
|
|
30
33
|
"workspaces.list.create": "Создать рабочее пространство",
|
|
31
34
|
"workspaces.list.creating": "Создание…",
|
|
32
35
|
"workspaces.members.loading": "Загрузка участников…",
|
|
36
|
+
"workspaces.members.empty": "Пока нет участников.",
|
|
37
|
+
"workspaces.members.load_failed": "Не удалось загрузить список участников.",
|
|
38
|
+
"workspaces.roles.load_failed":
|
|
39
|
+
"Не удалось загрузить список ролей, поэтому выбор роли пуст. Это не рабочее пространство без ролей.",
|
|
33
40
|
"workspaces.members.invite": "Пригласить",
|
|
34
41
|
"workspaces.members.inviting": "Приглашение…",
|
|
35
42
|
"workspaces.members.update_role": "Изменить роль",
|
|
@@ -43,6 +50,9 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
43
50
|
"workspaces.settings.field.slug": "URL-идентификатор",
|
|
44
51
|
"workspaces.settings.field.type": "Тип",
|
|
45
52
|
"workspaces.settings.save": "Сохранить изменения",
|
|
53
|
+
"workspaces.settings.blocked.not_owner":
|
|
54
|
+
"Менять эти настройки может только владелец рабочего пространства.",
|
|
55
|
+
"workspaces.settings.blocked.name_required": "Введите название рабочего пространства.",
|
|
46
56
|
"workspaces.settings.saving": "Сохранение…",
|
|
47
57
|
"workspaces.settings.danger_zone.title": "Опасная зона",
|
|
48
58
|
"workspaces.settings.danger_zone.delete": "Удалить рабочее пространство",
|
package/src/model/queries.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type {
|
|
|
4
4
|
UseInfiniteQueryResult,
|
|
5
5
|
UseQueryResult,
|
|
6
6
|
} from "@tanstack/react-query";
|
|
7
|
-
import { useActiveSessionReady } from "@stapel/core";
|
|
8
|
-
import type { StapelApiError } from "@stapel/core";
|
|
7
|
+
import { loadStateFromQuery, mapLoad, useActiveSessionReady } from "@stapel/core";
|
|
8
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
9
9
|
import type {
|
|
10
10
|
InvitationPage,
|
|
11
11
|
InvitationPreview,
|
|
@@ -223,15 +223,21 @@ export function useInvitationPreview(
|
|
|
223
223
|
/** What {@link useCapabilities} returns: the caller's granted capability
|
|
224
224
|
* strings in one workspace plus the wildcard-aware `can()` check. */
|
|
225
225
|
export interface CapabilitiesResult {
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The capability read, as a state. `ready` with the verbatim registry
|
|
228
|
+
* strings of the caller's role (wildcards included; `[]` for a non-member,
|
|
229
|
+
* and for a backend older than 0.6.0 which does not send the field at all).
|
|
230
|
+
*
|
|
231
|
+
* A state and not an array, because "you may not do this" and "we could not
|
|
232
|
+
* find out whether you may" both end in a hidden button, and only one of
|
|
233
|
+
* them should end in silence. `can()` stays deny-by-default in both — that
|
|
234
|
+
* part is a security property and does not change — but a screen that wants
|
|
235
|
+
* to SAY why can now tell them apart.
|
|
236
|
+
*/
|
|
237
|
+
readonly state: LoadState<readonly string[]>;
|
|
229
238
|
/** Wildcard-aware check (`*` / `prefix.*` — the backend matcher, ported).
|
|
230
239
|
* UI convenience only: the backend re-checks on every operation. */
|
|
231
240
|
can(capability: string): boolean;
|
|
232
|
-
readonly isLoading: boolean;
|
|
233
|
-
readonly isError: boolean;
|
|
234
|
-
readonly error: StapelApiError | null;
|
|
235
241
|
}
|
|
236
242
|
|
|
237
243
|
/**
|
|
@@ -243,13 +249,17 @@ export interface CapabilitiesResult {
|
|
|
243
249
|
*/
|
|
244
250
|
export function useCapabilities(workspaceId: string | null): CapabilitiesResult {
|
|
245
251
|
const query = useWorkspace(workspaceId);
|
|
246
|
-
|
|
252
|
+
// `my_capabilities` is additive since stapel-workspaces 0.6.0, so a
|
|
253
|
+
// SUCCESSFUL read against an older backend legitimately carries none. That
|
|
254
|
+
// is an empty grant, not a failed load, and the two must not merge.
|
|
255
|
+
const state = mapLoad(
|
|
256
|
+
loadStateFromQuery(query),
|
|
257
|
+
(workspace) => workspace.my_capabilities ?? []
|
|
258
|
+
);
|
|
247
259
|
return {
|
|
248
|
-
|
|
249
|
-
can: (capability) =>
|
|
250
|
-
|
|
251
|
-
isError: query.isError,
|
|
252
|
-
error: query.error ?? null,
|
|
260
|
+
state,
|
|
261
|
+
can: (capability) =>
|
|
262
|
+
state.status === "ready" && hasCapability(state.data, capability),
|
|
253
263
|
};
|
|
254
264
|
}
|
|
255
265
|
|
|
@@ -299,7 +309,7 @@ export function useCapabilityGate(
|
|
|
299
309
|
workspaceId: string | null,
|
|
300
310
|
capability: string
|
|
301
311
|
): CapabilityGate {
|
|
302
|
-
const { can,
|
|
312
|
+
const { can, state } = useCapabilities(workspaceId);
|
|
303
313
|
const level = capabilityLevel(capability);
|
|
304
314
|
return {
|
|
305
315
|
capability,
|
|
@@ -307,6 +317,6 @@ export function useCapabilityGate(
|
|
|
307
317
|
level,
|
|
308
318
|
requiresStepUp: level === "high",
|
|
309
319
|
stepUpScope: level === "high" ? SENSITIVE_SCOPE : null,
|
|
310
|
-
isLoading,
|
|
320
|
+
isLoading: state.status === "loading",
|
|
311
321
|
};
|
|
312
322
|
}
|