@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/MODULE.md +5 -1
  3. package/README.md +22 -0
  4. package/dist/api/generated/schema.d.ts +13 -8
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/MembersManager.d.ts.map +1 -1
  7. package/dist/default/MembersManager.js +20 -6
  8. package/dist/default/MembersManager.js.map +1 -1
  9. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  10. package/dist/default/WorkspaceSettings.js +7 -3
  11. package/dist/default/WorkspaceSettings.js.map +1 -1
  12. package/dist/headless/Can.d.ts +8 -0
  13. package/dist/headless/Can.d.ts.map +1 -1
  14. package/dist/headless/Can.js +5 -1
  15. package/dist/headless/Can.js.map +1 -1
  16. package/dist/headless/Members.d.ts +14 -13
  17. package/dist/headless/Members.d.ts.map +1 -1
  18. package/dist/headless/Members.js +4 -12
  19. package/dist/headless/Members.js.map +1 -1
  20. package/dist/headless/RoleSelect.d.ts +15 -9
  21. package/dist/headless/RoleSelect.d.ts.map +1 -1
  22. package/dist/headless/RoleSelect.js +12 -8
  23. package/dist/headless/RoleSelect.js.map +1 -1
  24. package/dist/headless/WorkspaceList.d.ts +25 -10
  25. package/dist/headless/WorkspaceList.d.ts.map +1 -1
  26. package/dist/headless/WorkspaceList.js +14 -5
  27. package/dist/headless/WorkspaceList.js.map +1 -1
  28. package/dist/i18n/es.d.ts +34 -0
  29. package/dist/i18n/es.d.ts.map +1 -0
  30. package/dist/i18n/es.js +43 -0
  31. package/dist/i18n/es.js.map +1 -0
  32. package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
  33. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  34. package/dist/i18n/generated/errors.es.gen.js +76 -0
  35. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  36. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  37. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  38. package/dist/i18n/generated/errors.gen.js +3 -0
  39. package/dist/i18n/generated/errors.gen.js.map +1 -1
  40. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  41. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  42. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  43. package/dist/i18n/keys.d.ts +13 -0
  44. package/dist/i18n/keys.d.ts.map +1 -1
  45. package/dist/i18n/keys.js +23 -0
  46. package/dist/i18n/keys.js.map +1 -1
  47. package/dist/i18n/ru.d.ts.map +1 -1
  48. package/dist/i18n/ru.js +7 -0
  49. package/dist/i18n/ru.js.map +1 -1
  50. package/dist/model/queries.d.ts +13 -7
  51. package/dist/model/queries.d.ts.map +1 -1
  52. package/dist/model/queries.js +9 -9
  53. package/dist/model/queries.js.map +1 -1
  54. package/dist/model/selection.d.ts +20 -7
  55. package/dist/model/selection.d.ts.map +1 -1
  56. package/dist/model/selection.js +32 -12
  57. package/dist/model/selection.js.map +1 -1
  58. package/llms.txt +3 -3
  59. package/manifest.json +16 -3
  60. package/package.json +12 -3
  61. package/src/analytics/generated/events.json +1 -1
  62. package/src/api/generated/schema.ts +13 -8
  63. package/src/default/MembersManager.tsx +69 -16
  64. package/src/default/WorkspaceSettings.tsx +41 -5
  65. package/src/headless/Can.tsx +13 -1
  66. package/src/headless/Members.tsx +18 -26
  67. package/src/headless/RoleSelect.tsx +17 -14
  68. package/src/headless/WorkspaceList.tsx +31 -14
  69. package/src/i18n/es.ts +47 -0
  70. package/src/i18n/generated/errors.es.gen.ts +82 -0
  71. package/src/i18n/generated/errors.gen.ts +3 -0
  72. package/src/i18n/generated/errors.json +7 -0
  73. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  74. package/src/i18n/keys.ts +26 -0
  75. package/src/i18n/ru.ts +10 -0
  76. package/src/model/queries.ts +26 -16
  77. 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
- * reached through this module's existing in-process profiles seam
322
- * (``services.set_profile_display_name``), which also publishes
323
- * ``profile.changed`` so every consumer of that name follows.
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 stapel-profiles does not run in this process there is nothing to
332
- * write and no remote operation to call for it (that module publishes no
333
- * write-somebody-else's-name endpoint and no comm Function), so the answer
334
- * is an honest ``error.503.profiles_unavailable`` — never a 200 over a
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
- {({ members, isLoading, isError, error, invite, isInviting, updateRole, remove }) => {
57
+ {({ state, writeError, invite, isInviting, updateRole, remove, refetch }) => {
58
58
  const { labelFor } = rolesBag;
59
- const roleOptions = rolesBag.roles.map((r) => ({
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={rolesBag.isLoading}
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
- {isError && <ErrorAlert error={errorDisplay(error)} style={{ marginTop: 12 }} />}
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
- <Table<Member>
154
- style={{ marginTop: 16 }}
155
- size="small"
156
- loading={isLoading}
157
- rowKey={(member) => member.id}
158
- dataSource={members as Member[]}
159
- columns={columns}
160
- pagination={false}
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={rolesBag.isLoading}
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 { useErrorDisplay, useT } from "@stapel/core";
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 value={name} onChange={(e) => setName(e.target.value)} disabled={!isOwner} />
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={!isOwner || !name.trim()}
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 && (
@@ -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({ allowed, isLoading: caps.isLoading });
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
  }
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
- import type { StapelApiError } from "@stapel/core";
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 workspace's members once loaded (empty before load). This is one page
16
- * (default 100) — the roster's own {@link Member.email}/`search` narrows
17
- * further; a full pager is out of scope for this renderless view (bring
18
- * your own via `useMembers`' `params` for anchor pagination).
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 members: readonly Member[];
21
- /** The initial member-list load is in flight. */
22
- readonly isLoading: boolean;
23
- /** The read, an invite, a role change, or a removal failed. */
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
- * {({ members, invite, updateRole, remove }) => ( ... )}
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
- members: query.data?.items ?? [],
66
- isLoading: query.isLoading,
67
- isError:
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 { StapelApiError } from "@stapel/core";
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. Empty while loading. */
11
- readonly roles: readonly RoleInfo[];
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
- * {({ roles, labelFor }) => (
34
- * <Select options={roles.map((r) => ({ value: r.role, label: labelFor(r.role) }))} />
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
- roles: query.data ?? [],
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 type { StapelApiError } from "@stapel/core";
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
- /** The caller's workspaces once loaded (empty before load). */
10
- readonly workspaces: readonly Workspace[];
11
- /** The initial list load is in flight (no data yet). */
12
- readonly isLoading: boolean;
13
- /** Either the read or the create failed. */
14
- readonly isError: boolean;
15
- /** The error, when `isError` (a localizable `StapelApiError`), else null. */
16
- readonly error: StapelApiError | null;
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
- * {({ workspaces, create }) => ( ... )}
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
- workspaces: query.data?.workspaces ?? [],
46
- isLoading: query.isLoading,
47
- isError: query.isError || mutation.isError,
48
- error: query.error ?? mutation.error ?? null,
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
  };