@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
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@stapel/workspaces-react",
3
+ "version": "0.1.0",
4
+ "description": "Headless React flow pair for stapel-workspaces: typed API client, TanStack Query hooks, flow machines, headless components, and i18n keys. Zero visual opinion.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/workspaces-react"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./i18n/ru": {
21
+ "types": "./dist/i18n/ru.d.ts",
22
+ "default": "./dist/i18n/ru.js"
23
+ },
24
+ "./manifest": "./manifest.json",
25
+ "./manifest.json": "./manifest.json",
26
+ "./llms.txt": "./llms.txt",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src",
32
+ "tsconfig.json",
33
+ "README.md",
34
+ "MODULE.md",
35
+ "CHANGELOG.md",
36
+ "manifest.json",
37
+ "llms.txt"
38
+ ],
39
+ "size-limit": [
40
+ {
41
+ "name": "index — the ru locale must NOT be in the main bundle",
42
+ "path": "dist/index.js",
43
+ "limit": "12 KB"
44
+ },
45
+ {
46
+ "name": "i18n/ru subpath (opt-in locale: en floor + generated ru + ru UI)",
47
+ "path": "dist/i18n/ru.js",
48
+ "limit": "3.5 KB"
49
+ }
50
+ ],
51
+ "peerDependencies": {
52
+ "@stapel/core": ">=0.3.0 <1.0.0",
53
+ "@tanstack/react-query": "^5.0.0",
54
+ "react": ">=19"
55
+ },
56
+ "devDependencies": {
57
+ "@size-limit/preset-small-lib": "^11.2.0",
58
+ "@tanstack/react-query": "^5.81.0",
59
+ "@testing-library/react": "^16.3.0",
60
+ "@types/react": "^19.1.0",
61
+ "@types/react-dom": "^19.1.0",
62
+ "jsdom": "^26.1.0",
63
+ "msw": "^2.10.2",
64
+ "react": "^19.1.0",
65
+ "react-dom": "^19.1.0",
66
+ "size-limit": "^11.2.0",
67
+ "typescript": "^5.8.3",
68
+ "vitest": "^3.2.4",
69
+ "@stapel/showcase": "^0.2.0",
70
+ "@stapel/tokens": "^0.2.0",
71
+ "@stapel/core": "^0.3.0"
72
+ },
73
+ "engines": {
74
+ "node": ">=22"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "scripts": {
80
+ "build": "tsc -p tsconfig.json",
81
+ "test": "tsc -p tsconfig.demo.json && vitest run",
82
+ "lint": "eslint .",
83
+ "size": "size-limit"
84
+ }
85
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
+ "package": "@stapel/workspaces-react",
4
+ "version": "0.1.0",
5
+ "defined": [],
6
+ "flows": []
7
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hand-authored API surface the codegen does not (yet) cover — browser-redirect
3
+ * URL builders, open-redirect guards, narrow domain type-guards, header
4
+ * conventions. Everything that CAN be derived from schema.json belongs in the
5
+ * generated operations (`api/workspacesApi.ts`), not here.
6
+ */
7
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Wire types for the stapel-workspaces HTTP contract — **derived from the generated
3
+ * OpenAPI surface** (frontend-standard §2/§3), never hand-maintained. The
4
+ * single source of truth is `components["schemas"]` from `@stapel/core`
5
+ * (`packages/core/src/generated/schema.ts`, produced by `pnpm gen:api` from the
6
+ * unified all-modules OpenAPI). Alias the schemas this pair uses under local
7
+ * names here; do NOT write parallel response bodies. Where drf-spectacular +
8
+ * openapi-typescript under-describe the runtime, apply a small documented
9
+ * correction (see auth-react `api/types.ts` for the three canonical patterns).
10
+ */
11
+ import type { components } from "@stapel/core";
12
+
13
+ /** The generated schema table — the one source of truth for wire shapes. */
14
+ export type Schemas = components["schemas"];
15
+
16
+ // ── aliases (the stapel-workspaces schemas this pair uses) ────────────────────
17
+
18
+ /** One workspace — identity, storage, my role, member count (GET/POST/PATCH). */
19
+ export type Workspace = Schemas["WorkspaceResponse"];
20
+ /** GET / 200 body — the caller's workspaces (memberships). */
21
+ export type WorkspaceList = Schemas["WorkspaceListResponse"];
22
+ /** POST / request body — create a workspace (slug auto-generated when omitted). */
23
+ export type WorkspaceCreate = Schemas["WorkspaceCreateRequest"];
24
+ /** PATCH /{id} request body — a partial name / slug / settings update. */
25
+ export type WorkspaceUpdate = Schemas["PatchedWorkspaceUpdateRequest"];
26
+ /** One workspace member (GET members, PATCH member role, POST accept). */
27
+ export type Member = Schemas["MemberResponse"];
28
+ /** GET /{id}/members 200 body — a workspace's members. */
29
+ export type MemberList = Schemas["MemberListResponse"];
30
+ /** POST /{id}/members/invite request body — one or more emails + a role. */
31
+ export type MemberInvite = Schemas["MemberInviteRequest"];
32
+ /** POST /{id}/members/invite 201 body — the created invitations. */
33
+ export type MemberInviteResult = Schemas["MemberInviteResponse"];
34
+ /** PATCH /{id}/members/{userId} request body — the new role. */
35
+ export type MemberRoleUpdate = Schemas["PatchedMemberUpdateRequest"];
36
+ /** One pending/accepted invitation (a row of {@link MemberInviteResult}). */
37
+ export type Invitation = Schemas["InvitationResponse"];
38
+ /** POST /invitations/accept request body — the token from the email link. */
39
+ export type InvitationAccept = Schemas["InvitationAcceptRequest"];
40
+
41
+ // ── documented corrections (drf-spectacular under-describes) ──────────────────
42
+
43
+ /**
44
+ * A workspace membership role, ordered least→most privileged. The generated
45
+ * schema types `MemberResponse.role` / the invite `role` as a bare `string`,
46
+ * but the backend (`models.Role`, a Django `TextChoices`) constrains it to
47
+ * exactly these values. Narrowing here gives call sites a checked union — a
48
+ * documented correction of the same kind auth-react/billing-react apply.
49
+ */
50
+ export type WorkspaceRole = "owner" | "admin" | "member" | "viewer";
51
+
52
+ /**
53
+ * A workspace category. The generated schema types `WorkspaceResponse.type` /
54
+ * the create `type` as a bare `string`; the backend (`models.WorkspaceType`)
55
+ * constrains it to exactly these two. Narrowed for the same reason as
56
+ * {@link WorkspaceRole}.
57
+ */
58
+ export type WorkspaceKind = "personal" | "work";
@@ -0,0 +1,139 @@
1
+ import type { StapelClient, StapelRequestOptions } from "@stapel/core";
2
+ import type {
3
+ Member,
4
+ MemberInvite,
5
+ MemberInviteResult,
6
+ MemberList,
7
+ MemberRoleUpdate,
8
+ InvitationAccept,
9
+ Workspace,
10
+ WorkspaceCreate,
11
+ WorkspaceList,
12
+ WorkspaceUpdate,
13
+ } from "./types.js";
14
+
15
+ /**
16
+ * CSRF rule for cookie-authenticated browser clients (mirrors auth-react): the
17
+ * simplest SPA rule is to always send `X-Requested-With: XMLHttpRequest` on
18
+ * mutating requests. Header-token clients ignore it; it is harmless there, so
19
+ * every mutation carries it.
20
+ */
21
+ const CSRF_HEADERS: Record<string, string> = {
22
+ "X-Requested-With": "XMLHttpRequest",
23
+ };
24
+
25
+ function mutating(
26
+ options?: Omit<StapelRequestOptions, "method" | "body">
27
+ ): Omit<StapelRequestOptions, "method" | "body"> {
28
+ return {
29
+ ...options,
30
+ headers: { ...CSRF_HEADERS, ...options?.headers },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * The pair's typed operation surface — one method per stapel-workspaces endpoint
36
+ * a signed-in browser client may call, bound to the injected {@link StapelClient}
37
+ * (the per-module override seam of frontend-standard §7.2). Paths are relative
38
+ * to the runtime's `baseUrl` (e.g. `/workspaces/api`).
39
+ *
40
+ * The service-to-service `GET /internal/{ws}/members/{user}` (membership check
41
+ * from another backend) and `POST /internal/users/{user}/personal` (get-or-create
42
+ * a personal workspace on first login) are intentionally absent — they are
43
+ * machine-to-machine surfaces (`IsServiceRequest | IsStaffUser`), not part of the
44
+ * signed-in UI this pair drives.
45
+ *
46
+ * These operations will be GENERATED from schema.json operationIds by gen-api
47
+ * v2 (task `core-typed-ops`); until then they are hand-authored here (the ONE
48
+ * legal home of path strings — `stapel/no-string-paths` §2.3 carve-out).
49
+ */
50
+ export interface WorkspacesApi {
51
+ readonly client: StapelClient;
52
+
53
+ /** The caller's workspaces (accepted memberships, newest-accessed first). */
54
+ listWorkspaces(): Promise<WorkspaceList>;
55
+ /** Create a workspace and seed the caller as owner. */
56
+ createWorkspace(body: WorkspaceCreate): Promise<Workspace>;
57
+ /** A single workspace by id (touches last-accessed). */
58
+ getWorkspace(workspaceId: string): Promise<Workspace>;
59
+ /** Partially update a workspace's name / slug / settings (admin+). */
60
+ updateWorkspace(
61
+ workspaceId: string,
62
+ patch: WorkspaceUpdate
63
+ ): Promise<Workspace>;
64
+ /** Soft-delete a workspace (owner only). */
65
+ deleteWorkspace(workspaceId: string): Promise<void>;
66
+
67
+ /** A workspace's members (viewer+). */
68
+ listMembers(workspaceId: string): Promise<MemberList>;
69
+ /** Invite one or more emails to a workspace at a role (admin+). */
70
+ inviteMembers(
71
+ workspaceId: string,
72
+ body: MemberInvite
73
+ ): Promise<MemberInviteResult>;
74
+ /** Change a member's role (admin+; owner grants require owner). */
75
+ updateMemberRole(
76
+ workspaceId: string,
77
+ userId: string,
78
+ patch: MemberRoleUpdate
79
+ ): Promise<Member>;
80
+ /** Remove a member from a workspace (admin+; the last owner is protected). */
81
+ removeMember(workspaceId: string, userId: string): Promise<void>;
82
+
83
+ /** Accept an invitation by its token — returns the caller's new membership. */
84
+ acceptInvitation(body: InvitationAccept): Promise<Member>;
85
+ }
86
+
87
+ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
88
+ return {
89
+ client,
90
+
91
+ listWorkspaces: () => client.get("/"),
92
+
93
+ createWorkspace: (body) =>
94
+ client.post("/", body satisfies WorkspaceCreate, mutating()),
95
+
96
+ getWorkspace: (workspaceId) =>
97
+ client.get(`/${encodeURIComponent(workspaceId)}`),
98
+
99
+ updateWorkspace: (workspaceId, patch) =>
100
+ client.patch(
101
+ `/${encodeURIComponent(workspaceId)}`,
102
+ patch satisfies WorkspaceUpdate,
103
+ mutating()
104
+ ),
105
+
106
+ deleteWorkspace: (workspaceId) =>
107
+ client.delete(`/${encodeURIComponent(workspaceId)}`, mutating()),
108
+
109
+ listMembers: (workspaceId) =>
110
+ client.get(`/${encodeURIComponent(workspaceId)}/members`),
111
+
112
+ inviteMembers: (workspaceId, body) =>
113
+ client.post(
114
+ `/${encodeURIComponent(workspaceId)}/members/invite`,
115
+ body satisfies MemberInvite,
116
+ mutating()
117
+ ),
118
+
119
+ updateMemberRole: (workspaceId, userId, patch) =>
120
+ client.patch(
121
+ `/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}`,
122
+ patch satisfies MemberRoleUpdate,
123
+ mutating()
124
+ ),
125
+
126
+ removeMember: (workspaceId, userId) =>
127
+ client.delete(
128
+ `/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}`,
129
+ mutating()
130
+ ),
131
+
132
+ acceptInvitation: (body) =>
133
+ client.post(
134
+ "/invitations/accept",
135
+ body satisfies InvitationAccept,
136
+ mutating()
137
+ ),
138
+ };
139
+ }
@@ -0,0 +1,16 @@
1
+ import { toFlowError as coreToFlowError } from "@stapel/core";
2
+ import type { FlowError } from "@stapel/core";
3
+
4
+ export type { FlowError } from "@stapel/core";
5
+ export { isErrorCode } from "@stapel/core";
6
+
7
+ /**
8
+ * Fold any thrown value into a {@link FlowError} using this pair's own
9
+ * module-scoped fallback key (`workspaces.error.unknown`, an en string in
10
+ * {@link workspacesI18nBundleEn}) so a non-`StapelApiError` fault still renders
11
+ * real copy rather than a raw key. The primitive lives in `@stapel/core`
12
+ * (frontend-core-architecture §4b); this wrapper only pins the fallback.
13
+ */
14
+ export function toFlowError(error: unknown): FlowError {
15
+ return coreToFlowError(error, "workspaces.error.unknown");
16
+ }
@@ -0,0 +1,38 @@
1
+ // AUTO-GENERATED by scripts/gen-flows.mjs — do not edit by hand.
2
+ // Source: unified Stapel flows.json (stapel-example-monolith `make codegen`).
3
+ // Regenerate: pnpm gen:flows · Drift gate: pnpm gen:flows:check
4
+
5
+ /**
6
+ * The canonical workspaces flow registry — the machine-facing projection of the
7
+ * flow source (docs/flow-system.md §5). Each entry carries a flow's canonical
8
+ * id (`flow.<id>.<step>` analytics funnel = this flow), its i18n keys, and its
9
+ * ordered step→endpoint contract. Flow machines bind their `id` to
10
+ * `WORKSPACES_FLOWS[...].id` so a backend flow rename/re-step surfaces as a drift
11
+ * here (`pnpm gen:flows:check`) rather than a silent analytics/contract skew.
12
+ */
13
+ export const WORKSPACES_FLOWS = {
14
+
15
+ } as const;
16
+
17
+ /** Canonical flow ids present in flows.json (a subset grows as the backend annotates more). */
18
+ export type WorkspacesFlowId = keyof typeof WORKSPACES_FLOWS;
19
+
20
+ export type WorkspacesFlowSpec = (typeof WORKSPACES_FLOWS)[WorkspacesFlowId];
21
+
22
+ export interface FlowEndpoint {
23
+ readonly method: string;
24
+ readonly path: string;
25
+ }
26
+
27
+ /** All HTTP endpoints a flow touches, in step order (for the contract test / MSW). */
28
+ export function flowEndpoints(id: WorkspacesFlowId): readonly FlowEndpoint[] {
29
+ // Guard the empty-registry case: a module with no annotated flows makes
30
+ // `WorkspacesFlowId` = `never` and `WORKSPACES_FLOWS` = `{}`, so a direct
31
+ // `WORKSPACES_FLOWS[id].steps` does not type-check. Widening to an optional spec
32
+ // keeps this body valid for the zero-flow scaffold AND correct once the
33
+ // backend annotates `@flow_step` and the registry fills in.
34
+ const spec = WORKSPACES_FLOWS[id] as
35
+ | { readonly steps: readonly { readonly endpoints: readonly FlowEndpoint[] }[] }
36
+ | undefined;
37
+ return spec ? spec.steps.flatMap((s) => s.endpoints) : [];
38
+ }
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from "react";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { Member } from "../api/types.js";
4
+ import { useAcceptInvitation } from "../model/mutations.js";
5
+
6
+ /** Render-prop bag for {@link AcceptInvitation}. */
7
+ export interface AcceptInvitationBag {
8
+ /** Accept the invitation identified by `token`. */
9
+ accept(token: string): void;
10
+ /** An accept call is in flight. */
11
+ readonly isAccepting: boolean;
12
+ /** The accept succeeded — the caller's new membership. */
13
+ readonly isAccepted: boolean;
14
+ /** The membership created on success, else null. */
15
+ readonly member: Member | null;
16
+ /** The accept failed. */
17
+ readonly isError: boolean;
18
+ /**
19
+ * The error, when `isError`. A dead token surfaces a localizable
20
+ * `StapelApiError` — `error.400.invitation_expired` /
21
+ * `error.400.invitation_revoked` / `error.400.invitation_already_used` /
22
+ * `error.404.invitation_not_found` — each carrying its backend remediation.
23
+ */
24
+ readonly error: StapelApiError | null;
25
+ }
26
+
27
+ /**
28
+ * Headless invitation-accept control — a renderless "join by token" action.
29
+ * Wires {@link useAcceptInvitation} and hands an {@link AcceptInvitationBag} to
30
+ * `children`; bring your own accept button / status. Zero visual opinion
31
+ * (frontend-standard §2).
32
+ *
33
+ * ```tsx
34
+ * <AcceptInvitation>
35
+ * {({ accept, isAccepted, error }) => ( ... )}
36
+ * </AcceptInvitation>
37
+ * ```
38
+ */
39
+ export function AcceptInvitation(props: {
40
+ children: (bag: AcceptInvitationBag) => ReactNode;
41
+ }): ReactNode {
42
+ const mutation = useAcceptInvitation();
43
+ return props.children({
44
+ accept: (token) => {
45
+ mutation.mutate({ token });
46
+ },
47
+ isAccepting: mutation.isPending,
48
+ isAccepted: mutation.isSuccess,
49
+ member: mutation.data ?? null,
50
+ isError: mutation.isError,
51
+ error: mutation.error ?? null,
52
+ });
53
+ }
@@ -0,0 +1,89 @@
1
+ import type { ReactNode } from "react";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { Member, MemberInvite } from "../api/types.js";
4
+ import { useMembers } from "../model/queries.js";
5
+ import {
6
+ useInviteMembers,
7
+ useRemoveMember,
8
+ useUpdateMemberRole,
9
+ } from "../model/mutations.js";
10
+ import type { MemberRoleChange } from "../model/mutations.js";
11
+
12
+ /** Render-prop bag for {@link Members}. */
13
+ export interface MembersBag {
14
+ /** The workspace's members once loaded (empty before load). */
15
+ readonly members: readonly Member[];
16
+ /** The initial member-list load is in flight. */
17
+ readonly isLoading: boolean;
18
+ /** The read, an invite, a role change, or a removal failed. */
19
+ readonly isError: boolean;
20
+ /** The error, when `isError` (a localizable `StapelApiError`), else null. */
21
+ readonly error: StapelApiError | null;
22
+ /** Invite one or more emails at a role. */
23
+ invite(body: MemberInvite): void;
24
+ /** An invite call is in flight. */
25
+ readonly isInviting: boolean;
26
+ /** Change a member's role. */
27
+ updateRole(change: MemberRoleChange): void;
28
+ /** A role-change call is in flight. */
29
+ readonly isUpdatingRole: boolean;
30
+ /** Remove a member by `userId`. */
31
+ remove(userId: string): void;
32
+ /** A removal call is in flight. */
33
+ readonly isRemoving: boolean;
34
+ /** Refetch the member list from the server. */
35
+ refetch(): void;
36
+ }
37
+
38
+ /**
39
+ * Headless member roster — a renderless view + invite / role-change / removal
40
+ * controls over one workspace's members. Wires {@link useMembers},
41
+ * {@link useInviteMembers}, {@link useUpdateMemberRole}, and
42
+ * {@link useRemoveMember} and hands a {@link MembersBag} to `children`; bring your
43
+ * own roster / invite form / role menu. Zero visual opinion (frontend-standard §2).
44
+ *
45
+ * ```tsx
46
+ * <Members workspaceId={id}>
47
+ * {({ members, invite, updateRole, remove }) => ( ... )}
48
+ * </Members>
49
+ * ```
50
+ */
51
+ export function Members(props: {
52
+ workspaceId: string;
53
+ children: (bag: MembersBag) => ReactNode;
54
+ }): ReactNode {
55
+ const query = useMembers(props.workspaceId);
56
+ const inviteMutation = useInviteMembers(props.workspaceId);
57
+ const roleMutation = useUpdateMemberRole(props.workspaceId);
58
+ const removeMutation = useRemoveMember(props.workspaceId);
59
+ return props.children({
60
+ members: query.data?.members ?? [],
61
+ isLoading: query.isLoading,
62
+ isError:
63
+ query.isError ||
64
+ inviteMutation.isError ||
65
+ roleMutation.isError ||
66
+ removeMutation.isError,
67
+ error:
68
+ query.error ??
69
+ inviteMutation.error ??
70
+ roleMutation.error ??
71
+ removeMutation.error ??
72
+ null,
73
+ invite: (body) => {
74
+ inviteMutation.mutate(body);
75
+ },
76
+ isInviting: inviteMutation.isPending,
77
+ updateRole: (change) => {
78
+ roleMutation.mutate(change);
79
+ },
80
+ isUpdatingRole: roleMutation.isPending,
81
+ remove: (userId) => {
82
+ removeMutation.mutate(userId);
83
+ },
84
+ isRemoving: removeMutation.isPending,
85
+ refetch: () => {
86
+ void query.refetch();
87
+ },
88
+ });
89
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { Workspace, WorkspaceCreate } from "../api/types.js";
4
+ import { useWorkspaces } from "../model/queries.js";
5
+ import { useCreateWorkspace } from "../model/mutations.js";
6
+
7
+ /** Render-prop bag for {@link WorkspaceList}. */
8
+ 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;
17
+ /** Create a workspace (owner-seeded). */
18
+ create(body: WorkspaceCreate): void;
19
+ /** A create call is in flight. */
20
+ readonly isCreating: boolean;
21
+ /** The workspace created by the last `create`, else null. */
22
+ readonly created: Workspace | null;
23
+ /** Refetch the list from the server. */
24
+ refetch(): void;
25
+ }
26
+
27
+ /**
28
+ * Headless workspace list — a renderless view + create control over the caller's
29
+ * workspaces. Wires {@link useWorkspaces} + {@link useCreateWorkspace} and hands a
30
+ * {@link WorkspaceListBag} to `children`; bring your own list / create form.
31
+ * Zero visual opinion (frontend-standard §2).
32
+ *
33
+ * ```tsx
34
+ * <WorkspaceList>
35
+ * {({ workspaces, create }) => ( ... )}
36
+ * </WorkspaceList>
37
+ * ```
38
+ */
39
+ export function WorkspaceList(props: {
40
+ children: (bag: WorkspaceListBag) => ReactNode;
41
+ }): ReactNode {
42
+ const query = useWorkspaces();
43
+ const mutation = useCreateWorkspace();
44
+ 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,
49
+ create: (body) => {
50
+ mutation.mutate(body);
51
+ },
52
+ isCreating: mutation.isPending,
53
+ created: mutation.data ?? null,
54
+ refetch: () => {
55
+ void query.refetch();
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { WorkspacesRuntimeContext } from "../model/context.js";
3
+ import type { WorkspacesRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Provides the wired {@link WorkspacesRuntime} to every workspaces hook and
7
+ * headless component below it. Bring your own visual shell — this component
8
+ * renders nothing of its own.
9
+ *
10
+ * ```tsx
11
+ * const runtime = createWorkspacesRuntime({ baseUrl: "/workspaces/api/" });
12
+ * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
13
+ * <WorkspacesProvider runtime={runtime}>{app}</WorkspacesProvider>
14
+ * ```
15
+ */
16
+ export function WorkspacesProvider(props: {
17
+ runtime: WorkspacesRuntime;
18
+ children: ReactNode;
19
+ }): ReactElement {
20
+ return (
21
+ <WorkspacesRuntimeContext.Provider value={props.runtime}>
22
+ {props.children}
23
+ </WorkspacesRuntimeContext.Provider>
24
+ );
25
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7, frontend-core §2.5):
3
+ * the generated `code → { status, params, remediation, en }` catalog plus a
4
+ * tiny `explain()` lookup. Backs the manifest `errors` block and gives hosts a
5
+ * mechanical UX branch beside `t(code, params)`. The map itself is generated
6
+ * from the backend registry (`pnpm gen:errors`); this file only adds the lookup
7
+ * helper and re-exports the public surface.
8
+ */
9
+ import { WORKSPACES_ERRORS } from "./generated/errors.gen.js";
10
+ import type { Remediation } from "./generated/errors.gen.js";
11
+
12
+ export {
13
+ WORKSPACES_ERRORS,
14
+ WORKSPACES_ERROR_CODES,
15
+ workspacesErrorBundleEn,
16
+ } from "./generated/errors.gen.js";
17
+ export type {
18
+ WorkspacesErrorCode,
19
+ WorkspacesErrorSpec,
20
+ Remediation,
21
+ } from "./generated/errors.gen.js";
22
+
23
+ /**
24
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
25
+ * code this module doesn't know (e.g. a cross-cutting `stapel.http.*` fallback).
26
+ * Zero guessing at runtime — a static lookup over the generated map.
27
+ */
28
+ export function explainWorkspacesError(code: string): Remediation | undefined {
29
+ return (WORKSPACES_ERRORS as Record<string, { remediation: Remediation }>)[code]
30
+ ?.remediation;
31
+ }