@tuturuuu/ui 0.37.1 → 0.37.2
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 +12 -0
- package/package.json +3 -3
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -0
- package/src/components/ui/custom/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +87 -359
- package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- package/src/components/ui/resizable.tsx +2 -1
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
- package/src/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
5
|
+
import { useVisibleWorkspaces } from '../use-visible-workspaces';
|
|
6
|
+
import { WorkspaceVisibilityProvider } from '../use-workspace-visibility';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({ list: vi.fn(), hidden: vi.fn() }));
|
|
9
|
+
vi.mock('@tuturuuu/internal-api/workspaces', () => ({
|
|
10
|
+
listWorkspaces: mocks.list,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
13
|
+
getCurrentUserHiddenWorkspaces: mocks.hidden,
|
|
14
|
+
updateCurrentUserHiddenWorkspace: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
mocks.list.mockResolvedValue([{ id: 'visible' }, { id: 'hidden' }]);
|
|
19
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['hidden'] });
|
|
20
|
+
});
|
|
21
|
+
function fixture() {
|
|
22
|
+
const client = new QueryClient({
|
|
23
|
+
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
|
24
|
+
});
|
|
25
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
26
|
+
<QueryClientProvider client={client}>
|
|
27
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
28
|
+
{children}
|
|
29
|
+
</WorkspaceVisibilityProvider>
|
|
30
|
+
</QueryClientProvider>
|
|
31
|
+
);
|
|
32
|
+
return { client, wrapper };
|
|
33
|
+
}
|
|
34
|
+
it('retains an explicit zero bound and only exposes known visible choices', async () => {
|
|
35
|
+
const { client, wrapper } = fixture();
|
|
36
|
+
const { result } = renderHook(() => useVisibleWorkspaces(true, 0), {
|
|
37
|
+
wrapper,
|
|
38
|
+
});
|
|
39
|
+
await waitFor(() => expect(result.current.data).toEqual([{ id: 'visible' }]));
|
|
40
|
+
expect(mocks.list).toHaveBeenCalledWith({ limit: 0 });
|
|
41
|
+
expect(client.getQueryData(['workspace-ui-list', 'A', 0])).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
it('settled private discovery failure is an error rather than permanent loading', async () => {
|
|
44
|
+
mocks.hidden.mockRejectedValue(new Error('offline'));
|
|
45
|
+
const { client, wrapper } = fixture();
|
|
46
|
+
const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
|
|
47
|
+
await waitFor(() =>
|
|
48
|
+
expect(client.getQueryState(['workspace-hidden', 'A'])?.status).toBe(
|
|
49
|
+
'error'
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
53
|
+
expect(result.current.isError).toBe(true);
|
|
54
|
+
expect(result.current.error?.message).toBe('offline');
|
|
55
|
+
expect(result.current.data).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('distinguishes an absent personal workspace from one excluded by Hidden preferences', async () => {
|
|
59
|
+
mocks.list.mockResolvedValue([{ id: 'team', personal: false }]);
|
|
60
|
+
const { client, wrapper } = fixture();
|
|
61
|
+
const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
|
|
62
|
+
await waitFor(() =>
|
|
63
|
+
expect(result.current.personalWorkspaceMissing).toBe(true)
|
|
64
|
+
);
|
|
65
|
+
mocks.list.mockResolvedValue([
|
|
66
|
+
{ id: 'team', personal: false },
|
|
67
|
+
{ id: 'personal', personal: true },
|
|
68
|
+
]);
|
|
69
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['personal'] });
|
|
70
|
+
await result.current.refetch();
|
|
71
|
+
await waitFor(() =>
|
|
72
|
+
expect(result.current.data).toEqual([{ id: 'team', personal: false }])
|
|
73
|
+
);
|
|
74
|
+
expect(result.current.personalWorkspaceMissing).toBe(false);
|
|
75
|
+
expect(client.getQueryData(['workspace-ui-list', 'A'])).toHaveLength(2);
|
|
76
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceVisibility,
|
|
7
|
+
WorkspaceVisibilityProvider,
|
|
8
|
+
} from '../use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));
|
|
11
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
12
|
+
getCurrentUserHiddenWorkspaces: mocks.get,
|
|
13
|
+
updateCurrentUserHiddenWorkspace: mocks.put,
|
|
14
|
+
}));
|
|
15
|
+
function deferred<T>() {
|
|
16
|
+
let resolve!: (v: T) => void;
|
|
17
|
+
let reject!: (e: Error) => void;
|
|
18
|
+
const promise = new Promise<T>((a, b) => {
|
|
19
|
+
resolve = a;
|
|
20
|
+
reject = b;
|
|
21
|
+
});
|
|
22
|
+
return { promise, resolve, reject };
|
|
23
|
+
}
|
|
24
|
+
function setup(
|
|
25
|
+
actor = 'A',
|
|
26
|
+
client = new QueryClient({
|
|
27
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
28
|
+
})
|
|
29
|
+
) {
|
|
30
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
31
|
+
<QueryClientProvider client={client}>
|
|
32
|
+
<WorkspaceVisibilityProvider actorId={actor}>
|
|
33
|
+
{children}
|
|
34
|
+
</WorkspaceVisibilityProvider>
|
|
35
|
+
</QueryClientProvider>
|
|
36
|
+
);
|
|
37
|
+
return { ...renderHook(() => useWorkspaceVisibility(), { wrapper }), client };
|
|
38
|
+
}
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
mocks.get.mockResolvedValue({ hiddenWorkspaceIds: [] });
|
|
42
|
+
mocks.put.mockResolvedValue({});
|
|
43
|
+
});
|
|
44
|
+
describe('private owner-scoped workspace visibility', () => {
|
|
45
|
+
it('optimistically hides and rolls back only the failed workspace', async () => {
|
|
46
|
+
const write = deferred<object>();
|
|
47
|
+
mocks.put.mockReturnValue(write.promise);
|
|
48
|
+
const hook = setup();
|
|
49
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
50
|
+
let operation!: Promise<void>;
|
|
51
|
+
act(() => {
|
|
52
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
53
|
+
});
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
|
|
56
|
+
);
|
|
57
|
+
await act(async () => {
|
|
58
|
+
write.reject(new Error('offline'));
|
|
59
|
+
await expect(operation).rejects.toThrow('offline');
|
|
60
|
+
});
|
|
61
|
+
expect(hook.result.current.hiddenIds).toEqual([]);
|
|
62
|
+
expect(hook.result.current.updateError).not.toBeNull();
|
|
63
|
+
expect(mocks.put).toHaveBeenCalledWith('ws-1', true, 'A');
|
|
64
|
+
await act(async () => {
|
|
65
|
+
await hook.result.current.refetch();
|
|
66
|
+
});
|
|
67
|
+
expect(hook.result.current.updateError).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
it('a stale refresh cannot replace optimistic state', async () => {
|
|
70
|
+
const hook = setup();
|
|
71
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
72
|
+
const read = deferred<{ hiddenWorkspaceIds: string[] }>();
|
|
73
|
+
mocks.get
|
|
74
|
+
.mockImplementationOnce(() => read.promise)
|
|
75
|
+
.mockResolvedValue({ hiddenWorkspaceIds: ['ws-1'] });
|
|
76
|
+
act(() => {
|
|
77
|
+
void hook.result.current.refetch();
|
|
78
|
+
});
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await hook.result.current.setHidden('ws-1', true);
|
|
81
|
+
});
|
|
82
|
+
await act(async () => {
|
|
83
|
+
read.resolve({ hiddenWorkspaceIds: [] });
|
|
84
|
+
});
|
|
85
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1']);
|
|
86
|
+
});
|
|
87
|
+
it('suppresses duplicate writes for the same pending workspace', async () => {
|
|
88
|
+
const write = deferred<object>();
|
|
89
|
+
mocks.put.mockReturnValue(write.promise);
|
|
90
|
+
const hook = setup();
|
|
91
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
92
|
+
let operation!: Promise<void>;
|
|
93
|
+
act(() => {
|
|
94
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
95
|
+
});
|
|
96
|
+
await waitFor(() =>
|
|
97
|
+
expect(hook.result.current.pending.has('ws-1')).toBe(true)
|
|
98
|
+
);
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await hook.result.current.setHidden('ws-1', true);
|
|
101
|
+
});
|
|
102
|
+
expect(mocks.put).toHaveBeenCalledTimes(1);
|
|
103
|
+
await act(async () => {
|
|
104
|
+
write.resolve({});
|
|
105
|
+
await operation;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
it('removes private owner caches on logout and ignores late response', async () => {
|
|
109
|
+
const hook = setup();
|
|
110
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
111
|
+
const write = deferred<object>();
|
|
112
|
+
mocks.put.mockReturnValue(write.promise);
|
|
113
|
+
let operation!: Promise<void>;
|
|
114
|
+
act(() => {
|
|
115
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
116
|
+
});
|
|
117
|
+
await waitFor(() =>
|
|
118
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
|
|
119
|
+
);
|
|
120
|
+
hook.unmount();
|
|
121
|
+
write.resolve({});
|
|
122
|
+
await operation;
|
|
123
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
124
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'B'])).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it('two actors use independent hidden cache keys', async () => {
|
|
127
|
+
mocks.get.mockImplementation(async (actor: string) => ({
|
|
128
|
+
hiddenWorkspaceIds: actor === 'A' ? ['ws-1'] : [],
|
|
129
|
+
}));
|
|
130
|
+
const client = new QueryClient({
|
|
131
|
+
defaultOptions: { queries: { retry: false } },
|
|
132
|
+
});
|
|
133
|
+
const a = setup('A', client);
|
|
134
|
+
const b = setup('B', client);
|
|
135
|
+
await waitFor(() => expect(a.result.current.hiddenIds).toEqual(['ws-1']));
|
|
136
|
+
await waitFor(() => expect(b.result.current.known).toBe(true));
|
|
137
|
+
expect(b.result.current.hiddenIds).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('settles a refetch safely after logout without returning private old-actor data', async () => {
|
|
142
|
+
const hook = setup();
|
|
143
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
144
|
+
const read = deferred<{ hiddenWorkspaceIds: string[] }>();
|
|
145
|
+
mocks.get.mockReturnValue(read.promise);
|
|
146
|
+
let operation!: ReturnType<typeof hook.result.current.refetch>;
|
|
147
|
+
act(() => {
|
|
148
|
+
operation = hook.result.current.refetch();
|
|
149
|
+
});
|
|
150
|
+
await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
|
|
151
|
+
hook.unmount();
|
|
152
|
+
read.resolve({ hiddenWorkspaceIds: ['old-private'] });
|
|
153
|
+
await expect(operation).resolves.toBeNull();
|
|
154
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
155
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useWorkspaceUser } from '../use-workspace-user';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceVisibility,
|
|
7
|
+
WorkspaceVisibilityProvider,
|
|
8
|
+
} from '../use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ hidden: vi.fn(), profile: vi.fn() }));
|
|
11
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
12
|
+
getCurrentUserHiddenWorkspaces: mocks.hidden,
|
|
13
|
+
getCurrentUserProfile: mocks.profile,
|
|
14
|
+
updateCurrentUserHiddenWorkspace: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['private'] });
|
|
19
|
+
mocks.profile.mockResolvedValue({ id: 'A' });
|
|
20
|
+
});
|
|
21
|
+
function Probe() {
|
|
22
|
+
const hidden = useWorkspaceVisibility();
|
|
23
|
+
const user = useWorkspaceUser();
|
|
24
|
+
return (
|
|
25
|
+
<output>
|
|
26
|
+
{JSON.stringify({
|
|
27
|
+
known: hidden.known,
|
|
28
|
+
ids: hidden.hiddenIds,
|
|
29
|
+
user: user.data?.id,
|
|
30
|
+
})}
|
|
31
|
+
</output>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function fixture() {
|
|
35
|
+
return new QueryClient({
|
|
36
|
+
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
it('retains the root owner cache across same-actor route unmount and remount', async () => {
|
|
40
|
+
const client = fixture();
|
|
41
|
+
function Tree({ route }: { route: boolean }) {
|
|
42
|
+
return (
|
|
43
|
+
<QueryClientProvider client={client}>
|
|
44
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
45
|
+
{route && (
|
|
46
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
47
|
+
<Probe />
|
|
48
|
+
</WorkspaceVisibilityProvider>
|
|
49
|
+
)}
|
|
50
|
+
</WorkspaceVisibilityProvider>
|
|
51
|
+
</QueryClientProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const tree = render(<Tree route />);
|
|
55
|
+
await waitFor(() =>
|
|
56
|
+
expect(screen.getByRole('status').textContent).toContain('"known":true')
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() =>
|
|
59
|
+
expect(client.getQueryData(['workspace-user', 'A'])).toMatchObject({
|
|
60
|
+
id: 'A',
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
tree.rerender(<Tree route={false} />);
|
|
64
|
+
expect(client.getQueryData(['workspace-hidden', 'A'])).toEqual(['private']);
|
|
65
|
+
tree.rerender(<Tree route />);
|
|
66
|
+
await waitFor(() =>
|
|
67
|
+
expect(screen.getByRole('status').textContent).toContain('private')
|
|
68
|
+
);
|
|
69
|
+
expect(mocks.hidden).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(client.getQueryData(['workspace-user'])).toBeUndefined();
|
|
72
|
+
tree.unmount();
|
|
73
|
+
expect(client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
74
|
+
expect(client.getQueryData(['workspace-user', 'A'])).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it('does not read profiles or private preferences without verified identity', async () => {
|
|
77
|
+
const client = fixture();
|
|
78
|
+
render(
|
|
79
|
+
<QueryClientProvider client={client}>
|
|
80
|
+
<Probe />
|
|
81
|
+
</QueryClientProvider>
|
|
82
|
+
);
|
|
83
|
+
await act(async () => {});
|
|
84
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
85
|
+
expect(mocks.hidden).not.toHaveBeenCalled();
|
|
86
|
+
expect(screen.getByRole('status').textContent).toContain('"known":false');
|
|
87
|
+
});
|
|
88
|
+
it('fails a mismatched route actor closed under a verified root', async () => {
|
|
89
|
+
const client = fixture();
|
|
90
|
+
render(
|
|
91
|
+
<QueryClientProvider client={client}>
|
|
92
|
+
<WorkspaceVisibilityProvider actorId="B">
|
|
93
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
94
|
+
<Probe />
|
|
95
|
+
</WorkspaceVisibilityProvider>
|
|
96
|
+
</WorkspaceVisibilityProvider>
|
|
97
|
+
</QueryClientProvider>
|
|
98
|
+
);
|
|
99
|
+
await act(async () => {});
|
|
100
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
101
|
+
expect(mocks.hidden).not.toHaveBeenCalled();
|
|
102
|
+
expect(screen.getByRole('status').textContent).toContain('"known":false');
|
|
103
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { listWorkspaces } from '@tuturuuu/internal-api/workspaces';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceActor,
|
|
7
|
+
useWorkspaceVisibility,
|
|
8
|
+
} from './use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
/** UI choices only. Do not use this hook for access or membership reconciliation. */
|
|
11
|
+
export function useVisibleWorkspaces(
|
|
12
|
+
enabled = true,
|
|
13
|
+
limit?: number,
|
|
14
|
+
options?: { refetchInterval?: number }
|
|
15
|
+
) {
|
|
16
|
+
const actor = useWorkspaceActor();
|
|
17
|
+
const visibility = useWorkspaceVisibility(enabled);
|
|
18
|
+
const query = useQuery({
|
|
19
|
+
queryKey: [
|
|
20
|
+
'workspace-ui-list',
|
|
21
|
+
actor?.actorId,
|
|
22
|
+
...(limit !== undefined ? [limit] : []),
|
|
23
|
+
],
|
|
24
|
+
enabled: enabled && Boolean(actor),
|
|
25
|
+
refetchInterval: options?.refetchInterval,
|
|
26
|
+
queryFn: async () => {
|
|
27
|
+
actor!.assertActive();
|
|
28
|
+
const result = await listWorkspaces(
|
|
29
|
+
limit !== undefined ? { limit } : undefined
|
|
30
|
+
);
|
|
31
|
+
actor!.assertActive();
|
|
32
|
+
return result;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const data = visibility.known
|
|
36
|
+
? query.data?.filter(
|
|
37
|
+
(workspace) => !visibility.hiddenIds.includes(workspace.id)
|
|
38
|
+
)
|
|
39
|
+
: undefined;
|
|
40
|
+
return {
|
|
41
|
+
...query,
|
|
42
|
+
personalWorkspaceMissing:
|
|
43
|
+
visibility.known &&
|
|
44
|
+
query.data !== undefined &&
|
|
45
|
+
!query.data.some((workspace) => workspace.personal),
|
|
46
|
+
data,
|
|
47
|
+
isLoading:
|
|
48
|
+
enabled &&
|
|
49
|
+
(query.isLoading || (!visibility.known && !visibility.isError)),
|
|
50
|
+
isError: query.isError || visibility.isError,
|
|
51
|
+
error: query.error ?? visibility.error,
|
|
52
|
+
refetch: async () => {
|
|
53
|
+
const visibilityResult = await visibility.refetch();
|
|
54
|
+
return visibilityResult ? query.refetch() : null;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useQuery } from '@tanstack/react-query';
|
|
4
4
|
import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
|
|
5
5
|
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
6
|
+
import { useWorkspaceActor } from './use-workspace-visibility';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Hook to fetch the current workspace user with their private details
|
|
@@ -10,11 +11,18 @@ import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
|
10
11
|
*
|
|
11
12
|
* @returns Query object with user data, loading state, and error info
|
|
12
13
|
*/
|
|
13
|
-
export function useWorkspaceUser() {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
export function useWorkspaceUser(actorId?: string) {
|
|
15
|
+
const actor = useWorkspaceActor();
|
|
16
|
+
const resolvedActorId = actorId ?? actor?.actorId;
|
|
17
|
+
const query = useQuery({
|
|
18
|
+
queryKey: ['workspace-user', resolvedActorId],
|
|
19
|
+
enabled: Boolean(actor) && actor?.actorId === resolvedActorId,
|
|
16
20
|
queryFn: async (): Promise<WorkspaceUser> => {
|
|
21
|
+
actor!.assertActive();
|
|
17
22
|
const data = await getCurrentUserProfile();
|
|
23
|
+
actor!.assertActive();
|
|
24
|
+
if (data.id !== resolvedActorId)
|
|
25
|
+
throw new Error('Workspace account changed');
|
|
18
26
|
return {
|
|
19
27
|
id: data.id,
|
|
20
28
|
email: data.email,
|
|
@@ -30,4 +38,8 @@ export function useWorkspaceUser() {
|
|
|
30
38
|
gcTime: 10 * 60 * 1000, // Keep cached data for 10 minutes
|
|
31
39
|
retry: 1, // Retry once on failure
|
|
32
40
|
});
|
|
41
|
+
return {
|
|
42
|
+
...query,
|
|
43
|
+
data: actor?.actorId === resolvedActorId ? query.data : undefined,
|
|
44
|
+
};
|
|
33
45
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type QueryClient,
|
|
5
|
+
useQuery,
|
|
6
|
+
useQueryClient,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
import { InternalApiError } from '@tuturuuu/internal-api/client';
|
|
9
|
+
import {
|
|
10
|
+
getCurrentUserHiddenWorkspaces,
|
|
11
|
+
updateCurrentUserHiddenWorkspace,
|
|
12
|
+
} from '@tuturuuu/internal-api/users';
|
|
13
|
+
import {
|
|
14
|
+
createContext,
|
|
15
|
+
createElement,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
useContext,
|
|
18
|
+
useEffect,
|
|
19
|
+
useMemo,
|
|
20
|
+
useRef,
|
|
21
|
+
useState,
|
|
22
|
+
} from 'react';
|
|
23
|
+
|
|
24
|
+
const controls = new WeakMap<
|
|
25
|
+
QueryClient,
|
|
26
|
+
Map<string, { revision: number; pending: Set<string> }>
|
|
27
|
+
>();
|
|
28
|
+
function ownerControl(client: QueryClient, actorId: string) {
|
|
29
|
+
let actors = controls.get(client);
|
|
30
|
+
if (!actors) {
|
|
31
|
+
actors = new Map();
|
|
32
|
+
controls.set(client, actors);
|
|
33
|
+
}
|
|
34
|
+
let control = actors.get(actorId);
|
|
35
|
+
if (!control) {
|
|
36
|
+
control = { revision: 0, pending: new Set() };
|
|
37
|
+
actors.set(actorId, control);
|
|
38
|
+
}
|
|
39
|
+
return control;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ActorScope = {
|
|
43
|
+
actorId: string;
|
|
44
|
+
lifetime: { active: boolean };
|
|
45
|
+
assertActive: () => void;
|
|
46
|
+
};
|
|
47
|
+
const ActorContext = createContext<ActorScope | null>(null);
|
|
48
|
+
|
|
49
|
+
/** Feed only a server-verified actor; preferences never become membership data. */
|
|
50
|
+
export function WorkspaceVisibilityProvider({
|
|
51
|
+
actorId,
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
actorId: string;
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
}) {
|
|
57
|
+
const parent = useContext(ActorContext);
|
|
58
|
+
const current = useRef(actorId);
|
|
59
|
+
current.current = actorId;
|
|
60
|
+
const scope = useMemo(() => {
|
|
61
|
+
if (parent?.actorId === actorId) return parent;
|
|
62
|
+
const lifetime = { active: true };
|
|
63
|
+
return {
|
|
64
|
+
actorId,
|
|
65
|
+
lifetime,
|
|
66
|
+
assertActive() {
|
|
67
|
+
parent?.assertActive();
|
|
68
|
+
if (
|
|
69
|
+
!lifetime.active ||
|
|
70
|
+
current.current !== actorId ||
|
|
71
|
+
(parent && parent.actorId !== actorId)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('Workspace account changed');
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}, [actorId, parent]);
|
|
78
|
+
const client = useQueryClient();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (scope === parent) return;
|
|
81
|
+
scope.lifetime.active = true;
|
|
82
|
+
return () => {
|
|
83
|
+
scope.lifetime.active = false;
|
|
84
|
+
controls.get(client)?.delete(scope.actorId);
|
|
85
|
+
// A private cache is never retained across logout/account replacement.
|
|
86
|
+
client.removeQueries({ queryKey: ['workspace-hidden', scope.actorId] });
|
|
87
|
+
client.removeQueries({ queryKey: ['workspace-ui-list', scope.actorId] });
|
|
88
|
+
client.removeQueries({ queryKey: ['user-workspaces', scope.actorId] });
|
|
89
|
+
client.removeQueries({ queryKey: ['all-user-boards', scope.actorId] });
|
|
90
|
+
client.removeQueries({ queryKey: ['chat-workspaces', scope.actorId] });
|
|
91
|
+
client.removeQueries({ queryKey: ['task-source-boards', scope.actorId] });
|
|
92
|
+
client.removeQueries({ queryKey: ['workspace-user', scope.actorId] });
|
|
93
|
+
client.removeQueries({
|
|
94
|
+
queryKey: ['workspace-invitations', scope.actorId],
|
|
95
|
+
});
|
|
96
|
+
client.removeQueries({
|
|
97
|
+
predicate: (query) =>
|
|
98
|
+
query.queryKey[0] === 'global-command-launcher' &&
|
|
99
|
+
query.queryKey.includes(scope.actorId),
|
|
100
|
+
});
|
|
101
|
+
client.removeQueries({
|
|
102
|
+
queryKey: ['workspace-select-current-workspace', scope.actorId],
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}, [scope, parent, client]);
|
|
106
|
+
return createElement(
|
|
107
|
+
ActorContext.Provider,
|
|
108
|
+
{
|
|
109
|
+
key: actorId,
|
|
110
|
+
value: parent && parent.actorId !== actorId ? null : scope,
|
|
111
|
+
},
|
|
112
|
+
children
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useWorkspaceActor() {
|
|
117
|
+
return useContext(ActorContext);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useWorkspaceVisibility(enabled = true) {
|
|
121
|
+
const scope = useWorkspaceActor();
|
|
122
|
+
const actorId = scope?.actorId;
|
|
123
|
+
const client = useQueryClient();
|
|
124
|
+
const key = ['workspace-hidden', actorId] as const;
|
|
125
|
+
const control = ownerControl(client, actorId ?? 'signed-out');
|
|
126
|
+
const [pending, setPending] = useState<Set<string>>(new Set());
|
|
127
|
+
const [updateError, setUpdateError] = useState<Error | null>(null);
|
|
128
|
+
const query = useQuery({
|
|
129
|
+
queryKey: key,
|
|
130
|
+
enabled: enabled && Boolean(scope),
|
|
131
|
+
queryFn: async () => {
|
|
132
|
+
scope!.assertActive();
|
|
133
|
+
if (control.pending.size > 0) {
|
|
134
|
+
const optimistic = client.getQueryData<string[]>(key);
|
|
135
|
+
if (optimistic) return optimistic;
|
|
136
|
+
}
|
|
137
|
+
const requestRevision = control.revision;
|
|
138
|
+
const response = await getCurrentUserHiddenWorkspaces(actorId!);
|
|
139
|
+
scope!.assertActive();
|
|
140
|
+
// A refresh started before a mutation cannot overwrite its optimistic state.
|
|
141
|
+
if (requestRevision !== control.revision) {
|
|
142
|
+
return (
|
|
143
|
+
client.getQueryData<string[]>(key) ?? response.hiddenWorkspaceIds
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return response.hiddenWorkspaceIds;
|
|
147
|
+
},
|
|
148
|
+
staleTime: 30_000,
|
|
149
|
+
retry: 1,
|
|
150
|
+
});
|
|
151
|
+
const accountDenied =
|
|
152
|
+
query.error instanceof InternalApiError &&
|
|
153
|
+
[401, 403, 409].includes(query.error.status);
|
|
154
|
+
const ids = accountDenied ? undefined : query.data;
|
|
155
|
+
async function setHidden(workspaceId: string, hidden: boolean) {
|
|
156
|
+
if (!scope || !ids || control.pending.has(workspaceId)) return;
|
|
157
|
+
scope.assertActive();
|
|
158
|
+
const ownerKey = ['workspace-hidden', scope.actorId] as const;
|
|
159
|
+
const wasHidden = ids.includes(workspaceId);
|
|
160
|
+
control.pending.add(workspaceId);
|
|
161
|
+
setPending(new Set(control.pending));
|
|
162
|
+
setUpdateError(null);
|
|
163
|
+
control.revision++;
|
|
164
|
+
await client.cancelQueries({ queryKey: ownerKey });
|
|
165
|
+
scope.assertActive();
|
|
166
|
+
client.setQueryData<string[]>(ownerKey, (current = []) =>
|
|
167
|
+
hidden
|
|
168
|
+
? [...new Set([...current, workspaceId])]
|
|
169
|
+
: current.filter((id) => id !== workspaceId)
|
|
170
|
+
);
|
|
171
|
+
try {
|
|
172
|
+
await updateCurrentUserHiddenWorkspace(
|
|
173
|
+
workspaceId,
|
|
174
|
+
hidden,
|
|
175
|
+
scope.actorId
|
|
176
|
+
);
|
|
177
|
+
scope.assertActive();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try {
|
|
180
|
+
scope.assertActive();
|
|
181
|
+
} catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
client.setQueryData<string[]>(ownerKey, (current = []) =>
|
|
185
|
+
wasHidden
|
|
186
|
+
? [...new Set([...current, workspaceId])]
|
|
187
|
+
: current.filter((id) => id !== workspaceId)
|
|
188
|
+
);
|
|
189
|
+
setUpdateError(
|
|
190
|
+
error instanceof Error
|
|
191
|
+
? error
|
|
192
|
+
: new Error('Unable to update Hidden workspaces')
|
|
193
|
+
);
|
|
194
|
+
throw error;
|
|
195
|
+
} finally {
|
|
196
|
+
control.pending.delete(workspaceId);
|
|
197
|
+
try {
|
|
198
|
+
scope.assertActive();
|
|
199
|
+
setPending(new Set(control.pending));
|
|
200
|
+
if (control.pending.size === 0) {
|
|
201
|
+
void client.invalidateQueries({ queryKey: ownerKey });
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* An old account cannot publish into the next account. */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
...query,
|
|
210
|
+
refetch: async () => {
|
|
211
|
+
if (!scope) return null;
|
|
212
|
+
try {
|
|
213
|
+
scope.assertActive();
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const result = await query.refetch();
|
|
218
|
+
try {
|
|
219
|
+
scope.assertActive();
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (!result.error) setUpdateError(null);
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
226
|
+
accountDenied,
|
|
227
|
+
actorId,
|
|
228
|
+
hiddenIds: ids ?? [],
|
|
229
|
+
known: ids !== undefined,
|
|
230
|
+
pending,
|
|
231
|
+
updateError,
|
|
232
|
+
setHidden,
|
|
233
|
+
};
|
|
234
|
+
}
|