@stapel/workspaces-react 0.1.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 (111) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +54 -0
  4. package/README.md +73 -0
  5. package/dist/api/extensions.d.ts +8 -0
  6. package/dist/api/extensions.d.ts.map +1 -0
  7. package/dist/api/extensions.js +8 -0
  8. package/dist/api/extensions.js.map +1 -0
  9. package/dist/api/types.d.ts +51 -0
  10. package/dist/api/types.d.ts.map +1 -0
  11. package/dist/api/types.js +2 -0
  12. package/dist/api/types.js.map +1 -0
  13. package/dist/api/workspacesApi.d.ts +43 -0
  14. package/dist/api/workspacesApi.d.ts.map +1 -0
  15. package/dist/api/workspacesApi.js +31 -0
  16. package/dist/api/workspacesApi.js.map +1 -0
  17. package/dist/flows/errors.d.ts +12 -0
  18. package/dist/flows/errors.d.ts.map +1 -0
  19. package/dist/flows/errors.js +13 -0
  20. package/dist/flows/errors.js.map +1 -0
  21. package/dist/flows/generated/flows.gen.d.ts +19 -0
  22. package/dist/flows/generated/flows.gen.d.ts.map +1 -0
  23. package/dist/flows/generated/flows.gen.js +23 -0
  24. package/dist/flows/generated/flows.gen.js.map +1 -0
  25. package/dist/headless/AcceptInvitation.d.ts +39 -0
  26. package/dist/headless/AcceptInvitation.d.ts.map +1 -0
  27. package/dist/headless/AcceptInvitation.js +27 -0
  28. package/dist/headless/AcceptInvitation.js.map +1 -0
  29. package/dist/headless/Members.d.ts +47 -0
  30. package/dist/headless/Members.d.ts.map +1 -0
  31. package/dist/headless/Members.js +50 -0
  32. package/dist/headless/Members.js.map +1 -0
  33. package/dist/headless/WorkspaceList.d.ts +38 -0
  34. package/dist/headless/WorkspaceList.d.ts.map +1 -0
  35. package/dist/headless/WorkspaceList.js +33 -0
  36. package/dist/headless/WorkspaceList.js.map +1 -0
  37. package/dist/headless/WorkspacesProvider.d.ts +18 -0
  38. package/dist/headless/WorkspacesProvider.d.ts.map +1 -0
  39. package/dist/headless/WorkspacesProvider.js +17 -0
  40. package/dist/headless/WorkspacesProvider.js.map +1 -0
  41. package/dist/i18n/errorsMap.d.ts +10 -0
  42. package/dist/i18n/errorsMap.d.ts.map +1 -0
  43. package/dist/i18n/errorsMap.js +20 -0
  44. package/dist/i18n/errorsMap.js.map +1 -0
  45. package/dist/i18n/generated/errors.gen.d.ts +347 -0
  46. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  47. package/dist/i18n/generated/errors.gen.js +177 -0
  48. package/dist/i18n/generated/errors.gen.js.map +1 -0
  49. package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
  50. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  51. package/dist/i18n/generated/errors.ru.gen.js +61 -0
  52. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  53. package/dist/i18n/keys.d.ts +55 -0
  54. package/dist/i18n/keys.d.ts.map +1 -0
  55. package/dist/i18n/keys.js +76 -0
  56. package/dist/i18n/keys.js.map +1 -0
  57. package/dist/i18n/ru.d.ts +28 -0
  58. package/dist/i18n/ru.d.ts.map +1 -0
  59. package/dist/i18n/ru.js +49 -0
  60. package/dist/i18n/ru.js.map +1 -0
  61. package/dist/index.d.ts +38 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +36 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/model/context.d.ts +14 -0
  66. package/dist/model/context.d.ts.map +1 -0
  67. package/dist/model/context.js +21 -0
  68. package/dist/model/context.js.map +1 -0
  69. package/dist/model/mutations.d.ts +55 -0
  70. package/dist/model/mutations.d.ts.map +1 -0
  71. package/dist/model/mutations.js +138 -0
  72. package/dist/model/mutations.js.map +1 -0
  73. package/dist/model/queries.d.ts +21 -0
  74. package/dist/model/queries.d.ts.map +1 -0
  75. package/dist/model/queries.js +41 -0
  76. package/dist/model/queries.js.map +1 -0
  77. package/dist/model/queryKeys.d.ts +7 -0
  78. package/dist/model/queryKeys.d.ts.map +1 -0
  79. package/dist/model/queryKeys.js +15 -0
  80. package/dist/model/queryKeys.js.map +1 -0
  81. package/dist/model/runtime.d.ts +26 -0
  82. package/dist/model/runtime.d.ts.map +1 -0
  83. package/dist/model/runtime.js +18 -0
  84. package/dist/model/runtime.js.map +1 -0
  85. package/llms.txt +95 -0
  86. package/manifest.json +705 -0
  87. package/package.json +85 -0
  88. package/src/analytics/generated/events.json +7 -0
  89. package/src/api/extensions.ts +7 -0
  90. package/src/api/types.ts +58 -0
  91. package/src/api/workspacesApi.ts +139 -0
  92. package/src/flows/errors.ts +16 -0
  93. package/src/flows/generated/flows.gen.ts +38 -0
  94. package/src/flows/generated/flows.json +1 -0
  95. package/src/headless/AcceptInvitation.tsx +53 -0
  96. package/src/headless/Members.tsx +89 -0
  97. package/src/headless/WorkspaceList.tsx +58 -0
  98. package/src/headless/WorkspacesProvider.tsx +25 -0
  99. package/src/i18n/errorsMap.ts +31 -0
  100. package/src/i18n/generated/errors.gen.ts +208 -0
  101. package/src/i18n/generated/errors.json +394 -0
  102. package/src/i18n/generated/errors.ru.gen.ts +67 -0
  103. package/src/i18n/keys.ts +83 -0
  104. package/src/i18n/ru.ts +53 -0
  105. package/src/index.ts +113 -0
  106. package/src/model/context.tsx +29 -0
  107. package/src/model/mutations.ts +196 -0
  108. package/src/model/queries.ts +51 -0
  109. package/src/model/queryKeys.ts +20 -0
  110. package/src/model/runtime.ts +46 -0
  111. package/tsconfig.json +26 -0
@@ -0,0 +1,83 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { workspacesErrorBundleEn } from "./generated/errors.gen.js";
3
+
4
+ /**
5
+ * workspaces-react's own translation KEYS (frontend-standard §4.2): headless
6
+ * components never render literal strings — hosts resolve these via core's i18n
7
+ * engine (`useT`). Backend error codes flow through the SAME contour: a
8
+ * `StapelApiError.code` is already a key, so the default bundle below ships
9
+ * English fallbacks for both the backend error codes (generated) and the
10
+ * pair's own UI keys. Point core's `loadLocale` at stapel-translate to override
11
+ * per locale. Add UI keys under the `workspaces.` namespace as you build flows.
12
+ */
13
+ export const WORKSPACES_I18N_KEYS = {
14
+ unknownError: "workspaces.error.unknown",
15
+ // Workspace list (WorkspaceList headless)
16
+ listLoading: "workspaces.list.loading",
17
+ listEmpty: "workspaces.list.empty",
18
+ listCreate: "workspaces.list.create",
19
+ listCreating: "workspaces.list.creating",
20
+ // Members (Members headless)
21
+ membersLoading: "workspaces.members.loading",
22
+ membersInvite: "workspaces.members.invite",
23
+ membersInviting: "workspaces.members.inviting",
24
+ membersUpdateRole: "workspaces.members.update_role",
25
+ membersRemove: "workspaces.members.remove",
26
+ // Accept invitation (AcceptInvitation headless)
27
+ acceptAccept: "workspaces.accept.accept",
28
+ acceptAccepting: "workspaces.accept.accepting",
29
+ acceptAccepted: "workspaces.accept.accepted",
30
+ } as const;
31
+
32
+ export type WorkspacesI18nKey =
33
+ (typeof WORKSPACES_I18N_KEYS)[keyof typeof WORKSPACES_I18N_KEYS];
34
+
35
+ /**
36
+ * English fallback bundle for workspaces-react UI keys + backend error codes.
37
+ * The generated `workspacesErrorBundleEn` (from stapel-workspaces's error registry,
38
+ * `pnpm gen:errors`) is spread FIRST so every backend `error.*` key has a
39
+ * fallback — a `StapelApiError.code` never renders as a raw key. Hand-polished
40
+ * copy below then OVERRIDES the generated English for the keys users see most.
41
+ */
42
+ export const workspacesI18nBundleEn: I18nDictionary = {
43
+ // Backend error codes — generated en fallbacks (coverage by construction).
44
+ ...workspacesErrorBundleEn,
45
+
46
+ // workspaces-react UI
47
+ "workspaces.error.unknown": "Something went wrong. Please try again.",
48
+ "workspaces.list.loading": "Loading workspaces…",
49
+ "workspaces.list.empty": "No workspaces yet.",
50
+ "workspaces.list.create": "Create workspace",
51
+ "workspaces.list.creating": "Creating…",
52
+ "workspaces.members.loading": "Loading members…",
53
+ "workspaces.members.invite": "Invite",
54
+ "workspaces.members.inviting": "Inviting…",
55
+ "workspaces.members.update_role": "Change role",
56
+ "workspaces.members.remove": "Remove",
57
+ "workspaces.accept.accept": "Accept invitation",
58
+ "workspaces.accept.accepting": "Accepting…",
59
+ "workspaces.accept.accepted": "You've joined the workspace.",
60
+ };
61
+
62
+ /**
63
+ * Register workspaces-react's key bundle into a core i18n engine (call once at
64
+ * startup). Registers under the given locale (default `"en"`); a later
65
+ * `loadLocale` from stapel-translate can layer localized overrides.
66
+ *
67
+ * MERGE-PRIORITY CONVENTION (pair checklist rule; i18n-shipping.md §3 — every
68
+ * `@stapel/*-react` pair follows it): registration order IS override
69
+ * priority, later wins per key. Within a locale, layers register bottom-up:
70
+ *
71
+ * 1. generated en floor (`WorkspacesErrorBundleEn` — coverage by construction),
72
+ * 2. the pair's polish / UI copy (this bundle spreads 1 then overrides),
73
+ * 3. the pair's locale bundle from the `./i18n/<locale>` subpath
74
+ * (e.g. `registerWorkspacesI18nRu` — registers the en floor UNDER the
75
+ * locale texts so a missing key degrades to English, never a raw key),
76
+ * 4. the HOST's own bundle — always registered LAST, so a host overrides any
77
+ * pair text without a fork.
78
+ *
79
+ * Dynamic overrides (stapel-translate `loadLocale`) layer on top at runtime.
80
+ */
81
+ export function registerWorkspacesI18n(engine: I18nEngine, locale = "en"): void {
82
+ engine.registerBundle(locale, workspacesI18nBundleEn);
83
+ }
package/src/i18n/ru.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { workspacesI18nBundleEn } from "./keys.js";
3
+ import { workspacesErrorBundleRu } from "./generated/errors.ru.gen.js";
4
+
5
+ export { workspacesErrorBundleRu } from "./generated/errors.ru.gen.js";
6
+
7
+ /**
8
+ * Russian bundle for workspaces-react — the pair's `ru` locale, shipped as
9
+ * the `@stapel/workspaces-react/i18n/ru` subpath (i18n-shipping.md §2) so
10
+ * the locale is opt-in: hosts that don't register it never carry these
11
+ * strings (the main entry does not import this module — gated by size-limit
12
+ * + the bundle-purity test).
13
+ *
14
+ * Composition mirrors {@link workspacesI18nBundleEn}: the GENERATED backend
15
+ * error texts (from stapel-workspaces's `translations/errors.ru.json`
16
+ * catalog, seeded from the curated stapel-translate corpus — `pnpm
17
+ * gen:errors`) are spread first for coverage by construction; the
18
+ * hand-written ru UI copy for the pair-owned {@link WORKSPACES_I18N_KEYS}
19
+ * follows. Override any key by registering a host bundle AFTER this one
20
+ * (merge-priority convention — see keys.ts).
21
+ */
22
+ export const workspacesI18nBundleRu: I18nDictionary = {
23
+ // Backend error codes — generated ru texts (coverage by construction).
24
+ ...workspacesErrorBundleRu,
25
+
26
+ // workspaces-react UI (hand-written ru mirror of the en copy in keys.ts)
27
+ "workspaces.error.unknown": "Что-то пошло не так. Попробуйте ещё раз.",
28
+ "workspaces.list.loading": "Загрузка рабочих пространств…",
29
+ "workspaces.list.empty": "Пока нет рабочих пространств.",
30
+ "workspaces.list.create": "Создать рабочее пространство",
31
+ "workspaces.list.creating": "Создание…",
32
+ "workspaces.members.loading": "Загрузка участников…",
33
+ "workspaces.members.invite": "Пригласить",
34
+ "workspaces.members.inviting": "Приглашение…",
35
+ "workspaces.members.update_role": "Изменить роль",
36
+ "workspaces.members.remove": "Удалить",
37
+ "workspaces.accept.accept": "Принять приглашение",
38
+ "workspaces.accept.accepting": "Принятие…",
39
+ "workspaces.accept.accepted": "Вы присоединились к рабочему пространству.",
40
+ };
41
+
42
+ /**
43
+ * Register the pair's `ru` locale into a core i18n engine (call once at
44
+ * startup, after {@link registerWorkspacesI18n}). Layers per the
45
+ * merge-priority convention (i18n-shipping.md §3): the en floor is registered
46
+ * UNDER the ru texts inside the `ru` locale, so a key the ru bundle ever
47
+ * misses degrades to its English text — never to a raw key. A host bundle
48
+ * registered after this call overrides both.
49
+ */
50
+ export function registerWorkspacesI18nRu(engine: I18nEngine): void {
51
+ engine.registerBundle("ru", workspacesI18nBundleEn);
52
+ engine.registerBundle("ru", workspacesI18nBundleRu);
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `@stapel/workspaces-react` — the headless React flow pair for stapel-workspaces
3
+ * (frontend-standard §2). Business + state only, zero visual opinion. Built on
4
+ * `@stapel/core`'s StapelClient (verification-403 interception, token refresh,
5
+ * i18n, analytics, query layer).
6
+ *
7
+ * Scaffolded by `stapel-new-react-lib`. Layers: api → model → flows → headless
8
+ * → i18n. Generated surfaces (flows registry, error map, manifest, llms.txt)
9
+ * are produced by the monorepo `gen:*` drivers and stand under drift gates.
10
+ */
11
+
12
+ // ── api ──────────────────────────────────────────────────────────────────────
13
+ export { createWorkspacesApi } from "./api/workspacesApi.js";
14
+ export type { WorkspacesApi } from "./api/workspacesApi.js";
15
+ export type { Schemas } from "./api/types.js";
16
+
17
+ // ── flows ────────────────────────────────────────────────────────────────────
18
+ // The flow-machine primitive lives in `@stapel/core` (one reviewed copy for
19
+ // every pair — frontend-core-architecture §4b). Re-exported for ergonomics.
20
+ export { createFlowMachine, useFlow, isErrorCode } from "@stapel/core";
21
+ export type {
22
+ FlowMachine,
23
+ FlowMachineOptions,
24
+ FlowStateBase,
25
+ FlowError,
26
+ } from "@stapel/core";
27
+ export { toFlowError } from "./flows/errors.js";
28
+ export { WORKSPACES_FLOWS, flowEndpoints } from "./flows/generated/flows.gen.js";
29
+ export type {
30
+ WorkspacesFlowId,
31
+ WorkspacesFlowSpec,
32
+ FlowEndpoint,
33
+ } from "./flows/generated/flows.gen.js";
34
+
35
+ // ── model (runtime wiring, query keys, context) ──────────────────────────────
36
+ export { createWorkspacesRuntime } from "./model/runtime.js";
37
+ export type {
38
+ WorkspacesRuntime,
39
+ CreateWorkspacesRuntimeOptions,
40
+ } from "./model/runtime.js";
41
+ export {
42
+ WorkspacesRuntimeContext,
43
+ useWorkspacesRuntime,
44
+ useWorkspacesApi,
45
+ useWorkspacesAnalytics,
46
+ } from "./model/context.js";
47
+ export { workspacesQueryKeys } from "./model/queryKeys.js";
48
+
49
+ // ── model (read hooks) ───────────────────────────────────────────────────────
50
+ export {
51
+ useWorkspaces,
52
+ useWorkspace,
53
+ useMembers,
54
+ } from "./model/queries.js";
55
+
56
+ // ── model (write hooks) ──────────────────────────────────────────────────────
57
+ export {
58
+ useCreateWorkspace,
59
+ useUpdateWorkspace,
60
+ useDeleteWorkspace,
61
+ useInviteMembers,
62
+ useUpdateMemberRole,
63
+ useRemoveMember,
64
+ useAcceptInvitation,
65
+ } from "./model/mutations.js";
66
+ export type { MemberRoleChange } from "./model/mutations.js";
67
+
68
+ // ── api (wire type aliases) ──────────────────────────────────────────────────
69
+ export type {
70
+ Workspace as WorkspaceData,
71
+ WorkspaceList as WorkspaceListData,
72
+ WorkspaceCreate,
73
+ WorkspaceUpdate,
74
+ Member as MemberData,
75
+ MemberList as MemberListData,
76
+ MemberInvite,
77
+ MemberInviteResult,
78
+ MemberRoleUpdate,
79
+ Invitation,
80
+ InvitationAccept,
81
+ WorkspaceRole,
82
+ WorkspaceKind,
83
+ } from "./api/types.js";
84
+
85
+ // ── headless (renderless components) ─────────────────────────────────────────
86
+ export { WorkspacesProvider } from "./headless/WorkspacesProvider.js";
87
+ export { WorkspaceList } from "./headless/WorkspaceList.js";
88
+ export type { WorkspaceListBag } from "./headless/WorkspaceList.js";
89
+ export { Members } from "./headless/Members.js";
90
+ export type { MembersBag } from "./headless/Members.js";
91
+ export { AcceptInvitation } from "./headless/AcceptInvitation.js";
92
+ export type { AcceptInvitationBag } from "./headless/AcceptInvitation.js";
93
+
94
+ // ── i18n ─────────────────────────────────────────────────────────────────────
95
+ export {
96
+ WORKSPACES_I18N_KEYS,
97
+ workspacesI18nBundleEn,
98
+ registerWorkspacesI18n,
99
+ } from "./i18n/keys.js";
100
+ export type { WorkspacesI18nKey } from "./i18n/keys.js";
101
+
102
+ // ── errors map (code → status/params/remediation/en; generated) ──────────────
103
+ export {
104
+ WORKSPACES_ERRORS,
105
+ WORKSPACES_ERROR_CODES,
106
+ workspacesErrorBundleEn,
107
+ explainWorkspacesError,
108
+ } from "./i18n/errorsMap.js";
109
+ export type {
110
+ WorkspacesErrorCode,
111
+ WorkspacesErrorSpec,
112
+ Remediation,
113
+ } from "./i18n/errorsMap.js";
@@ -0,0 +1,29 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { Context } from "react";
3
+ import type { Analytics } from "@stapel/core";
4
+ import type { WorkspacesApi } from "../api/workspacesApi.js";
5
+ import type { WorkspacesRuntime } from "./runtime.js";
6
+
7
+ /**
8
+ * The wired WorkspacesRuntime shared through React context by
9
+ * `<WorkspacesProvider>`. Hooks in `model/` and `headless/` read the singletons
10
+ * from here.
11
+ */
12
+ export const WorkspacesRuntimeContext: Context<WorkspacesRuntime | null> =
13
+ createContext<WorkspacesRuntime | null>(null);
14
+
15
+ export function useWorkspacesRuntime(): WorkspacesRuntime {
16
+ const runtime = useContext(WorkspacesRuntimeContext);
17
+ if (runtime === null) {
18
+ throw new Error("Workspaces hooks must be used within a <WorkspacesProvider>");
19
+ }
20
+ return runtime;
21
+ }
22
+
23
+ export function useWorkspacesApi(): WorkspacesApi {
24
+ return useWorkspacesRuntime().api;
25
+ }
26
+
27
+ export function useWorkspacesAnalytics(): Analytics | null {
28
+ return useWorkspacesRuntime().analytics;
29
+ }
@@ -0,0 +1,196 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ UseMutationOptions,
4
+ UseMutationResult,
5
+ } from "@tanstack/react-query";
6
+ import type { StapelApiError } from "@stapel/core";
7
+ import type {
8
+ Member,
9
+ MemberInvite,
10
+ MemberInviteResult,
11
+ InvitationAccept,
12
+ Workspace,
13
+ WorkspaceCreate,
14
+ WorkspaceRole,
15
+ WorkspaceUpdate,
16
+ } from "../api/types.js";
17
+ import { useWorkspacesApi } from "./context.js";
18
+ import { workspacesQueryKeys } from "./queryKeys.js";
19
+
20
+ /**
21
+ * Write hooks (frontend-standard §2 — "мутации с инвалидацией"). Each mutation
22
+ * invalidates exactly the server state it can move. Membership and ownership are
23
+ * server truth (roles gate access in other services via the membership cache),
24
+ * so NONE of these are optimistic (frontend-core-architecture §2.6: optimistic
25
+ * is for local-echo UX, never for flows with server truth).
26
+ *
27
+ * Options are built as typed `UseMutationOptions` objects (not call-site
28
+ * generics) so `void` stays in type-reference position, which
29
+ * `no-invalid-void-type` permits. Hooks that act on one workspace take its id as
30
+ * a parameter (the mutation variable then carries only the payload).
31
+ */
32
+
33
+ /** Create a workspace (owner-seeded) — refreshes the workspace list. */
34
+ export function useCreateWorkspace(): UseMutationResult<
35
+ Workspace,
36
+ StapelApiError,
37
+ WorkspaceCreate
38
+ > {
39
+ const api = useWorkspacesApi();
40
+ const queryClient = useQueryClient();
41
+ const options: UseMutationOptions<Workspace, StapelApiError, WorkspaceCreate> =
42
+ {
43
+ mutationFn: (body) => api.createWorkspace(body),
44
+ onSuccess: () => {
45
+ void queryClient.invalidateQueries({
46
+ queryKey: workspacesQueryKeys.list(),
47
+ });
48
+ },
49
+ };
50
+ return useMutation(options);
51
+ }
52
+
53
+ /**
54
+ * Partially update a workspace — writes the fresh row into its detail cache and
55
+ * refreshes the list (name / slug may have changed there too).
56
+ */
57
+ export function useUpdateWorkspace(
58
+ workspaceId: string
59
+ ): UseMutationResult<Workspace, StapelApiError, WorkspaceUpdate> {
60
+ const api = useWorkspacesApi();
61
+ const queryClient = useQueryClient();
62
+ const options: UseMutationOptions<Workspace, StapelApiError, WorkspaceUpdate> =
63
+ {
64
+ mutationFn: (patch) => api.updateWorkspace(workspaceId, patch),
65
+ onSuccess: (updated) => {
66
+ queryClient.setQueryData(
67
+ workspacesQueryKeys.detail(workspaceId),
68
+ updated
69
+ );
70
+ void queryClient.invalidateQueries({
71
+ queryKey: workspacesQueryKeys.list(),
72
+ });
73
+ },
74
+ };
75
+ return useMutation(options);
76
+ }
77
+
78
+ /** Soft-delete a workspace (owner only) — drops its detail cache + refreshes the list. */
79
+ export function useDeleteWorkspace(): UseMutationResult<
80
+ void,
81
+ StapelApiError,
82
+ string
83
+ > {
84
+ const api = useWorkspacesApi();
85
+ const queryClient = useQueryClient();
86
+ const options: UseMutationOptions<void, StapelApiError, string> = {
87
+ mutationFn: (workspaceId) => api.deleteWorkspace(workspaceId),
88
+ onSuccess: (_result, workspaceId) => {
89
+ queryClient.removeQueries({
90
+ queryKey: workspacesQueryKeys.detail(workspaceId),
91
+ });
92
+ void queryClient.invalidateQueries({
93
+ queryKey: workspacesQueryKeys.list(),
94
+ });
95
+ },
96
+ };
97
+ return useMutation(options);
98
+ }
99
+
100
+ /**
101
+ * Invite one or more emails to a workspace (admin+). Server truth — no optimism.
102
+ * A bad role or missing email surfaces as a localizable `StapelApiError`
103
+ * (`error.400.invalid_role`). Refreshes the member list (pending invites show
104
+ * there).
105
+ */
106
+ export function useInviteMembers(
107
+ workspaceId: string
108
+ ): UseMutationResult<MemberInviteResult, StapelApiError, MemberInvite> {
109
+ const api = useWorkspacesApi();
110
+ const queryClient = useQueryClient();
111
+ const options: UseMutationOptions<
112
+ MemberInviteResult,
113
+ StapelApiError,
114
+ MemberInvite
115
+ > = {
116
+ mutationFn: (body) => api.inviteMembers(workspaceId, body),
117
+ onSuccess: () => {
118
+ void queryClient.invalidateQueries({
119
+ queryKey: workspacesQueryKeys.members(workspaceId),
120
+ });
121
+ },
122
+ };
123
+ return useMutation(options);
124
+ }
125
+
126
+ /** The variable for {@link useUpdateMemberRole}: which member, which new role. */
127
+ export interface MemberRoleChange {
128
+ readonly userId: string;
129
+ readonly role: WorkspaceRole;
130
+ }
131
+
132
+ /**
133
+ * Change a member's role (admin+; the backend guards owner grants and the
134
+ * last-owner invariant, surfacing `error.403.last_owner_cannot_be_removed`).
135
+ * Refreshes the member list.
136
+ */
137
+ export function useUpdateMemberRole(
138
+ workspaceId: string
139
+ ): UseMutationResult<Member, StapelApiError, MemberRoleChange> {
140
+ const api = useWorkspacesApi();
141
+ const queryClient = useQueryClient();
142
+ const options: UseMutationOptions<Member, StapelApiError, MemberRoleChange> = {
143
+ mutationFn: ({ userId, role }) =>
144
+ api.updateMemberRole(workspaceId, userId, { role }),
145
+ onSuccess: () => {
146
+ void queryClient.invalidateQueries({
147
+ queryKey: workspacesQueryKeys.members(workspaceId),
148
+ });
149
+ },
150
+ };
151
+ return useMutation(options);
152
+ }
153
+
154
+ /**
155
+ * Remove a member (admin+; the last owner is protected by the backend). The
156
+ * variable is the member's `userId`. Refreshes the member list.
157
+ */
158
+ export function useRemoveMember(
159
+ workspaceId: string
160
+ ): UseMutationResult<void, StapelApiError, string> {
161
+ const api = useWorkspacesApi();
162
+ const queryClient = useQueryClient();
163
+ const options: UseMutationOptions<void, StapelApiError, string> = {
164
+ mutationFn: (userId) => api.removeMember(workspaceId, userId),
165
+ onSuccess: () => {
166
+ void queryClient.invalidateQueries({
167
+ queryKey: workspacesQueryKeys.members(workspaceId),
168
+ });
169
+ },
170
+ };
171
+ return useMutation(options);
172
+ }
173
+
174
+ /**
175
+ * Accept an invitation by its token — the caller joins the workspace. A dead
176
+ * token surfaces a localizable `StapelApiError` (`error.400.invitation_expired`
177
+ * / `error.400.invitation_revoked` / `error.400.invitation_already_used`).
178
+ * Refreshes the workspace list (the new membership appears there).
179
+ */
180
+ export function useAcceptInvitation(): UseMutationResult<
181
+ Member,
182
+ StapelApiError,
183
+ InvitationAccept
184
+ > {
185
+ const api = useWorkspacesApi();
186
+ const queryClient = useQueryClient();
187
+ const options: UseMutationOptions<Member, StapelApiError, InvitationAccept> = {
188
+ mutationFn: (body) => api.acceptInvitation(body),
189
+ onSuccess: () => {
190
+ void queryClient.invalidateQueries({
191
+ queryKey: workspacesQueryKeys.list(),
192
+ });
193
+ },
194
+ };
195
+ return useMutation(options);
196
+ }
@@ -0,0 +1,51 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { UseQueryResult } from "@tanstack/react-query";
3
+ import type { StapelApiError } from "@stapel/core";
4
+ import type { MemberList, Workspace, WorkspaceList } from "../api/types.js";
5
+ import { useWorkspacesApi } from "./context.js";
6
+ import { workspacesQueryKeys } from "./queryKeys.js";
7
+
8
+ /**
9
+ * Read hooks over the workspaces API. Staleness follows core's query defaults;
10
+ * override per call site via a page that needs fresher data. Keys are
11
+ * namespaced (see `workspacesQueryKeys`).
12
+ */
13
+
14
+ /** The caller's workspaces — their accepted memberships (GET /). */
15
+ export function useWorkspaces(): UseQueryResult<WorkspaceList, StapelApiError> {
16
+ const api = useWorkspacesApi();
17
+ return useQuery({
18
+ queryKey: workspacesQueryKeys.list(),
19
+ queryFn: () => api.listWorkspaces(),
20
+ });
21
+ }
22
+
23
+ /**
24
+ * A single workspace by id (GET /{id}). Disabled until a `workspaceId` is
25
+ * given, so a detail hook can mount before a selection exists.
26
+ */
27
+ export function useWorkspace(
28
+ workspaceId: string | null
29
+ ): UseQueryResult<Workspace, StapelApiError> {
30
+ const api = useWorkspacesApi();
31
+ return useQuery({
32
+ queryKey: workspacesQueryKeys.detail(workspaceId ?? ""),
33
+ queryFn: () => api.getWorkspace(workspaceId as string),
34
+ enabled: workspaceId !== null && workspaceId !== "",
35
+ });
36
+ }
37
+
38
+ /**
39
+ * A workspace's members (GET /{id}/members). Disabled until a `workspaceId` is
40
+ * given.
41
+ */
42
+ export function useMembers(
43
+ workspaceId: string | null
44
+ ): UseQueryResult<MemberList, StapelApiError> {
45
+ const api = useWorkspacesApi();
46
+ return useQuery({
47
+ queryKey: workspacesQueryKeys.members(workspaceId ?? ""),
48
+ queryFn: () => api.listMembers(workspaceId as string),
49
+ enabled: workspaceId !== null && workspaceId !== "",
50
+ });
51
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2 — "ключи неймспейснуты").
3
+ * Everything under the `"workspaces"` root so a host can invalidate the whole
4
+ * module or match a single resource. Persist scope is per-user via core's query
5
+ * runtime (`setPersistUser`). Explicit tuple return types satisfy
6
+ * `--isolatedDeclarations`. One entry per read-operation.
7
+ */
8
+ const ROOT = "workspaces" as const;
9
+
10
+ export const workspacesQueryKeys: {
11
+ readonly all: readonly ["workspaces"];
12
+ list(): readonly ["workspaces", "list"];
13
+ detail(workspaceId: string): readonly ["workspaces", "detail", string];
14
+ members(workspaceId: string): readonly ["workspaces", "members", string];
15
+ } = {
16
+ all: [ROOT],
17
+ list: () => [ROOT, "list"],
18
+ detail: (workspaceId) => [ROOT, "detail", workspaceId],
19
+ members: (workspaceId) => [ROOT, "members", workspaceId],
20
+ };
@@ -0,0 +1,46 @@
1
+ import { createStapelClient } from "@stapel/core";
2
+ import type { Analytics, StapelClient } from "@stapel/core";
3
+ import { createWorkspacesApi } from "../api/workspacesApi.js";
4
+ import type { WorkspacesApi } from "../api/workspacesApi.js";
5
+
6
+ /**
7
+ * The wired workspaces runtime — builds a {@link StapelClient} and the pair's
8
+ * API over it. The returned `client` is what the host injects into core's
9
+ * `StapelConfigProvider` (as the default or the `"workspaces"` module client),
10
+ * preserving the client-injection fork seam (frontend-standard §7.2). Auth
11
+ * token/refresh and the verification-403 seam are supplied by the host's auth
12
+ * runtime on the shared client — this pair does not re-implement them.
13
+ */
14
+ export interface WorkspacesRuntime {
15
+ readonly client: StapelClient;
16
+ readonly api: WorkspacesApi;
17
+ readonly analytics: Analytics | null;
18
+ }
19
+
20
+ export interface CreateWorkspacesRuntimeOptions {
21
+ /** e.g. `/workspaces/api/` or `https://app.example.com/workspaces/api/`. */
22
+ readonly baseUrl: string;
23
+ readonly fetch?: typeof globalThis.fetch;
24
+ readonly credentials?: RequestCredentials;
25
+ readonly analytics?: Analytics | null;
26
+ /** Extra headers merged into every request (e.g. a tenant id). */
27
+ readonly defaultHeaders?: Record<string, string>;
28
+ }
29
+
30
+ export function createWorkspacesRuntime(
31
+ options: CreateWorkspacesRuntimeOptions
32
+ ): WorkspacesRuntime {
33
+ const analytics = options.analytics ?? null;
34
+ const client = createStapelClient({
35
+ baseUrl: options.baseUrl,
36
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
37
+ ...(options.credentials !== undefined
38
+ ? { credentials: options.credentials }
39
+ : {}),
40
+ ...(options.defaultHeaders !== undefined
41
+ ? { defaultHeaders: options.defaultHeaders }
42
+ : {}),
43
+ });
44
+ const api = createWorkspacesApi(client);
45
+ return { client, api, analytics };
46
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }