@sanity/sdk-react 0.0.0-alpha.9 → 0.0.0-rc.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/README.md +33 -126
- package/dist/index.d.ts +4742 -2
- package/dist/index.js +1054 -2
- package/dist/index.js.map +1 -1
- package/package.json +19 -43
- package/src/_exports/index.ts +66 -10
- package/src/components/Login/LoginLinks.test.tsx +90 -0
- package/src/components/Login/LoginLinks.tsx +58 -0
- package/src/components/SDKProvider.test.tsx +79 -0
- package/src/components/SDKProvider.tsx +42 -0
- package/src/components/SanityApp.test.tsx +104 -2
- package/src/components/SanityApp.tsx +54 -17
- package/src/components/auth/AuthBoundary.test.tsx +2 -2
- package/src/components/auth/AuthBoundary.tsx +13 -3
- package/src/components/auth/Login.test.tsx +1 -1
- package/src/components/auth/Login.tsx +11 -26
- package/src/components/auth/LoginCallback.tsx +4 -7
- package/src/components/auth/LoginError.tsx +12 -8
- package/src/components/auth/LoginFooter.tsx +13 -20
- package/src/components/auth/LoginLayout.tsx +8 -9
- package/src/components/auth/authTestHelpers.tsx +1 -8
- package/src/components/utils.ts +22 -0
- package/src/context/SanityInstanceContext.ts +4 -0
- package/src/context/SanityProvider.test.tsx +1 -1
- package/src/context/SanityProvider.tsx +10 -8
- package/src/hooks/_synchronous-groq-js.mjs +4 -0
- package/src/hooks/auth/useAuthState.tsx +0 -2
- package/src/hooks/auth/useCurrentUser.tsx +26 -20
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
- package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
- package/src/hooks/client/useClient.ts +8 -30
- package/src/hooks/comlink/useFrameConnection.test.tsx +55 -10
- package/src/hooks/comlink/useFrameConnection.ts +39 -43
- package/src/hooks/comlink/useManageFavorite.test.ts +106 -0
- package/src/hooks/comlink/useManageFavorite.ts +101 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +77 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +79 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +53 -12
- package/src/hooks/comlink/useWindowConnection.ts +69 -29
- package/src/hooks/context/useSanityInstance.test.tsx +1 -1
- package/src/hooks/context/useSanityInstance.ts +21 -5
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +97 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +274 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +91 -0
- package/src/hooks/datasets/useDatasets.ts +37 -0
- package/src/hooks/document/useApplyActions.test.ts +5 -4
- package/src/hooks/document/useApplyActions.ts +55 -5
- package/src/hooks/document/useDocument.test.ts +2 -2
- package/src/hooks/document/useDocument.ts +90 -21
- package/src/hooks/document/useDocumentEvent.test.ts +13 -3
- package/src/hooks/document/useDocumentEvent.ts +36 -4
- package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -1
- package/src/hooks/document/useDocumentSyncStatus.ts +26 -2
- package/src/hooks/document/useEditDocument.test.ts +55 -10
- package/src/hooks/document/useEditDocument.ts +159 -31
- package/src/hooks/document/usePermissions.ts +82 -0
- package/src/hooks/helpers/createCallbackHook.tsx +3 -2
- package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
- package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
- package/src/hooks/infiniteList/useInfiniteList.test.tsx +152 -0
- package/src/hooks/infiniteList/useInfiniteList.ts +174 -0
- package/src/hooks/paginatedList/usePaginatedList.test.tsx +259 -0
- package/src/hooks/paginatedList/usePaginatedList.ts +290 -0
- package/src/hooks/preview/usePreview.test.tsx +6 -6
- package/src/hooks/preview/usePreview.tsx +12 -9
- package/src/hooks/projection/useProjection.test.tsx +218 -0
- package/src/hooks/projection/useProjection.ts +147 -0
- package/src/hooks/projects/useProject.ts +45 -0
- package/src/hooks/projects/useProjects.ts +41 -0
- package/src/hooks/query/useQuery.test.tsx +188 -0
- package/src/hooks/query/useQuery.ts +103 -0
- package/src/hooks/users/useUsers.test.ts +163 -0
- package/src/hooks/users/useUsers.ts +107 -0
- package/src/utils/getEnv.ts +21 -0
- package/src/version.ts +8 -0
- package/dist/_chunks-es/context.js +0 -8
- package/dist/_chunks-es/context.js.map +0 -1
- package/dist/_chunks-es/useLogOut.js +0 -45
- package/dist/_chunks-es/useLogOut.js.map +0 -1
- package/dist/components.d.ts +0 -111
- package/dist/components.js +0 -153
- package/dist/components.js.map +0 -1
- package/dist/context.d.ts +0 -45
- package/dist/context.js +0 -5
- package/dist/context.js.map +0 -1
- package/dist/hooks.d.ts +0 -3532
- package/dist/hooks.js +0 -218
- package/dist/hooks.js.map +0 -1
- package/src/_exports/components.ts +0 -2
- package/src/_exports/context.ts +0 -2
- package/src/_exports/hooks.ts +0 -32
- package/src/hooks/client/useClient.test.tsx +0 -130
- package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
- package/src/hooks/documentCollection/useDocuments.ts +0 -135
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {type MessageData, type Node, type Status} from '@sanity/comlink'
|
|
1
2
|
import {type FrameMessage, getOrCreateNode, releaseNode, type WindowMessage} from '@sanity/sdk'
|
|
2
|
-
import {useCallback, useEffect,
|
|
3
|
+
import {useCallback, useEffect, useRef} from 'react'
|
|
3
4
|
|
|
4
5
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
6
|
|
|
@@ -17,6 +18,7 @@ export interface UseWindowConnectionOptions<TMessage extends FrameMessage> {
|
|
|
17
18
|
name: string
|
|
18
19
|
connectTo: string
|
|
19
20
|
onMessage?: Record<TMessage['type'], WindowMessageHandler<TMessage>>
|
|
21
|
+
onStatus?: (status: Status) => void
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -27,56 +29,94 @@ export interface WindowConnection<TMessage extends WindowMessage> {
|
|
|
27
29
|
type: TType,
|
|
28
30
|
data?: Extract<TMessage, {type: TType}>['data'],
|
|
29
31
|
) => void
|
|
32
|
+
fetch: <TResponse>(
|
|
33
|
+
type: string,
|
|
34
|
+
data?: MessageData,
|
|
35
|
+
options?: {
|
|
36
|
+
signal?: AbortSignal
|
|
37
|
+
suppressWarnings?: boolean
|
|
38
|
+
responseTimeout?: number
|
|
39
|
+
},
|
|
40
|
+
) => Promise<TResponse>
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
/**
|
|
33
44
|
* @internal
|
|
45
|
+
* Hook to wrap a Comlink node in a React hook.
|
|
46
|
+
* Our store functionality takes care of the lifecycle of the node,
|
|
47
|
+
* as well as sharing a single node between invocations if they share the same name.
|
|
48
|
+
*
|
|
49
|
+
* Generally not to be used directly, but to be used as a dependency of
|
|
50
|
+
* Comlink-powered hooks like `useManageFavorite`.
|
|
34
51
|
*/
|
|
35
52
|
export function useWindowConnection<
|
|
36
53
|
TWindowMessage extends WindowMessage,
|
|
37
54
|
TFrameMessage extends FrameMessage,
|
|
38
|
-
>(
|
|
39
|
-
|
|
55
|
+
>({
|
|
56
|
+
name,
|
|
57
|
+
connectTo,
|
|
58
|
+
onMessage,
|
|
59
|
+
onStatus,
|
|
60
|
+
}: UseWindowConnectionOptions<TFrameMessage>): WindowConnection<TWindowMessage> {
|
|
61
|
+
const nodeRef = useRef<Node<TWindowMessage, TFrameMessage> | null>(null)
|
|
62
|
+
const messageUnsubscribers = useRef<(() => void)[]>([])
|
|
40
63
|
const instance = useSanityInstance()
|
|
41
64
|
|
|
42
|
-
const node = useMemo(
|
|
43
|
-
() => getOrCreateNode(instance, {name, connectTo}),
|
|
44
|
-
[instance, name, connectTo],
|
|
45
|
-
)
|
|
46
|
-
|
|
47
65
|
useEffect(() => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
66
|
+
// the type cast is unfortunate, but the generic type of the node is not known here.
|
|
67
|
+
// We know that the node is a WindowMessage node, but not the generic types.
|
|
68
|
+
const node = getOrCreateNode(instance, {
|
|
69
|
+
name,
|
|
70
|
+
connectTo,
|
|
71
|
+
}) as unknown as Node<TWindowMessage, TFrameMessage>
|
|
72
|
+
nodeRef.current = node
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
unsubscribers.push(unsubscribe)
|
|
74
|
+
const statusUnsubscribe = node.onStatus((eventStatus) => {
|
|
75
|
+
onStatus?.(eventStatus)
|
|
55
76
|
})
|
|
56
77
|
|
|
78
|
+
if (onMessage) {
|
|
79
|
+
Object.entries(onMessage).forEach(([type, handler]) => {
|
|
80
|
+
const messageUnsubscribe = node.on(type, handler as WindowMessageHandler<TFrameMessage>)
|
|
81
|
+
messageUnsubscribers.current.push(messageUnsubscribe)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
return () => {
|
|
58
|
-
|
|
86
|
+
statusUnsubscribe()
|
|
87
|
+
messageUnsubscribers.current.forEach((unsubscribe) => unsubscribe())
|
|
88
|
+
messageUnsubscribers.current = []
|
|
89
|
+
releaseNode(instance, name)
|
|
90
|
+
nodeRef.current = null
|
|
59
91
|
}
|
|
60
|
-
}, [
|
|
92
|
+
}, [instance, name, connectTo, onMessage, onStatus])
|
|
61
93
|
|
|
62
94
|
const sendMessage = useCallback(
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
(type: TWindowMessage['type'], data?: Extract<TWindowMessage, {type: typeof type}>['data']) => {
|
|
96
|
+
if (!nodeRef.current) {
|
|
97
|
+
throw new Error('Cannot send message before connection is established')
|
|
98
|
+
}
|
|
99
|
+
nodeRef.current.post(type, data)
|
|
68
100
|
},
|
|
69
|
-
[
|
|
101
|
+
[],
|
|
70
102
|
)
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
const fetch = useCallback(
|
|
105
|
+
<TResponse>(
|
|
106
|
+
type: string,
|
|
107
|
+
data?: MessageData,
|
|
108
|
+
fetchOptions?: {
|
|
109
|
+
responseTimeout?: number
|
|
110
|
+
signal?: AbortSignal
|
|
111
|
+
suppressWarnings?: boolean
|
|
112
|
+
},
|
|
113
|
+
): Promise<TResponse> => {
|
|
114
|
+
return nodeRef.current?.fetch(type, data, fetchOptions ?? {}) as Promise<TResponse>
|
|
115
|
+
},
|
|
116
|
+
[],
|
|
117
|
+
)
|
|
79
118
|
return {
|
|
80
119
|
sendMessage,
|
|
120
|
+
fetch,
|
|
81
121
|
}
|
|
82
122
|
}
|
|
@@ -11,7 +11,7 @@ describe('useSanityInstance', () => {
|
|
|
11
11
|
|
|
12
12
|
it('returns sanity instance when used within provider', () => {
|
|
13
13
|
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
14
|
-
<SanityProvider
|
|
14
|
+
<SanityProvider sanityInstances={[sanityInstance]}>{children}</SanityProvider>
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
const {result} = renderHook(() => useSanityInstance(), {wrapper})
|
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
import {type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {useContext} from 'react'
|
|
3
3
|
|
|
4
|
-
import {SanityInstanceContext} from '../../context/
|
|
4
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `useSanityInstance` returns the current Sanity instance from the application context.
|
|
8
8
|
* This must be called from within a `SanityProvider` component.
|
|
9
|
-
* @
|
|
9
|
+
* @internal
|
|
10
|
+
*
|
|
11
|
+
* @param resourceId - The resourceId of the Sanity instance to return (optional)
|
|
10
12
|
* @returns The current Sanity instance
|
|
11
13
|
* @example
|
|
12
14
|
* ```tsx
|
|
13
|
-
* const instance = useSanityInstance()
|
|
15
|
+
* const instance = useSanityInstance('abc123.production')
|
|
14
16
|
* ```
|
|
15
17
|
*/
|
|
16
|
-
export const useSanityInstance = (): SanityInstance => {
|
|
18
|
+
export const useSanityInstance = (resourceId?: string): SanityInstance => {
|
|
17
19
|
const sanityInstance = useContext(SanityInstanceContext)
|
|
18
20
|
if (!sanityInstance) {
|
|
19
21
|
throw new Error('useSanityInstance must be called from within the SanityProvider')
|
|
20
22
|
}
|
|
23
|
+
if (sanityInstance.length === 0) {
|
|
24
|
+
throw new Error('No Sanity instances found')
|
|
25
|
+
}
|
|
26
|
+
if (sanityInstance.length === 1 || !resourceId) {
|
|
27
|
+
return sanityInstance[0]
|
|
28
|
+
}
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
if (!resourceId) {
|
|
31
|
+
throw new Error('resourceId is required when there are multiple Sanity instances')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const instance = sanityInstance.find((inst) => inst.identity.resourceId === resourceId)
|
|
35
|
+
if (!instance) {
|
|
36
|
+
throw new Error(`Sanity instance with resourceId ${resourceId} not found`)
|
|
37
|
+
}
|
|
38
|
+
return instance
|
|
23
39
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {type Status} from '@sanity/comlink'
|
|
2
|
+
import {type DocumentHandle} from '@sanity/sdk'
|
|
3
|
+
import {useCallback, useState} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
6
|
+
import {useStudioWorkspacesByResourceId} from './useStudioWorkspacesByResourceId'
|
|
7
|
+
|
|
8
|
+
interface NavigateToResourceMessage {
|
|
9
|
+
type: 'core/v1/bridge/navigate-to-resource'
|
|
10
|
+
data: {
|
|
11
|
+
/**
|
|
12
|
+
* Resource ID
|
|
13
|
+
*/
|
|
14
|
+
resourceId: string
|
|
15
|
+
/**
|
|
16
|
+
* Resource type
|
|
17
|
+
* @example 'application' | 'studio'
|
|
18
|
+
*/
|
|
19
|
+
resourceType: string
|
|
20
|
+
/**
|
|
21
|
+
* Path within the resource to navigate to.
|
|
22
|
+
*/
|
|
23
|
+
path?: string
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface NavigateToStudioResult {
|
|
28
|
+
navigateToStudioDocument: () => void
|
|
29
|
+
isConnected: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @public
|
|
34
|
+
* Hook that provides a function to navigate to a studio document.
|
|
35
|
+
* @param documentHandle - The document handle containing document ID, type, and resource ID
|
|
36
|
+
* @returns An object containing:
|
|
37
|
+
* - navigateToStudioDocument - Function that when called will navigate to the studio document
|
|
38
|
+
* - isConnected - Boolean indicating if connection to Core UI is established
|
|
39
|
+
*/
|
|
40
|
+
export function useNavigateToStudioDocument(
|
|
41
|
+
documentHandle: DocumentHandle,
|
|
42
|
+
): NavigateToStudioResult {
|
|
43
|
+
const {workspacesByResourceId, isConnected: workspacesConnected} =
|
|
44
|
+
useStudioWorkspacesByResourceId()
|
|
45
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
46
|
+
const {sendMessage} = useWindowConnection<NavigateToResourceMessage, never>({
|
|
47
|
+
name: 'core/nodes/sdk',
|
|
48
|
+
connectTo: 'core/channels/sdk',
|
|
49
|
+
onStatus: setStatus,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const navigateToStudioDocument = useCallback(() => {
|
|
53
|
+
if (!workspacesConnected || status !== 'connected' || !documentHandle.resourceId) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Extract projectId and dataset from the resourceId (current format: document:projectId.dataset:documentId)
|
|
58
|
+
const [, projectAndDataset] = documentHandle.resourceId.split(':')
|
|
59
|
+
const [projectId, dataset] = projectAndDataset.split('.')
|
|
60
|
+
if (!projectId || !dataset) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find the workspace for this document
|
|
65
|
+
const workspaces = workspacesByResourceId[`${projectId}:${dataset}`]
|
|
66
|
+
if (!workspaces?.length) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.warn('No workspace found for document', documentHandle.resourceId)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (workspaces.length > 1) {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.warn('Multiple workspaces found for document', documentHandle.resourceId)
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.warn('Using the first one', workspaces[0])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const workspace = workspaces[0]
|
|
80
|
+
|
|
81
|
+
const message: NavigateToResourceMessage = {
|
|
82
|
+
type: 'core/v1/bridge/navigate-to-resource',
|
|
83
|
+
data: {
|
|
84
|
+
resourceId: workspace._ref,
|
|
85
|
+
resourceType: 'studio',
|
|
86
|
+
path: `/intent/edit/id=${documentHandle._id};type=${documentHandle._type}`,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
sendMessage(message.type, message.data)
|
|
91
|
+
}, [documentHandle, workspacesConnected, status, sendMessage, workspacesByResourceId])
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
navigateToStudioDocument,
|
|
95
|
+
isConnected: workspacesConnected && status === 'connected',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -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,0 +1,91 @@
|
|
|
1
|
+
import {type Status} from '@sanity/comlink'
|
|
2
|
+
import {useEffect, useState} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
5
|
+
|
|
6
|
+
interface Workspace {
|
|
7
|
+
name: string
|
|
8
|
+
title: string
|
|
9
|
+
basePath: string
|
|
10
|
+
dataset: string
|
|
11
|
+
userApplicationId: string
|
|
12
|
+
url: string
|
|
13
|
+
_ref: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WorkspacesByResourceId {
|
|
17
|
+
[key: string]: Workspace[] // key format: `${projectId}:${dataset}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StudioWorkspacesResult {
|
|
21
|
+
workspacesByResourceId: WorkspacesByResourceId
|
|
22
|
+
error: string | null
|
|
23
|
+
isConnected: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook that fetches studio workspaces and organizes them by projectId:dataset
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export function useStudioWorkspacesByResourceId(): StudioWorkspacesResult {
|
|
31
|
+
const [workspacesByResourceId, setWorkspacesByResourceId] = useState<WorkspacesByResourceId>({})
|
|
32
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
|
|
35
|
+
const {fetch} = useWindowConnection({
|
|
36
|
+
name: 'core/nodes/sdk',
|
|
37
|
+
connectTo: 'core/channels/sdk',
|
|
38
|
+
onStatus: setStatus,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Once computed, this should probably be in a store and poll for changes
|
|
42
|
+
// However, our stores are currently being refactored
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!fetch || status !== 'connected') return
|
|
45
|
+
|
|
46
|
+
async function fetchWorkspaces(signal: AbortSignal) {
|
|
47
|
+
try {
|
|
48
|
+
const data = await fetch<{
|
|
49
|
+
context: {availableResources: Array<{projectId: string; workspaces: Workspace[]}>}
|
|
50
|
+
}>('core/v1/bridge/context', undefined, {signal})
|
|
51
|
+
|
|
52
|
+
const workspaceMap: WorkspacesByResourceId = {}
|
|
53
|
+
|
|
54
|
+
data.context.availableResources.forEach((resource) => {
|
|
55
|
+
if (!resource.projectId || !resource.workspaces?.length) return
|
|
56
|
+
|
|
57
|
+
resource.workspaces.forEach((workspace) => {
|
|
58
|
+
const key = `${resource.projectId}:${workspace.dataset}`
|
|
59
|
+
if (!workspaceMap[key]) {
|
|
60
|
+
workspaceMap[key] = []
|
|
61
|
+
}
|
|
62
|
+
workspaceMap[key].push(workspace)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
setWorkspacesByResourceId(workspaceMap)
|
|
67
|
+
setError(null)
|
|
68
|
+
} catch (err: unknown) {
|
|
69
|
+
if (err instanceof Error) {
|
|
70
|
+
if (err.name === 'AbortError') {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
setError('Failed to fetch workspaces')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
fetchWorkspaces(controller.signal)
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
controller.abort()
|
|
83
|
+
}
|
|
84
|
+
}, [fetch, status])
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
workspacesByResourceId,
|
|
88
|
+
error,
|
|
89
|
+
isConnected: status === 'connected',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
+
import {getDatasetsState, resolveDatasets, type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
3
|
+
|
|
4
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
5
|
+
|
|
6
|
+
type UseDatasets = {
|
|
7
|
+
/**
|
|
8
|
+
*
|
|
9
|
+
* Returns metadata for each dataset in your organization.
|
|
10
|
+
*
|
|
11
|
+
* @category Datasets
|
|
12
|
+
* @returns The metadata for your organization's datasets
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const datasets = useDatasets()
|
|
17
|
+
*
|
|
18
|
+
* return (
|
|
19
|
+
* <select>
|
|
20
|
+
* {datasets.map((dataset) => (
|
|
21
|
+
* <option key={dataset.name}>{dataset.name}</option>
|
|
22
|
+
* ))}
|
|
23
|
+
* </select>
|
|
24
|
+
* )
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
*/
|
|
28
|
+
(): DatasetsResponse
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @public */
|
|
32
|
+
export const useDatasets: UseDatasets = createStateSourceHook({
|
|
33
|
+
// remove `undefined` since we're suspending when that is the case
|
|
34
|
+
getState: getDatasetsState as (instance: SanityInstance) => StateSource<DatasetsResponse>,
|
|
35
|
+
shouldSuspend: (instance) => getDatasetsState(instance).getCurrent() === undefined,
|
|
36
|
+
suspender: resolveDatasets,
|
|
37
|
+
})
|