@stapel/workspaces-react 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/dist/api/generated/schema.d.ts +110 -0
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +9 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +9 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +2 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/model/mutations.d.ts +16 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +39 -0
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/selection.d.ts +146 -0
- package/dist/model/selection.d.ts.map +1 -0
- package/dist/model/selection.js +227 -0
- package/dist/model/selection.js.map +1 -0
- package/llms.txt +6 -2
- package/manifest.json +46 -2
- package/package.json +3 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +110 -0
- package/src/api/types.ts +9 -0
- package/src/api/workspacesApi.ts +21 -0
- package/src/index.ts +16 -0
- package/src/model/mutations.ts +59 -0
- package/src/model/selection.tsx +392 -0
|
@@ -661,6 +661,50 @@ export interface paths {
|
|
|
661
661
|
patch?: never;
|
|
662
662
|
trace?: never;
|
|
663
663
|
};
|
|
664
|
+
"/workspaces/api/v1/me/preferred-workspace": {
|
|
665
|
+
parameters: {
|
|
666
|
+
query?: never;
|
|
667
|
+
header?: never;
|
|
668
|
+
path?: never;
|
|
669
|
+
cookie?: never;
|
|
670
|
+
};
|
|
671
|
+
get?: never;
|
|
672
|
+
/**
|
|
673
|
+
* @description ``PUT/DELETE me/preferred-workspace`` — the person states where home is.
|
|
674
|
+
*
|
|
675
|
+
* ``STAPEL_WORKSPACES["DEFAULT_WORKSPACE_ID"]`` already describes itself as
|
|
676
|
+
* "a DEFAULT, not a cage: a person still switches spaces, and their
|
|
677
|
+
* explicit choice wins over it" — and until this endpoint there was nowhere
|
|
678
|
+
* for that choice to be written down. Clients filled the hole by guessing,
|
|
679
|
+
* and the guess (``workspaces[0]`` off a recency-ordered list) is #239.
|
|
680
|
+
*
|
|
681
|
+
* The choice is stated, never inferred. ``last_accessed_at`` remains what
|
|
682
|
+
* it has always been: telemetry written as a side effect of a GET, used to
|
|
683
|
+
* sort the list and for nothing else.
|
|
684
|
+
*
|
|
685
|
+
* Deliberately user-scoped rather than workspace-scoped
|
|
686
|
+
* (``.../<workspace_id>/prefer``): there is exactly one answer per person,
|
|
687
|
+
* and a route shaped per workspace would invite a second one.
|
|
688
|
+
*
|
|
689
|
+
* **Permissions:** `IsAuthenticated`
|
|
690
|
+
*/
|
|
691
|
+
put: operations["workspaces_api_v1_me_preferred_workspace_update"];
|
|
692
|
+
post?: never;
|
|
693
|
+
/**
|
|
694
|
+
* @description Clear the choice — back to the instance default / the client's chain.
|
|
695
|
+
*
|
|
696
|
+
* Answers 200 with an empty id rather than 204: the client's whole job
|
|
697
|
+
* here is to re-resolve, and handing it the new state saves it from
|
|
698
|
+
* guessing what "no content" left behind. Idempotent.
|
|
699
|
+
*
|
|
700
|
+
* **Permissions:** `IsAuthenticated`
|
|
701
|
+
*/
|
|
702
|
+
delete: operations["workspaces_api_v1_me_preferred_workspace_destroy"];
|
|
703
|
+
options?: never;
|
|
704
|
+
head?: never;
|
|
705
|
+
patch?: never;
|
|
706
|
+
trace?: never;
|
|
707
|
+
};
|
|
664
708
|
"/workspaces/api/v1/roles": {
|
|
665
709
|
parameters: {
|
|
666
710
|
query?: never;
|
|
@@ -1000,6 +1044,23 @@ export interface components {
|
|
|
1000
1044
|
[key: string]: unknown;
|
|
1001
1045
|
} | null;
|
|
1002
1046
|
};
|
|
1047
|
+
/** @description Which workspace the person is choosing as home. */
|
|
1048
|
+
PreferredWorkspaceRequest: {
|
|
1049
|
+
/**
|
|
1050
|
+
* Format: uuid
|
|
1051
|
+
* @description UUID of a workspace the caller actively belongs to
|
|
1052
|
+
* @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
|
|
1053
|
+
*/
|
|
1054
|
+
workspace_id: string;
|
|
1055
|
+
};
|
|
1056
|
+
/** @description The caller's stated home workspace after the write. */
|
|
1057
|
+
PreferredWorkspaceResponse: {
|
|
1058
|
+
/**
|
|
1059
|
+
* @description The workspace now recorded as the caller's choice, or "" after a DELETE cleared it
|
|
1060
|
+
* @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
|
|
1061
|
+
*/
|
|
1062
|
+
preferred_workspace_id?: string;
|
|
1063
|
+
};
|
|
1003
1064
|
/** @description Provision an org-created (synthetic) member (org-program spec §C1). */
|
|
1004
1065
|
ProvisionMemberRequest: {
|
|
1005
1066
|
/**
|
|
@@ -1132,6 +1193,11 @@ export interface components {
|
|
|
1132
1193
|
* @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
|
|
1133
1194
|
*/
|
|
1134
1195
|
default_workspace_id?: string;
|
|
1196
|
+
/**
|
|
1197
|
+
* @description The person's OWN stated choice of home workspace (PUT me/preferred-workspace), echoed back ONLY while the membership carrying it is active, else "". This is the "explicit choice" default_workspace_id documents itself as yielding to, so a client resolves preferred first and the instance default only after it. Never inferred from behaviour — where somebody last clicked is last_accessed_at, which is telemetry, and reading it as a choice is what produced #239
|
|
1198
|
+
* @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
|
|
1199
|
+
*/
|
|
1200
|
+
preferred_workspace_id?: string;
|
|
1135
1201
|
};
|
|
1136
1202
|
/** @description Workspace details. */
|
|
1137
1203
|
WorkspaceResponse: {
|
|
@@ -1767,6 +1833,50 @@ export interface operations {
|
|
|
1767
1833
|
};
|
|
1768
1834
|
};
|
|
1769
1835
|
};
|
|
1836
|
+
workspaces_api_v1_me_preferred_workspace_update: {
|
|
1837
|
+
parameters: {
|
|
1838
|
+
query?: never;
|
|
1839
|
+
header?: never;
|
|
1840
|
+
path?: never;
|
|
1841
|
+
cookie?: never;
|
|
1842
|
+
};
|
|
1843
|
+
requestBody: {
|
|
1844
|
+
content: {
|
|
1845
|
+
"application/json": components["schemas"]["PreferredWorkspaceRequest"];
|
|
1846
|
+
"application/x-www-form-urlencoded": components["schemas"]["PreferredWorkspaceRequest"];
|
|
1847
|
+
"multipart/form-data": components["schemas"]["PreferredWorkspaceRequest"];
|
|
1848
|
+
};
|
|
1849
|
+
};
|
|
1850
|
+
responses: {
|
|
1851
|
+
200: {
|
|
1852
|
+
headers: {
|
|
1853
|
+
[name: string]: unknown;
|
|
1854
|
+
};
|
|
1855
|
+
content: {
|
|
1856
|
+
"application/json": components["schemas"]["PreferredWorkspaceResponse"];
|
|
1857
|
+
};
|
|
1858
|
+
};
|
|
1859
|
+
};
|
|
1860
|
+
};
|
|
1861
|
+
workspaces_api_v1_me_preferred_workspace_destroy: {
|
|
1862
|
+
parameters: {
|
|
1863
|
+
query?: never;
|
|
1864
|
+
header?: never;
|
|
1865
|
+
path?: never;
|
|
1866
|
+
cookie?: never;
|
|
1867
|
+
};
|
|
1868
|
+
requestBody?: never;
|
|
1869
|
+
responses: {
|
|
1870
|
+
200: {
|
|
1871
|
+
headers: {
|
|
1872
|
+
[name: string]: unknown;
|
|
1873
|
+
};
|
|
1874
|
+
content: {
|
|
1875
|
+
"application/json": components["schemas"]["PreferredWorkspaceResponse"];
|
|
1876
|
+
};
|
|
1877
|
+
};
|
|
1878
|
+
};
|
|
1879
|
+
};
|
|
1770
1880
|
workspaces_api_v1_roles_retrieve: {
|
|
1771
1881
|
parameters: {
|
|
1772
1882
|
query?: never;
|
package/src/api/types.ts
CHANGED
|
@@ -24,6 +24,15 @@ export type Operations = operations;
|
|
|
24
24
|
export type Workspace = Schemas["WorkspaceResponse"];
|
|
25
25
|
/** GET / 200 body — the caller's workspaces (memberships). */
|
|
26
26
|
export type WorkspaceList = Schemas["WorkspaceListResponse"];
|
|
27
|
+
/** PUT /me/preferred-workspace request body — the workspace the person is
|
|
28
|
+
* choosing as home. Must be one they actively belong to; anything else
|
|
29
|
+
* (unknown, not a member, invitation still pending, membership suspended)
|
|
30
|
+
* answers ONE identical `error.404.workspace_not_found`, so the endpoint
|
|
31
|
+
* cannot be used to probe which workspace ids are real. */
|
|
32
|
+
export type PreferredWorkspace = Schemas["PreferredWorkspaceRequest"];
|
|
33
|
+
/** PUT/DELETE /me/preferred-workspace 200 body — the choice after the write
|
|
34
|
+
* (`""` after a DELETE cleared it). */
|
|
35
|
+
export type PreferredWorkspaceResult = Schemas["PreferredWorkspaceResponse"];
|
|
27
36
|
/** POST / request body — create a workspace (slug auto-generated when omitted). */
|
|
28
37
|
export type WorkspaceCreate = Schemas["WorkspaceCreateRequest"];
|
|
29
38
|
/** PATCH /{id} request body — a partial name / slug / settings update. */
|
package/src/api/workspacesApi.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type {
|
|
|
13
13
|
MemberPasswordResetResult,
|
|
14
14
|
MemberRoleUpdate,
|
|
15
15
|
MembersParams,
|
|
16
|
+
PreferredWorkspace,
|
|
17
|
+
PreferredWorkspaceResult,
|
|
16
18
|
InvitationAccept,
|
|
17
19
|
InvitationClaim,
|
|
18
20
|
InvitationPreview,
|
|
@@ -76,6 +78,15 @@ export interface WorkspacesApi {
|
|
|
76
78
|
/** Soft-delete a workspace (owner only). */
|
|
77
79
|
deleteWorkspace(workspaceId: string): Promise<void>;
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Record the caller's home workspace — their EXPLICIT choice, which the
|
|
83
|
+
* instance's `default_workspace_id` documents itself as yielding to.
|
|
84
|
+
* Answers 404 for anything they cannot actively open.
|
|
85
|
+
*/
|
|
86
|
+
setPreferredWorkspace(body: PreferredWorkspace): Promise<PreferredWorkspaceResult>;
|
|
87
|
+
/** Clear the caller's home workspace — back to the resolution chain. Idempotent. */
|
|
88
|
+
clearPreferredWorkspace(): Promise<PreferredWorkspaceResult>;
|
|
89
|
+
|
|
79
90
|
/** A workspace's members (viewer+), an anchor-paginated page. */
|
|
80
91
|
listMembers(
|
|
81
92
|
workspaceId: string,
|
|
@@ -238,6 +249,16 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
|
|
|
238
249
|
deleteWorkspace: (workspaceId) =>
|
|
239
250
|
client.delete(`/${encodeURIComponent(workspaceId)}`, mutating()),
|
|
240
251
|
|
|
252
|
+
setPreferredWorkspace: (body) =>
|
|
253
|
+
client.put(
|
|
254
|
+
"/me/preferred-workspace",
|
|
255
|
+
body satisfies PreferredWorkspace,
|
|
256
|
+
mutating()
|
|
257
|
+
),
|
|
258
|
+
|
|
259
|
+
clearPreferredWorkspace: () =>
|
|
260
|
+
client.delete("/me/preferred-workspace", mutating()),
|
|
261
|
+
|
|
241
262
|
listMembers: (workspaceId, params) => {
|
|
242
263
|
const query: Record<string, string | number> = {};
|
|
243
264
|
if (params?.anchor !== undefined) query.anchor = params.anchor;
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,18 @@ export {
|
|
|
52
52
|
} from "./model/context.js";
|
|
53
53
|
export { workspacesQueryKeys } from "./model/queryKeys.js";
|
|
54
54
|
|
|
55
|
+
// ── model (active-workspace selection: URL > localStorage > backend) ─────────
|
|
56
|
+
export {
|
|
57
|
+
WorkspaceSelectionProvider,
|
|
58
|
+
useWorkspaceSelection,
|
|
59
|
+
} from "./model/selection.js";
|
|
60
|
+
export type {
|
|
61
|
+
WorkspaceSelection,
|
|
62
|
+
WorkspaceSelectionProviderProps,
|
|
63
|
+
WorkspaceSelectionSource,
|
|
64
|
+
WorkspaceSelectionUrlBinding,
|
|
65
|
+
} from "./model/selection.js";
|
|
66
|
+
|
|
55
67
|
// ── model (read hooks) ───────────────────────────────────────────────────────
|
|
56
68
|
export {
|
|
57
69
|
useWorkspaces,
|
|
@@ -98,6 +110,8 @@ export {
|
|
|
98
110
|
useResendInvitation,
|
|
99
111
|
useResetMemberPassword,
|
|
100
112
|
useUpdateSecuritySettings,
|
|
113
|
+
useSetPreferredWorkspace,
|
|
114
|
+
useClearPreferredWorkspace,
|
|
101
115
|
} from "./model/mutations.js";
|
|
102
116
|
export type {
|
|
103
117
|
MemberRoleChange,
|
|
@@ -127,6 +141,8 @@ export type {
|
|
|
127
141
|
InvitationClaim,
|
|
128
142
|
MemberPasswordReset,
|
|
129
143
|
MemberPasswordResetResult,
|
|
144
|
+
PreferredWorkspace,
|
|
145
|
+
PreferredWorkspaceResult,
|
|
130
146
|
DisplayNameUpdate,
|
|
131
147
|
DisplayNameResult,
|
|
132
148
|
ProvisionedUserPolicy,
|
package/src/model/mutations.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
MemberPasswordResetResult,
|
|
15
15
|
InvitationAccept,
|
|
16
16
|
InvitationClaim,
|
|
17
|
+
PreferredWorkspace,
|
|
18
|
+
PreferredWorkspaceResult,
|
|
17
19
|
Workspace,
|
|
18
20
|
WorkspaceCreate,
|
|
19
21
|
WorkspaceSecuritySettings,
|
|
@@ -565,3 +567,60 @@ export function useDeclineInvitation(): UseMutationResult<
|
|
|
565
567
|
};
|
|
566
568
|
return useMutation(options);
|
|
567
569
|
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Record the caller's home workspace (PUT /me/preferred-workspace).
|
|
573
|
+
*
|
|
574
|
+
* Invalidates the workspace list, because the list response is where the
|
|
575
|
+
* choice is READ back from (`preferred_workspace_id`) — one round trip, and
|
|
576
|
+
* no window in which the list has arrived but the answer has not.
|
|
577
|
+
*
|
|
578
|
+
* Callers should treat this as fire-and-forget: {@link useWorkspaceSelection}
|
|
579
|
+
* switches the tab locally and never blocks on the round trip. A switch that
|
|
580
|
+
* hangs on a flaky network is worse than a preference that lags one click.
|
|
581
|
+
*/
|
|
582
|
+
export function useSetPreferredWorkspace(): UseMutationResult<
|
|
583
|
+
PreferredWorkspaceResult,
|
|
584
|
+
StapelApiError,
|
|
585
|
+
PreferredWorkspace
|
|
586
|
+
> {
|
|
587
|
+
const api = useWorkspacesApi();
|
|
588
|
+
const queryClient = useQueryClient();
|
|
589
|
+
const options: UseMutationOptions<
|
|
590
|
+
PreferredWorkspaceResult,
|
|
591
|
+
StapelApiError,
|
|
592
|
+
PreferredWorkspace
|
|
593
|
+
> = {
|
|
594
|
+
mutationFn: (body) => api.setPreferredWorkspace(body),
|
|
595
|
+
onSuccess: () => {
|
|
596
|
+
void queryClient.invalidateQueries({
|
|
597
|
+
queryKey: workspacesQueryKeys.list(),
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
return useMutation(options);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Clear the caller's home workspace (DELETE /me/preferred-workspace) — back
|
|
605
|
+
* to the instance default and the rest of the resolution chain. Idempotent. */
|
|
606
|
+
export function useClearPreferredWorkspace(): UseMutationResult<
|
|
607
|
+
PreferredWorkspaceResult,
|
|
608
|
+
StapelApiError,
|
|
609
|
+
void
|
|
610
|
+
> {
|
|
611
|
+
const api = useWorkspacesApi();
|
|
612
|
+
const queryClient = useQueryClient();
|
|
613
|
+
const options: UseMutationOptions<
|
|
614
|
+
PreferredWorkspaceResult,
|
|
615
|
+
StapelApiError,
|
|
616
|
+
void
|
|
617
|
+
> = {
|
|
618
|
+
mutationFn: () => api.clearPreferredWorkspace(),
|
|
619
|
+
onSuccess: () => {
|
|
620
|
+
void queryClient.invalidateQueries({
|
|
621
|
+
queryKey: workspacesQueryKeys.list(),
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
return useMutation(options);
|
|
626
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { Context, ReactElement, ReactNode } from "react";
|
|
11
|
+
import { createRepository } from "@stapel/core";
|
|
12
|
+
import type { Repository } from "@stapel/core";
|
|
13
|
+
import type { Workspace } from "../api/types.js";
|
|
14
|
+
import { useWorkspaces } from "./queries.js";
|
|
15
|
+
import { useSetPreferredWorkspace } from "./mutations.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which workspace am I in — the resolution the whole fleet was inventing
|
|
19
|
+
* separately, in one place.
|
|
20
|
+
*
|
|
21
|
+
* There are three places an answer can come from, and the design IS the order
|
|
22
|
+
* between them:
|
|
23
|
+
*
|
|
24
|
+
* 1. **the URL** — highest. This is what makes several browser tabs each work
|
|
25
|
+
* in a different workspace, and it only works because it is highest: a
|
|
26
|
+
* tab's identity must never be stolen by another tab writing shared
|
|
27
|
+
* storage.
|
|
28
|
+
* 2. **local storage** — the returning-user default on this device.
|
|
29
|
+
* 3. **the backend preference** (`preferred_workspace_id`, stapel-workspaces
|
|
30
|
+
* 0.20) — follows the person across devices and browsers.
|
|
31
|
+
*
|
|
32
|
+
* then the instance's `default_workspace_id`, then the personal workspace,
|
|
33
|
+
* then, and only then, the first row.
|
|
34
|
+
*
|
|
35
|
+
* `workspaces[0]` was the rule every client invented, and it is #239: the
|
|
36
|
+
* owner's pending invitations sat in the org workspace while his screen showed
|
|
37
|
+
* his personal one, because the list is ordered by `-last_accessed_at` and the
|
|
38
|
+
* first row is therefore "wherever you happened to be last", not a choice. It
|
|
39
|
+
* survives here only as step 6 — reached by someone with no URL, no local
|
|
40
|
+
* history, no recorded choice, no instance default and no personal workspace —
|
|
41
|
+
* because for the single-workspace majority it is trivially right, and
|
|
42
|
+
* refusing to pick would punish them with a chooser screen listing one option.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Where {@link WorkspaceSelection.current} came from — the step of the chain
|
|
46
|
+
* that answered. Exposed because "why am I here" is otherwise unanswerable
|
|
47
|
+
* from the outside, and every previous version of this bug was invisible. */
|
|
48
|
+
export type WorkspaceSelectionSource =
|
|
49
|
+
| "url"
|
|
50
|
+
| "local"
|
|
51
|
+
| "preference"
|
|
52
|
+
| "instance-default"
|
|
53
|
+
| "personal"
|
|
54
|
+
| "positional";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How a host binds the URL layer. Both halves are optional: omitted, the
|
|
58
|
+
* library runs on the remaining layers and multi-tab independence is simply
|
|
59
|
+
* not available — it is not an error, it is a host that has not wired it.
|
|
60
|
+
*
|
|
61
|
+
* Controlled props rather than the library reading `window.location` itself,
|
|
62
|
+
* and rather than an injected `{read, write}` adapter, for one mechanical
|
|
63
|
+
* reason: SPA routers navigate through `history.pushState`, which emits no
|
|
64
|
+
* observable event. A library reading `location` could not RE-RENDER when the
|
|
65
|
+
* URL changed, and a `read()` adapter has the same defect in disguise (a
|
|
66
|
+
* correct one would need `subscribe`, re-implementing what the host router's
|
|
67
|
+
* own re-render already gives us for free). Writing `history` directly would
|
|
68
|
+
* also desynchronise the host router's location state.
|
|
69
|
+
*/
|
|
70
|
+
export interface WorkspaceSelectionUrlBinding {
|
|
71
|
+
/**
|
|
72
|
+
* The workspace id the host read out of the URL — `?workspace=<uuid>` by
|
|
73
|
+
* convention. `null`/`undefined` means the URL names none.
|
|
74
|
+
*
|
|
75
|
+
* A query parameter, not a path segment, deliberately: a path parameter
|
|
76
|
+
* would force a `/w/:workspaceId/*` prefix into every host's route table,
|
|
77
|
+
* which is this pair dictating routing structure — the exact thing the seam
|
|
78
|
+
* exists to avoid.
|
|
79
|
+
*/
|
|
80
|
+
readonly urlWorkspaceId?: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* The URL should now name `workspaceId`. The host navigates; this pair
|
|
83
|
+
* never touches history itself.
|
|
84
|
+
*
|
|
85
|
+
* `reason: "switch"` is an explicit picker action and wants a history
|
|
86
|
+
* PUSH — back should return the person to the workspace they came from.
|
|
87
|
+
* `reason: "invalid-url"` is the correction after a URL named a workspace
|
|
88
|
+
* the person cannot open, and wants a REPLACE — a URL that does not work
|
|
89
|
+
* must not stay reachable with the back button.
|
|
90
|
+
*/
|
|
91
|
+
readonly onUrlWorkspaceChange?: (
|
|
92
|
+
workspaceId: string,
|
|
93
|
+
meta: { reason: "switch" | "invalid-url"; history: "push" | "replace" }
|
|
94
|
+
) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What {@link useWorkspaceSelection} hands back. */
|
|
98
|
+
export interface WorkspaceSelection {
|
|
99
|
+
/** The caller's workspaces (empty before load, and for a guest). */
|
|
100
|
+
readonly workspaces: readonly Workspace[];
|
|
101
|
+
/**
|
|
102
|
+
* The resolved workspace — `null` while loading, and for a person who
|
|
103
|
+
* belongs to none.
|
|
104
|
+
*
|
|
105
|
+
* Downstream fetching must gate on THIS, never on a raw id: an id that has
|
|
106
|
+
* not been checked against the fetched list is exactly how a client ends up
|
|
107
|
+
* briefly rendering with a workspace the person cannot open.
|
|
108
|
+
*/
|
|
109
|
+
readonly current: Workspace | null;
|
|
110
|
+
/** Which step of the chain answered, or `null` before one has. */
|
|
111
|
+
readonly source: WorkspaceSelectionSource | null;
|
|
112
|
+
/**
|
|
113
|
+
* The URL named a workspace that is not in the caller's list — it does not
|
|
114
|
+
* exist, or they are not a member, or their membership is suspended (the
|
|
115
|
+
* backend deliberately does not say which). Drive a visible notice off
|
|
116
|
+
* this. The person has already been landed somewhere sane.
|
|
117
|
+
*/
|
|
118
|
+
readonly urlWorkspaceInvalid: boolean;
|
|
119
|
+
/** The workspace list is still loading. */
|
|
120
|
+
readonly loading: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* An EXPLICIT choice by the person — the only entry point that persists.
|
|
123
|
+
* Writes the URL (push), local storage, and the backend preference.
|
|
124
|
+
* Returns immediately; the backend write is fire-and-forget.
|
|
125
|
+
*/
|
|
126
|
+
switchTo: (workspaceId: string) => void;
|
|
127
|
+
/** Refetch the workspace list. */
|
|
128
|
+
refetch: () => Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const SelectionContext: Context<WorkspaceSelection | null> =
|
|
132
|
+
createContext<WorkspaceSelection | null>(null);
|
|
133
|
+
|
|
134
|
+
/** The repository namespace and key holding the local-storage layer. */
|
|
135
|
+
const REPO_NAMESPACE = "workspaces";
|
|
136
|
+
const REPO_KEY = "selected";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `scope: "user"` is deliberate on both counts it implies. It is encrypted at
|
|
140
|
+
* rest like the rest of the session's data, and — the part that matters — it
|
|
141
|
+
* is wiped at logout, so the next account signed in on a shared machine
|
|
142
|
+
* cannot inherit the previous account's workspace pointer.
|
|
143
|
+
*/
|
|
144
|
+
function defaultRepository(): Repository<string> {
|
|
145
|
+
return createRepository<string>(REPO_NAMESPACE, { scope: "user" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Props for {@link WorkspaceSelectionProvider}. */
|
|
149
|
+
export interface WorkspaceSelectionProviderProps
|
|
150
|
+
extends WorkspaceSelectionUrlBinding {
|
|
151
|
+
readonly children: ReactNode;
|
|
152
|
+
/**
|
|
153
|
+
* Override the local-storage layer. Injected for tests and for a host with
|
|
154
|
+
* its own persistence policy; the default is core's `createRepository`
|
|
155
|
+
* (`scope: "user"`). Pass `null` to run with NO local layer at all.
|
|
156
|
+
*/
|
|
157
|
+
readonly repository?: Repository<string> | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolves and holds the active workspace. Wrap the part of the app that
|
|
162
|
+
* needs one; read it with {@link useWorkspaceSelection}.
|
|
163
|
+
*
|
|
164
|
+
* ```tsx
|
|
165
|
+
* // react-router 7 host — the pair never imports the router
|
|
166
|
+
* const [params, setParams] = useSearchParams();
|
|
167
|
+
* <WorkspaceSelectionProvider
|
|
168
|
+
* urlWorkspaceId={params.get("workspace")}
|
|
169
|
+
* onUrlWorkspaceChange={(id, { history }) =>
|
|
170
|
+
* setParams((prev) => {
|
|
171
|
+
* const next = new URLSearchParams(prev);
|
|
172
|
+
* next.set("workspace", id);
|
|
173
|
+
* return next;
|
|
174
|
+
* }, { replace: history === "replace" })}
|
|
175
|
+
* >
|
|
176
|
+
* <App />
|
|
177
|
+
* </WorkspaceSelectionProvider>
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
export function WorkspaceSelectionProvider(
|
|
181
|
+
props: WorkspaceSelectionProviderProps
|
|
182
|
+
): ReactElement {
|
|
183
|
+
const { urlWorkspaceId, onUrlWorkspaceChange, children } = props;
|
|
184
|
+
const query = useWorkspaces();
|
|
185
|
+
// Same rule as `query.refetch` below: TanStack's mutation result is a new
|
|
186
|
+
// object every render, so the stable `mutate` handle is what `switchTo`
|
|
187
|
+
// closes over — depending on the result object would restore the very
|
|
188
|
+
// instability the memoisation exists to remove.
|
|
189
|
+
const savePreferred = useSetPreferredWorkspace().mutate;
|
|
190
|
+
|
|
191
|
+
// Built once. `createRepository` registers a logout wipe hook, so calling it
|
|
192
|
+
// per render would register one per render.
|
|
193
|
+
const repoRef = useRef<Repository<string> | null | undefined>(undefined);
|
|
194
|
+
if (repoRef.current === undefined) {
|
|
195
|
+
repoRef.current =
|
|
196
|
+
props.repository === undefined ? defaultRepository() : props.repository;
|
|
197
|
+
}
|
|
198
|
+
const repo = repoRef.current;
|
|
199
|
+
|
|
200
|
+
const [localId, setLocalId] = useState<string | null>(null);
|
|
201
|
+
const [localRead, setLocalRead] = useState(!repo);
|
|
202
|
+
const [chosenId, setChosenId] = useState<string | null>(null);
|
|
203
|
+
|
|
204
|
+
// The local layer is read EXACTLY ONCE, at mount, and never subscribed to.
|
|
205
|
+
//
|
|
206
|
+
// That is the multi-tab requirement, stated as code. The obvious "improvement"
|
|
207
|
+
// is a `storage` event listener to keep tabs in sync — and it is precisely
|
|
208
|
+
// what breaks the feature: `storage` fires in the OTHER tabs on every write,
|
|
209
|
+
// so switching in tab A would drag tab B onto A's workspace, which is the
|
|
210
|
+
// behaviour the URL layer exists to prevent. There is no listener here, and
|
|
211
|
+
// there must never be one.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!repo) return;
|
|
214
|
+
let cancelled = false;
|
|
215
|
+
void repo
|
|
216
|
+
.get(REPO_KEY)
|
|
217
|
+
.then((value) => {
|
|
218
|
+
if (cancelled) return;
|
|
219
|
+
if (typeof value === "string") setLocalId(value);
|
|
220
|
+
})
|
|
221
|
+
.catch(() => undefined)
|
|
222
|
+
.finally(() => {
|
|
223
|
+
if (!cancelled) setLocalRead(true);
|
|
224
|
+
});
|
|
225
|
+
return () => {
|
|
226
|
+
cancelled = true;
|
|
227
|
+
};
|
|
228
|
+
}, [repo]);
|
|
229
|
+
|
|
230
|
+
const workspaces = useMemo(
|
|
231
|
+
() => query.data?.workspaces ?? [],
|
|
232
|
+
[query.data]
|
|
233
|
+
);
|
|
234
|
+
const loading = query.isLoading || !localRead;
|
|
235
|
+
|
|
236
|
+
const memberOf = useCallback(
|
|
237
|
+
(id: string | null | undefined): Workspace | null =>
|
|
238
|
+
id ? (workspaces.find((w) => w.id === id) ?? null) : null,
|
|
239
|
+
[workspaces]
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// The chain. Runs against the FETCHED list only — never against an
|
|
243
|
+
// unresolved one, or a raw stored id would be briefly authoritative and the
|
|
244
|
+
// app would fetch a workspace it has not verified the person can open.
|
|
245
|
+
const resolved = useMemo<{
|
|
246
|
+
workspace: Workspace | null;
|
|
247
|
+
source: WorkspaceSelectionSource | null;
|
|
248
|
+
}>(() => {
|
|
249
|
+
if (loading || workspaces.length === 0) {
|
|
250
|
+
return { workspace: null, source: null };
|
|
251
|
+
}
|
|
252
|
+
const explicit = memberOf(chosenId);
|
|
253
|
+
if (explicit) return { workspace: explicit, source: "url" };
|
|
254
|
+
const fromUrl = memberOf(urlWorkspaceId);
|
|
255
|
+
if (fromUrl) return { workspace: fromUrl, source: "url" };
|
|
256
|
+
const fromLocal = memberOf(localId);
|
|
257
|
+
if (fromLocal) return { workspace: fromLocal, source: "local" };
|
|
258
|
+
const fromPreference = memberOf(query.data?.preferred_workspace_id);
|
|
259
|
+
if (fromPreference)
|
|
260
|
+
return { workspace: fromPreference, source: "preference" };
|
|
261
|
+
const fromInstance = memberOf(query.data?.default_workspace_id);
|
|
262
|
+
if (fromInstance)
|
|
263
|
+
return { workspace: fromInstance, source: "instance-default" };
|
|
264
|
+
const personal = workspaces.find((w) => w.type === "personal");
|
|
265
|
+
if (personal) return { workspace: personal, source: "personal" };
|
|
266
|
+
// Last resort only — see this module's header for why it is last and why
|
|
267
|
+
// it is still here.
|
|
268
|
+
return { workspace: workspaces[0] ?? null, source: "positional" };
|
|
269
|
+
}, [loading, workspaces, memberOf, chosenId, urlWorkspaceId, localId, query.data]);
|
|
270
|
+
|
|
271
|
+
// The URL named something the person cannot open. Deliberately NOT an
|
|
272
|
+
// error state that blanks the screen, and deliberately NOT a silent
|
|
273
|
+
// substitution: they clicked a link expecting workspace X, and quietly
|
|
274
|
+
// showing workspace Y's data is the same misattribution class as #239.
|
|
275
|
+
// They land through the rest of the chain, and this flag drives the notice.
|
|
276
|
+
const urlWorkspaceInvalid =
|
|
277
|
+
!loading &&
|
|
278
|
+
workspaces.length > 0 &&
|
|
279
|
+
chosenId === null &&
|
|
280
|
+
!!urlWorkspaceId &&
|
|
281
|
+
memberOf(urlWorkspaceId) === null;
|
|
282
|
+
|
|
283
|
+
// Correct the address bar once, with a REPLACE so the broken URL does not
|
|
284
|
+
// stay reachable with the back button.
|
|
285
|
+
const correctedFor = useRef<string | null>(null);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!urlWorkspaceInvalid || !resolved.workspace) return;
|
|
288
|
+
if (correctedFor.current === urlWorkspaceId) return;
|
|
289
|
+
correctedFor.current = urlWorkspaceId ?? null;
|
|
290
|
+
onUrlWorkspaceChange?.(resolved.workspace.id, {
|
|
291
|
+
reason: "invalid-url",
|
|
292
|
+
history: "replace",
|
|
293
|
+
});
|
|
294
|
+
}, [urlWorkspaceInvalid, resolved.workspace, urlWorkspaceId, onUrlWorkspaceChange]);
|
|
295
|
+
|
|
296
|
+
// A stale local pointer is DELETED rather than rewritten to the fallback.
|
|
297
|
+
// Writing the fallback would ossify a guess into something that outranks a
|
|
298
|
+
// later-corrected backend preference or instance default; deleting it just
|
|
299
|
+
// stops the next boot retrying a dead id.
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (loading || !repo || localId === null) return;
|
|
302
|
+
if (memberOf(localId)) return;
|
|
303
|
+
setLocalId(null);
|
|
304
|
+
void repo.del(REPO_KEY).catch(() => undefined);
|
|
305
|
+
}, [loading, repo, localId, memberOf]);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The only path that persists anything, and that is the whole write policy:
|
|
309
|
+
* a URL is per-tab CONTEXT, a picker click is a CHOICE.
|
|
310
|
+
*
|
|
311
|
+
* If resolving from a shared link wrote the preference, one link pasted into
|
|
312
|
+
* a chat would permanently repoint its recipient's home — so a URL-driven
|
|
313
|
+
* resolution writes nothing at all, on either layer. A person who is in a
|
|
314
|
+
* URL-driven workspace and then picks it in the switcher is making a choice,
|
|
315
|
+
* and that goes through here.
|
|
316
|
+
*/
|
|
317
|
+
const switchTo = useCallback(
|
|
318
|
+
(workspaceId: string) => {
|
|
319
|
+
if (!workspaces.some((w) => w.id === workspaceId)) return;
|
|
320
|
+
setChosenId(workspaceId);
|
|
321
|
+
if (repo) void repo.set(REPO_KEY, workspaceId).catch(() => undefined);
|
|
322
|
+
// Fire-and-forget: the tab has already switched. A switch that blocks on
|
|
323
|
+
// a flaky network is worse than a preference that lags one click.
|
|
324
|
+
savePreferred({ workspace_id: workspaceId });
|
|
325
|
+
onUrlWorkspaceChange?.(workspaceId, {
|
|
326
|
+
reason: "switch",
|
|
327
|
+
history: "push",
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
[workspaces, repo, savePreferred, onUrlWorkspaceChange]
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// `query.refetch`, NOT `query`: TanStack returns a NEW result object on every
|
|
334
|
+
// render, so depending on the whole query would make this callback — and
|
|
335
|
+
// therefore the memoised bag below — change identity every render, which is
|
|
336
|
+
// exactly the #251 render-loop this memoisation exists to prevent. The
|
|
337
|
+
// `refetch` handle itself is stable.
|
|
338
|
+
const refetchQuery = query.refetch;
|
|
339
|
+
const refetch = useCallback(async () => {
|
|
340
|
+
await refetchQuery();
|
|
341
|
+
}, [refetchQuery]);
|
|
342
|
+
|
|
343
|
+
// Memoised, and that is load-bearing rather than a performance nicety
|
|
344
|
+
// (ironmemo #251): consumers put `current` straight into `useEffect`
|
|
345
|
+
// dependency arrays to refetch when the workspace changes. A fresh object
|
|
346
|
+
// literal here would be a new value on every render of this provider, so
|
|
347
|
+
// every such effect would re-run on every render — and when the effect also
|
|
348
|
+
// sets state, that is an unbounded render loop whose only symptom is a
|
|
349
|
+
// spinner that never resolves, with nothing in the console.
|
|
350
|
+
const value = useMemo<WorkspaceSelection>(
|
|
351
|
+
() => ({
|
|
352
|
+
workspaces,
|
|
353
|
+
current: resolved.workspace,
|
|
354
|
+
source: resolved.source,
|
|
355
|
+
urlWorkspaceInvalid,
|
|
356
|
+
loading,
|
|
357
|
+
switchTo,
|
|
358
|
+
refetch,
|
|
359
|
+
}),
|
|
360
|
+
[
|
|
361
|
+
workspaces,
|
|
362
|
+
resolved.workspace,
|
|
363
|
+
resolved.source,
|
|
364
|
+
urlWorkspaceInvalid,
|
|
365
|
+
loading,
|
|
366
|
+
switchTo,
|
|
367
|
+
refetch,
|
|
368
|
+
]
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<SelectionContext.Provider value={value}>
|
|
373
|
+
{children}
|
|
374
|
+
</SelectionContext.Provider>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The resolved active workspace. Throws outside
|
|
380
|
+
* {@link WorkspaceSelectionProvider} — a silent `null` here would be read as
|
|
381
|
+
* "no workspace yet" and produce exactly the blank screen this pair exists to
|
|
382
|
+
* prevent.
|
|
383
|
+
*/
|
|
384
|
+
export function useWorkspaceSelection(): WorkspaceSelection {
|
|
385
|
+
const value = useContext(SelectionContext);
|
|
386
|
+
if (!value) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
"useWorkspaceSelection must be used inside <WorkspaceSelectionProvider>"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return value;
|
|
392
|
+
}
|