Skip to content

Commit 83feb21

Browse files
authored
feat(react): create useStudioWorkspaceById hook (#343)
1 parent a2a41d0 commit 83feb21

File tree

5 files changed

+429
-2
lines changed

5 files changed

+429
-2
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {ProjectAuthHome} from './ProjectAuthentication/ProjectAuthHome'
1717
import {ProjectInstanceWrapper} from './ProjectAuthentication/ProjectInstanceWrapper'
1818
import {ProtectedRoute} from './ProtectedRoute'
1919
import {DashboardContextRoute} from './routes/DashboardContextRoute'
20+
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
2021
import {UsersRoute} from './routes/UsersRoute'
2122
import {UnauthenticatedHome} from './Unauthenticated/UnauthenticatedHome'
2223
import {UnauthenticatedInstanceWrapper} from './Unauthenticated/UnauthenticatedInstanceWrapper'
@@ -60,6 +61,13 @@ const documentCollectionRoutes = [
6061
},
6162
]
6263

64+
const dashboardInteractionRoutes = [
65+
{
66+
path: 'workspaces',
67+
element: <DashboardWorkspacesRoute />,
68+
},
69+
]
70+
6371
const frameRoutes = [1, 2, 3].map((frameNum) => ({
6472
path: `frame${frameNum}`,
6573
element: <Framed />,
@@ -71,7 +79,14 @@ export function AppRoutes(): JSX.Element {
7179
<Route path="/" element={<Home />} />
7280

7381
<Route path="/authenticated" element={<ProjectInstanceWrapper />}>
74-
<Route index element={<ProjectAuthHome routes={documentCollectionRoutes} />} />
82+
<Route
83+
index
84+
element={
85+
<ProjectAuthHome
86+
routes={[...documentCollectionRoutes, ...dashboardInteractionRoutes]}
87+
/>
88+
}
89+
/>
7590
<Route element={<ProtectedRoute subPath="/authenticated" />}>
7691
{documentCollectionRoutes.map((route) => (
7792
<Route key={route.path} path={route.path} element={route.element} />
@@ -87,10 +102,20 @@ export function AppRoutes(): JSX.Element {
87102
</Route>
88103

89104
<Route path="/unauthenticated" element={<UnauthenticatedInstanceWrapper />}>
90-
<Route index element={<UnauthenticatedHome routes={documentCollectionRoutes} />} />
105+
<Route
106+
index
107+
element={
108+
<UnauthenticatedHome
109+
routes={[...documentCollectionRoutes, ...dashboardInteractionRoutes]}
110+
/>
111+
}
112+
/>
91113
{documentCollectionRoutes.map((route) => (
92114
<Route key={route.path} path={route.path} element={route.element} />
93115
))}
116+
{dashboardInteractionRoutes.map((route) => (
117+
<Route key={route.path} path={route.path} element={route.element} />
118+
))}
94119
</Route>
95120

96121
<Route
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {useStudioWorkspacesByResourceId} from '@sanity/sdk-react'
2+
import {Card, Code, Container, Flex, Heading, Stack, Text} from '@sanity/ui'
3+
import {type ReactElement} from 'react'
4+
5+
export function DashboardWorkspacesRoute(): ReactElement {
6+
const {workspacesByResourceId, error, isConnected} = useStudioWorkspacesByResourceId()
7+
8+
return (
9+
<Container width={2}>
10+
<Stack space={4} paddingY={4}>
11+
<Heading>Studio Workspaces By Resource ID</Heading>
12+
13+
<Card padding={4} radius={2} shadow={1}>
14+
<Stack space={4}>
15+
<Flex direction="column" gap={2}>
16+
<Text weight="semibold">Connection Status:</Text>
17+
<Text>{isConnected ? 'Connected' : 'Not Connected'}</Text>
18+
</Flex>
19+
20+
{error && (
21+
<Flex direction="column" gap={2}>
22+
<Text weight="semibold">Error:</Text>
23+
<Text>{error}</Text>
24+
</Flex>
25+
)}
26+
27+
<Flex direction="column" gap={2}>
28+
<Text weight="semibold">Workspaces by Resource ID:</Text>
29+
<Code language="json">{JSON.stringify(workspacesByResourceId, null, 2)}</Code>
30+
</Flex>
31+
</Stack>
32+
</Card>
33+
</Stack>
34+
</Container>
35+
)
36+
}

packages/react/src/_exports/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export {
2626
type WindowMessageHandler,
2727
} from '../hooks/comlink/useWindowConnection'
2828
export {useSanityInstance} from '../hooks/context/useSanityInstance'
29+
export {useStudioWorkspacesByResourceId} from '../hooks/dashboard/useStudioWorkspacesByResourceId'
2930
export {useDatasets} from '../hooks/datasets/useDatasets'
3031
export {useApplyActions} from '../hooks/document/useApplyActions'
3132
export {useDocument} from '../hooks/document/useDocument'
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import {type Message, type Status} from '@sanity/comlink'
2+
import {renderHook, waitFor} from '@testing-library/react'
3+
import {describe, expect, it, vi} from 'vitest'
4+
5+
import {useWindowConnection, type WindowConnection} from '../comlink/useWindowConnection'
6+
import {useStudioWorkspacesByResourceId} from './useStudioWorkspacesByResourceId'
7+
8+
vi.mock('../comlink/useWindowConnection', () => ({
9+
useWindowConnection: vi.fn(),
10+
}))
11+
12+
const mockWorkspaceData = {
13+
context: {
14+
availableResources: [
15+
{
16+
projectId: 'project1',
17+
workspaces: [
18+
{
19+
name: 'workspace1',
20+
title: 'Workspace 1',
21+
basePath: '/workspace1',
22+
dataset: 'dataset1',
23+
userApplicationId: 'user1',
24+
url: 'https://test.sanity.studio',
25+
_ref: 'user1-workspace1',
26+
},
27+
{
28+
name: 'workspace2',
29+
title: 'Workspace 2',
30+
basePath: '/workspace2',
31+
dataset: 'dataset1',
32+
userApplicationId: 'user1',
33+
url: 'https://test.sanity.studio',
34+
_ref: 'user1-workspace2',
35+
},
36+
],
37+
},
38+
{
39+
projectId: 'project2',
40+
workspaces: [
41+
{
42+
name: 'workspace3',
43+
title: 'Workspace 3',
44+
basePath: '/workspace3',
45+
dataset: 'dataset2',
46+
userApplicationId: 'user2',
47+
url: 'https://test.sanity.studio',
48+
_ref: 'user2-workspace3',
49+
},
50+
],
51+
},
52+
{
53+
// Project without workspaces
54+
projectId: 'project3',
55+
workspaces: [],
56+
},
57+
],
58+
},
59+
}
60+
61+
describe('useStudioWorkspacesByResourceId', () => {
62+
it('should return empty workspaces and connected=false when not connected', async () => {
63+
// Create a mock that captures the onStatus callback
64+
let capturedOnStatus: ((status: Status) => void) | undefined
65+
66+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
67+
capturedOnStatus = onStatus
68+
69+
return {
70+
fetch: undefined,
71+
sendMessage: vi.fn(),
72+
} as unknown as WindowConnection<Message>
73+
})
74+
75+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
76+
77+
// Call onStatus with 'idle' to simulate not connected
78+
if (capturedOnStatus) capturedOnStatus('idle')
79+
80+
expect(result.current).toEqual({
81+
workspacesByResourceId: {},
82+
error: null,
83+
isConnected: false,
84+
})
85+
})
86+
87+
it('should process workspaces into lookup by projectId:dataset', async () => {
88+
const mockFetch = vi.fn().mockResolvedValue(mockWorkspaceData)
89+
let capturedOnStatus: ((status: Status) => void) | undefined
90+
91+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
92+
capturedOnStatus = onStatus
93+
94+
return {
95+
fetch: mockFetch,
96+
sendMessage: vi.fn(),
97+
} as unknown as WindowConnection<Message>
98+
})
99+
100+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
101+
102+
// Call onStatus with 'connected' to simulate connected state
103+
if (capturedOnStatus) capturedOnStatus('connected')
104+
105+
await waitFor(() => {
106+
expect(result.current.workspacesByResourceId).toEqual({
107+
'project1:dataset1': [
108+
{
109+
name: 'workspace1',
110+
title: 'Workspace 1',
111+
basePath: '/workspace1',
112+
dataset: 'dataset1',
113+
userApplicationId: 'user1',
114+
url: 'https://test.sanity.studio',
115+
_ref: 'user1-workspace1',
116+
},
117+
{
118+
name: 'workspace2',
119+
title: 'Workspace 2',
120+
basePath: '/workspace2',
121+
dataset: 'dataset1',
122+
userApplicationId: 'user1',
123+
url: 'https://test.sanity.studio',
124+
_ref: 'user1-workspace2',
125+
},
126+
],
127+
'project2:dataset2': [
128+
{
129+
name: 'workspace3',
130+
title: 'Workspace 3',
131+
basePath: '/workspace3',
132+
dataset: 'dataset2',
133+
userApplicationId: 'user2',
134+
url: 'https://test.sanity.studio',
135+
_ref: 'user2-workspace3',
136+
},
137+
],
138+
})
139+
expect(result.current.error).toBeNull()
140+
expect(result.current.isConnected).toBe(true)
141+
})
142+
143+
expect(mockFetch).toHaveBeenCalledWith('core/v1/bridge/context', undefined, expect.any(Object))
144+
})
145+
146+
it('should handle fetch errors', async () => {
147+
const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
148+
let capturedOnStatus: ((status: Status) => void) | undefined
149+
150+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
151+
capturedOnStatus = onStatus
152+
153+
return {
154+
fetch: mockFetch,
155+
sendMessage: vi.fn(),
156+
} as unknown as WindowConnection<Message>
157+
})
158+
159+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
160+
161+
// Call onStatus with 'connected' to simulate connected state
162+
if (capturedOnStatus) capturedOnStatus('connected')
163+
164+
await waitFor(() => {
165+
expect(result.current.workspacesByResourceId).toEqual({})
166+
expect(result.current.error).toBe('Failed to fetch workspaces')
167+
expect(result.current.isConnected).toBe(true)
168+
})
169+
})
170+
171+
it('should handle AbortError silently', async () => {
172+
const abortError = new Error('Aborted')
173+
abortError.name = 'AbortError'
174+
const mockFetch = vi.fn().mockRejectedValue(abortError)
175+
let capturedOnStatus: ((status: Status) => void) | undefined
176+
177+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
178+
capturedOnStatus = onStatus
179+
180+
return {
181+
fetch: mockFetch,
182+
sendMessage: vi.fn(),
183+
} as unknown as WindowConnection<Message>
184+
})
185+
186+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
187+
188+
// Call onStatus with 'connected' to simulate connected state
189+
if (capturedOnStatus) capturedOnStatus('connected')
190+
191+
await waitFor(() => {
192+
expect(result.current.workspacesByResourceId).toEqual({})
193+
expect(result.current.error).toBeNull()
194+
expect(result.current.isConnected).toBe(true)
195+
})
196+
})
197+
198+
it('should handle projects without workspaces', async () => {
199+
const mockFetch = vi.fn().mockResolvedValue({
200+
context: {
201+
availableResources: [
202+
{
203+
projectId: 'project1',
204+
workspaces: [],
205+
},
206+
],
207+
},
208+
})
209+
let capturedOnStatus: ((status: Status) => void) | undefined
210+
211+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
212+
capturedOnStatus = onStatus
213+
214+
return {
215+
fetch: mockFetch,
216+
sendMessage: vi.fn(),
217+
} as unknown as WindowConnection<Message>
218+
})
219+
220+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
221+
222+
// Call onStatus with 'connected' to simulate connected state
223+
if (capturedOnStatus) capturedOnStatus('connected')
224+
225+
await waitFor(() => {
226+
expect(result.current.workspacesByResourceId).toEqual({})
227+
expect(result.current.error).toBeNull()
228+
expect(result.current.isConnected).toBe(true)
229+
})
230+
})
231+
232+
it('should handle projects without projectId', async () => {
233+
const mockFetch = vi.fn().mockResolvedValue({
234+
context: {
235+
availableResources: [
236+
{
237+
workspaces: [
238+
{
239+
name: 'workspace1',
240+
title: 'Workspace 1',
241+
basePath: '/workspace1',
242+
dataset: 'dataset1',
243+
userApplicationId: 'user1',
244+
url: 'https://test.sanity.studio',
245+
_ref: 'user1-workspace1',
246+
},
247+
],
248+
},
249+
],
250+
},
251+
})
252+
let capturedOnStatus: ((status: Status) => void) | undefined
253+
254+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
255+
capturedOnStatus = onStatus
256+
257+
return {
258+
fetch: mockFetch,
259+
sendMessage: vi.fn(),
260+
} as unknown as WindowConnection<Message>
261+
})
262+
263+
const {result} = renderHook(() => useStudioWorkspacesByResourceId())
264+
265+
// Call onStatus with 'connected' to simulate connected state
266+
if (capturedOnStatus) capturedOnStatus('connected')
267+
268+
await waitFor(() => {
269+
expect(result.current.workspacesByResourceId).toEqual({})
270+
expect(result.current.error).toBeNull()
271+
expect(result.current.isConnected).toBe(true)
272+
})
273+
})
274+
})

0 commit comments

Comments
 (0)