@stapel/workspaces-react 0.12.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 +84 -0
- package/MODULE.md +5 -1
- package/README.md +22 -0
- package/dist/api/generated/schema.d.ts +13 -8
- package/dist/api/generated/schema.d.ts.map +1 -1
- 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/es.d.ts +34 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +43 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +76 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -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 +1 -0
- package/dist/i18n/generated/errors.ru.gen.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 +3 -3
- package/manifest.json +16 -3
- package/package.json +12 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +13 -8
- 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/es.ts +47 -0
- package/src/i18n/generated/errors.es.gen.ts +82 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- 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
|
@@ -318,9 +318,14 @@ export interface paths {
|
|
|
318
318
|
* @description ``PATCH <ws>/members/<user_id>/name`` — correct a member's display name.
|
|
319
319
|
*
|
|
320
320
|
* Writes the CANONICAL name: stapel-profiles' ``Profile.display_name``,
|
|
321
|
-
*
|
|
322
|
-
* (``
|
|
323
|
-
* ``
|
|
321
|
+
* through the named write that module publishes
|
|
322
|
+
* (``profiles.set_display_name``, called by
|
|
323
|
+
* ``services.set_profile_display_name``), which validates against its own
|
|
324
|
+
* canon and publishes ``profile.changed`` so every consumer of that name
|
|
325
|
+
* follows. Topology-independent: the same call runs in-process in a
|
|
326
|
+
* monolith and over the configured route where profiles is its own
|
|
327
|
+
* container. Until 0.21.0 it was dotted-path symbol resolution instead,
|
|
328
|
+
* and this endpoint simply did not work in a split deployment.
|
|
324
329
|
*
|
|
325
330
|
* NOT ``WorkspaceMember.display_name_hint``: the hint is a pre-profile
|
|
326
331
|
* placeholder, copied once at creation and dark from the moment a real
|
|
@@ -328,11 +333,11 @@ export interface paths {
|
|
|
328
333
|
* produce a correction the roster shows and nothing else in the product
|
|
329
334
|
* ever does — including, eventually, the roster.
|
|
330
335
|
*
|
|
331
|
-
* Where
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* write that did not happen.
|
|
336
|
+
* Where the write cannot be performed the answer is a 503 that names its
|
|
337
|
+
* own cause — ``error.503.profiles_not_configured`` when this deployment
|
|
338
|
+
* has neither a provider nor a route (an operator's job, and it will not
|
|
339
|
+
* heal on its own), ``error.503.profiles_unavailable`` when the call was
|
|
340
|
+
* made and failed — never a 200 over a write that did not happen.
|
|
336
341
|
*
|
|
337
342
|
* Only an owner may rename an owner — the same hardcoded owner protection
|
|
338
343
|
* that role changes, removals and password resets carry. Renaming is not
|
|
@@ -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/es.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { workspacesI18nBundleEn } from "./keys.js";
|
|
3
|
+
import { workspacesErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
4
|
+
|
|
5
|
+
export { workspacesErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Spanish bundle for workspaces-react — the pair's `es` locale, shipped as the
|
|
9
|
+
* `@stapel/workspaces-react/i18n/es` subpath (i18n-shipping.md §2) so the locale is
|
|
10
|
+
* opt-in: hosts that don't register it never carry these strings (the main
|
|
11
|
+
* entry does not import this module — gated by size-limit + the tree-shake
|
|
12
|
+
* purity test).
|
|
13
|
+
*
|
|
14
|
+
* COVERAGE IS DECLARED, NOT DISCOVERED. This bundle carries the GENERATED
|
|
15
|
+
* backend error texts (from stapel-workspaces's `translations/errors.es.json`
|
|
16
|
+
* catalog — `pnpm gen:errors`), complete over the error registry by
|
|
17
|
+
* construction: the generator fails on a gap and the Record type fails
|
|
18
|
+
* compilation on drift. The pair-owned UI keys (`WORKSPACES_I18N_KEYS`) are NOT
|
|
19
|
+
* translated to Spanish yet and deliberately fall back to their English text
|
|
20
|
+
* through the en floor that {@link registerWorkspacesI18nEs} registers underneath.
|
|
21
|
+
* So a Spanish-speaking user reads Spanish error messages and English UI copy —
|
|
22
|
+
* never a raw key. That state is asserted in `test/i18nEs.test.ts`; whoever
|
|
23
|
+
* adds hand-written Spanish UI copy flips those assertions deliberately.
|
|
24
|
+
*
|
|
25
|
+
* Adding that copy later is additive and host-invisible: this const and the
|
|
26
|
+
* `./i18n/es` subpath keep their names and shapes when it lands.
|
|
27
|
+
*/
|
|
28
|
+
export const workspacesI18nBundleEs: I18nDictionary = {
|
|
29
|
+
// Backend error codes — generated es texts (coverage by construction).
|
|
30
|
+
...workspacesErrorBundleEs,
|
|
31
|
+
|
|
32
|
+
// No hand-written Spanish UI copy yet — see COVERAGE above. Pair-owned keys
|
|
33
|
+
// resolve through the en floor until it exists.
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register the pair's `es` locale into a core i18n engine (call once at
|
|
38
|
+
* startup, after {@link registerWorkspacesI18n}). Layers per the merge-priority
|
|
39
|
+
* convention (i18n-shipping.md §3): the en floor is registered UNDER the es
|
|
40
|
+
* texts inside the `es` locale, so any key the es bundle does not carry —
|
|
41
|
+
* today, every pair-owned UI key — degrades to its English text rather than to
|
|
42
|
+
* a raw key. A host bundle registered after this call overrides both.
|
|
43
|
+
*/
|
|
44
|
+
export function registerWorkspacesI18nEs(engine: I18nEngine): void {
|
|
45
|
+
engine.registerBundle("es", workspacesI18nBundleEn);
|
|
46
|
+
engine.registerBundle("es", workspacesI18nBundleEs);
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/gen-errors.mjs — do not edit by hand.
|
|
2
|
+
// Source: stapel-auth canonical errors.json (backend codegen artifact).
|
|
3
|
+
// Regenerate: pnpm gen:errors · Drift gate: pnpm gen:errors:check
|
|
4
|
+
// Locale source: backend translations/errors.es.json (locale catalog
|
|
5
|
+
// beside the canonical errors.json — i18n-shipping.md §1).
|
|
6
|
+
import type { WorkspacesErrorCode } from "./errors.gen.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `es` texts for every backend error code — complete over the registry by
|
|
10
|
+
* construction (the generator fails on a gap; the Record type fails compilation
|
|
11
|
+
* on drift). Import from the pair's `./i18n/es` subpath, NOT the main
|
|
12
|
+
* entry, so the locale stays out of hosts that don't ship it.
|
|
13
|
+
*/
|
|
14
|
+
export const workspacesErrorBundleEs: Record<WorkspacesErrorCode, string> = {
|
|
15
|
+
"error.400.already_workspace_member": "El usuario ya es miembro de este espacio de trabajo",
|
|
16
|
+
"error.400.bad_request": "Solicitud incorrecta",
|
|
17
|
+
"error.400.captcha_invalid": "La verificación del captcha ha fallado. Inténtalo de nuevo.",
|
|
18
|
+
"error.400.captcha_required": "Se requiere el token del captcha.",
|
|
19
|
+
"error.400.display_name_emoji": "El nombre para mostrar no puede contener emojis",
|
|
20
|
+
"error.400.display_name_forbidden_chars": "El nombre para mostrar contiene caracteres no permitidos",
|
|
21
|
+
"error.400.display_name_invisible_chars": "El nombre para mostrar contiene caracteres invisibles",
|
|
22
|
+
"error.400.display_name_too_short": "El nombre para mostrar debe tener al menos 2 caracteres",
|
|
23
|
+
"error.400.expected_list": "Se esperaba una lista de elementos",
|
|
24
|
+
"error.400.field.blank": "{field} no puede estar vacío",
|
|
25
|
+
"error.400.field.does_not_exist": "{field} no existe",
|
|
26
|
+
"error.400.field.invalid": "{field} no es válido",
|
|
27
|
+
"error.400.field.invalid_choice": "{field} no es una opción válida",
|
|
28
|
+
"error.400.field.max_length": "{field} debe tener como máximo {max_length} caracteres",
|
|
29
|
+
"error.400.field.max_value": "{field} debe ser como máximo {max_value}",
|
|
30
|
+
"error.400.field.min_length": "{field} debe tener al menos {min_length} caracteres",
|
|
31
|
+
"error.400.field.min_value": "{field} debe ser como mínimo {min_value}",
|
|
32
|
+
"error.400.field.null": "{field} no puede ser nulo",
|
|
33
|
+
"error.400.field.required": "{field} es obligatorio",
|
|
34
|
+
"error.400.field.unique": "{field} debe ser único",
|
|
35
|
+
"error.400.invalid_ad_id": "ID de anuncio no válido",
|
|
36
|
+
"error.400.invalid_provision_username": "Nombre de usuario no válido para una cuenta aprovisionada",
|
|
37
|
+
"error.400.invalid_role": "Rol no válido",
|
|
38
|
+
"error.400.invitation_already_used": "La invitación ya se ha utilizado",
|
|
39
|
+
"error.400.invitation_declined": "La invitación ha sido rechazada",
|
|
40
|
+
"error.400.invitation_expired": "La invitación ha caducado",
|
|
41
|
+
"error.400.invitation_revoked": "La invitación ha sido revocada",
|
|
42
|
+
"error.400.validation_error": "Error de validación",
|
|
43
|
+
"error.400.verification_failed": "La verificación ha fallado",
|
|
44
|
+
"error.400.verification_invalid_factor": "Este factor de verificación no está disponible",
|
|
45
|
+
"error.400.workspace_slug_taken": "El slug del espacio de trabajo ya está en uso",
|
|
46
|
+
"error.401.unauthorized": "Se requiere autenticación",
|
|
47
|
+
"error.402.entitlement_required": "El plan del propietario del espacio de trabajo no incluye esta función",
|
|
48
|
+
"error.402.member_limit_reached": "Se ha alcanzado el límite de miembros del espacio de trabajo ({limit})",
|
|
49
|
+
"error.402.payment_required": "Se requiere pago",
|
|
50
|
+
"error.403.forbidden": "No tienes permiso para realizar esta acción",
|
|
51
|
+
"error.403.forbidden_workspace": "No tienes acceso a este espacio de trabajo",
|
|
52
|
+
"error.403.last_owner_cannot_be_removed": "El último propietario no se puede eliminar; primero transfiere la propiedad",
|
|
53
|
+
"error.403.membership_suspended": "Tu pertenencia a este espacio de trabajo está suspendida ({reason})",
|
|
54
|
+
"error.403.missing_capability": "Tu rol no incluye la capacidad {capability} en este espacio de trabajo",
|
|
55
|
+
"error.403.network_blocked": "No se permiten solicitudes desde esta red.",
|
|
56
|
+
"error.403.role_exceeds_inviter_rank": "No puedes conceder un rol superior al tuyo ({role})",
|
|
57
|
+
"error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
|
|
58
|
+
"error.403.verification_required": "Se requiere verificación adicional",
|
|
59
|
+
"error.404.ad_not_found": "Anuncio no encontrado",
|
|
60
|
+
"error.404.invitation_not_found": "Invitación no encontrada",
|
|
61
|
+
"error.404.member_not_found": "Miembro no encontrado en este espacio de trabajo",
|
|
62
|
+
"error.404.not_found": "Recurso solicitado no encontrado",
|
|
63
|
+
"error.404.verification_challenge_not_found": "Desafío de verificación no encontrado o caducado",
|
|
64
|
+
"error.404.workspace_not_found": "Espacio de trabajo no encontrado",
|
|
65
|
+
"error.405.method_not_allowed": "Método no permitido",
|
|
66
|
+
"error.406.not_acceptable": "No aceptable",
|
|
67
|
+
"error.408.request_timeout": "Tiempo de espera de la solicitud agotado",
|
|
68
|
+
"error.409.conflict": "El recurso ya existe",
|
|
69
|
+
"error.409.email_already_registered": "Ya existe una cuenta con este correo electrónico: inicia sesión en su lugar",
|
|
70
|
+
"error.410.gone": "El recurso se ha eliminado permanentemente",
|
|
71
|
+
"error.413.payload_too_large": "El cuerpo de la solicitud es demasiado grande",
|
|
72
|
+
"error.415.unsupported_media_type": "Tipo de contenido no compatible",
|
|
73
|
+
"error.422.unprocessable_entity": "Entidad no procesable",
|
|
74
|
+
"error.423.locked": "El recurso está bloqueado",
|
|
75
|
+
"error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
|
|
76
|
+
"error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
|
|
77
|
+
"error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
|
|
78
|
+
"error.500.internal": "Algo salió mal",
|
|
79
|
+
"error.503.auth_unavailable": "El servicio de autenticación no está disponible; inténtalo de nuevo más tarde",
|
|
80
|
+
"error.503.profiles_not_configured": "Este despliegue no tiene configurado un servicio de perfiles, por lo que aquí no se puede escribir un nombre para mostrar",
|
|
81
|
+
"error.503.profiles_unavailable": "El servicio de perfiles no está disponible; inténtalo de nuevo más tarde",
|
|
82
|
+
};
|
|
@@ -100,6 +100,7 @@ export const WORKSPACES_ERRORS = {
|
|
|
100
100
|
"error.429.too_many_requests": { status: 429, params: [], remediation: "wait_and_retry", en: "Too many requests. Please try again later." },
|
|
101
101
|
"error.500.internal": { status: 500, params: [], remediation: "contact_support", en: "Something went wrong" },
|
|
102
102
|
"error.503.auth_unavailable": { status: 503, params: [], remediation: "wait_and_retry", en: "The authentication service is unavailable; try again later" },
|
|
103
|
+
"error.503.profiles_not_configured": { status: 503, params: [], remediation: "contact_support", en: "This deployment has no profiles service configured, so a display name cannot be written here" },
|
|
103
104
|
"error.503.profiles_unavailable": { status: 503, params: [], remediation: "wait_and_retry", en: "The profiles service is unavailable; try again later" },
|
|
104
105
|
} as const;
|
|
105
106
|
|
|
@@ -172,6 +173,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
|
|
|
172
173
|
"error.429.too_many_requests",
|
|
173
174
|
"error.500.internal",
|
|
174
175
|
"error.503.auth_unavailable",
|
|
176
|
+
"error.503.profiles_not_configured",
|
|
175
177
|
"error.503.profiles_unavailable",
|
|
176
178
|
];
|
|
177
179
|
|
|
@@ -246,5 +248,6 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
|
|
|
246
248
|
"error.429.too_many_requests": "Too many requests. Please try again later.",
|
|
247
249
|
"error.500.internal": "Something went wrong",
|
|
248
250
|
"error.503.auth_unavailable": "The authentication service is unavailable; try again later",
|
|
251
|
+
"error.503.profiles_not_configured": "This deployment has no profiles service configured, so a display name cannot be written here",
|
|
249
252
|
"error.503.profiles_unavailable": "The profiles service is unavailable; try again later",
|
|
250
253
|
};
|