@sanity/sdk-react 0.0.0-alpha.2 → 0.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +38 -67
  2. package/dist/index.d.ts +4811 -2
  3. package/dist/index.js +1069 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +27 -58
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +4 -14
  8. package/src/components/Login/LoginLinks.tsx +16 -31
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +156 -0
  12. package/src/components/SanityApp.tsx +90 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +6 -19
  14. package/src/components/auth/AuthBoundary.tsx +20 -4
  15. package/src/components/auth/Login.test.tsx +2 -16
  16. package/src/components/auth/Login.tsx +11 -30
  17. package/src/components/auth/LoginCallback.test.tsx +5 -20
  18. package/src/components/auth/LoginCallback.tsx +9 -14
  19. package/src/components/auth/LoginError.test.tsx +2 -17
  20. package/src/components/auth/LoginError.tsx +11 -16
  21. package/src/components/auth/LoginFooter.test.tsx +2 -16
  22. package/src/components/auth/LoginFooter.tsx +8 -24
  23. package/src/components/auth/LoginLayout.test.tsx +2 -16
  24. package/src/components/auth/LoginLayout.tsx +8 -38
  25. package/src/components/auth/authTestHelpers.tsx +11 -0
  26. package/src/components/utils.ts +22 -0
  27. package/src/context/SanityInstanceContext.ts +4 -0
  28. package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
  29. package/src/context/SanityProvider.tsx +50 -0
  30. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  31. package/src/hooks/auth/useAuthState.tsx +4 -5
  32. package/src/hooks/auth/useAuthToken.tsx +1 -1
  33. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  34. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  35. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  36. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  37. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  38. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  39. package/src/hooks/auth/useLogOut.tsx +1 -1
  40. package/src/hooks/auth/useLoginUrls.tsx +1 -0
  41. package/src/hooks/client/useClient.ts +9 -30
  42. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  43. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  44. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  45. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  46. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  47. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  48. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  49. package/src/hooks/comlink/useWindowConnection.ts +122 -0
  50. package/src/hooks/context/useSanityInstance.test.tsx +2 -2
  51. package/src/hooks/context/useSanityInstance.ts +24 -8
  52. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  53. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  54. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  55. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  56. package/src/hooks/datasets/useDatasets.ts +40 -0
  57. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  58. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  59. package/src/hooks/document/useDocument.test.ts +81 -0
  60. package/src/hooks/document/useDocument.ts +107 -0
  61. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  62. package/src/hooks/document/useDocumentEvent.ts +54 -0
  63. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  64. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  65. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  66. package/src/hooks/document/useEditDocument.test.ts +179 -0
  67. package/src/hooks/document/useEditDocument.ts +195 -0
  68. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  69. package/src/hooks/documents/useDocuments.ts +174 -0
  70. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  71. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  72. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  73. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  74. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  75. package/src/hooks/preview/usePreview.test.tsx +19 -10
  76. package/src/hooks/preview/usePreview.tsx +67 -13
  77. package/src/hooks/projection/useProjection.test.tsx +218 -0
  78. package/src/hooks/projection/useProjection.ts +147 -0
  79. package/src/hooks/projects/useProject.ts +48 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +103 -0
  83. package/src/hooks/users/useUsers.test.ts +163 -0
  84. package/src/hooks/users/useUsers.ts +107 -0
  85. package/src/utils/getEnv.ts +21 -0
  86. package/src/version.ts +8 -0
  87. package/src/vite-env.d.ts +10 -0
  88. package/dist/_chunks-es/useLogOut.js +0 -44
  89. package/dist/_chunks-es/useLogOut.js.map +0 -1
  90. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  91. package/dist/components.d.ts +0 -257
  92. package/dist/components.js +0 -316
  93. package/dist/components.js.map +0 -1
  94. package/dist/hooks.d.ts +0 -187
  95. package/dist/hooks.js +0 -81
  96. package/dist/hooks.js.map +0 -1
  97. package/src/_exports/components.ts +0 -13
  98. package/src/_exports/hooks.ts +0 -9
  99. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  100. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  101. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  102. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  103. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  104. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  105. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  106. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  107. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  108. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  109. package/src/components/context/SanityProvider.tsx +0 -42
  110. package/src/css/css.config.js +0 -220
  111. package/src/css/paramour.css +0 -2347
  112. package/src/css/styles.css +0 -11
  113. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  114. package/src/hooks/client/useClient.test.tsx +0 -130
  115. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  116. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -1,10 +1,34 @@
1
- import {getCurrentUserState} from '@sanity/sdk'
1
+ import {type CurrentUser, getCurrentUserState} from '@sanity/sdk'
2
2
 
3
3
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
4
 
5
+ type UseCurrentUser = {
6
+ /**
7
+ * @public
8
+ *
9
+ * Provides the currently authenticated user’s profile information.
10
+ *
11
+ * @category Users
12
+ * @returns The current user data
13
+ *
14
+ * @example Rendering a basic user profile
15
+ * ```
16
+ * const user = useCurrentUser()
17
+ *
18
+ * return (
19
+ * <figure>
20
+ * <img src={user?.profileImage} alt=`Profile image for ${user?.name}` />
21
+ * <h2>{user?.name}</h2>
22
+ * </figure>
23
+ * )
24
+ * ```
25
+ */
26
+ (): CurrentUser | null
27
+ }
28
+
5
29
  /**
6
- * Hook to get the currently logged in user
7
30
  * @public
8
- * @returns The current user or null if not authenticated
31
+ * @function
32
+ * @TODO This should not return null — users of a custom app will always be authenticated via Core
9
33
  */
10
- export const useCurrentUser = createStateSourceHook(getCurrentUserState)
34
+ export const useCurrentUser: UseCurrentUser = createStateSourceHook(getCurrentUserState)
@@ -0,0 +1,42 @@
1
+ import {createSanityInstance, getDashboardOrganizationId} from '@sanity/sdk'
2
+ import {renderHook} from '@testing-library/react'
3
+ import {throwError} from 'rxjs'
4
+ import {describe, expect, it, vi} from 'vitest'
5
+
6
+ import {useDashboardOrganizationId} from './useDashboardOrganizationId'
7
+
8
+ vi.mock('../context/useSanityInstance', () => ({
9
+ useSanityInstance: vi.fn().mockReturnValue(createSanityInstance({projectId: 'p', dataset: 'd'})),
10
+ }))
11
+
12
+ vi.mock('@sanity/sdk', async (importOriginal) => {
13
+ const actual = await importOriginal()
14
+ return {...(actual || {}), getDashboardOrganizationId: vi.fn()}
15
+ })
16
+
17
+ describe('useDashboardOrganizationId', () => {
18
+ it('should return undefined when no organization ID is set', () => {
19
+ const subscribe = vi.fn()
20
+ vi.mocked(getDashboardOrganizationId).mockReturnValue({
21
+ getCurrent: () => undefined,
22
+ subscribe,
23
+ observable: throwError(() => new Error('Unexpected usage of observable')),
24
+ })
25
+
26
+ const {result} = renderHook(() => useDashboardOrganizationId())
27
+ expect(result.current).toBeUndefined()
28
+ })
29
+
30
+ it('should return organization ID when one is set', () => {
31
+ const subscribe = vi.fn()
32
+ const mockOrgId = 'team_123'
33
+ vi.mocked(getDashboardOrganizationId).mockReturnValue({
34
+ getCurrent: () => mockOrgId,
35
+ subscribe,
36
+ observable: throwError(() => new Error('Unexpected usage of observable')),
37
+ })
38
+
39
+ const {result} = renderHook(() => useDashboardOrganizationId())
40
+ expect(result.current).toBe(mockOrgId)
41
+ })
42
+ })
@@ -0,0 +1,29 @@
1
+ import {getDashboardOrganizationId} from '@sanity/sdk'
2
+ import {useMemo, useSyncExternalStore} from 'react'
3
+
4
+ import {useSanityInstance} from '../context/useSanityInstance'
5
+
6
+ /**
7
+ * @remarks
8
+ * A React hook that retrieves the dashboard organization ID that is currently selected in the Sanity Dashboard.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * function DashboardComponent() {
13
+ * const orgId = useDashboardOrganizationId()
14
+ *
15
+ * if (!orgId) return null
16
+ *
17
+ * return <div>Organization ID: {String(orgId)}</div>
18
+ * }
19
+ * ```
20
+ *
21
+ * @returns The dashboard organization ID (string | undefined)
22
+ * @public
23
+ */
24
+ export function useDashboardOrganizationId(): string | undefined {
25
+ const instance = useSanityInstance()
26
+ const {subscribe, getCurrent} = useMemo(() => getDashboardOrganizationId(instance), [instance])
27
+
28
+ return useSyncExternalStore(subscribe, getCurrent)
29
+ }
@@ -0,0 +1,16 @@
1
+ import {handleAuthCallback} from '@sanity/sdk'
2
+ import {identity} from 'rxjs'
3
+ import {describe, it} from 'vitest'
4
+
5
+ import {createCallbackHook} from '../helpers/createCallbackHook'
6
+
7
+ vi.mock('../helpers/createCallbackHook', () => ({createCallbackHook: vi.fn(identity)}))
8
+ vi.mock('@sanity/sdk', () => ({handleAuthCallback: vi.fn()}))
9
+
10
+ describe('useHandleAuthCallback', () => {
11
+ it('calls `createCallbackHook` with `handleAuthCallback`', async () => {
12
+ const {useHandleAuthCallback} = await import('./useHandleAuthCallback')
13
+ expect(createCallbackHook).toHaveBeenCalledWith(handleAuthCallback)
14
+ expect(useHandleAuthCallback).toBe(handleAuthCallback)
15
+ })
16
+ })
@@ -1,15 +1,16 @@
1
- import {handleCallback} from '@sanity/sdk'
1
+ import {handleAuthCallback} from '@sanity/sdk'
2
2
 
3
3
  import {createCallbackHook} from '../helpers/createCallbackHook'
4
4
 
5
5
  /**
6
+ * @internal
6
7
  * A React hook that returns a function for handling authentication callbacks.
7
8
  *
8
9
  * @remarks
9
10
  * This hook provides access to the authentication store's callback handler,
10
11
  * which processes auth redirects by extracting the session ID and fetching the
11
12
  * authentication token. If fetching the long-lived token is successful,
12
- * `handleCallback` will return a Promise that resolves a new location that
13
+ * `handleAuthCallback` will return a Promise that resolves a new location that
13
14
  * removes the short-lived token from the URL. Use this in combination with
14
15
  * `history.replaceState` or your own router's `replace` function to update the
15
16
  * current location without triggering a reload.
@@ -17,13 +18,13 @@ import {createCallbackHook} from '../helpers/createCallbackHook'
17
18
  * @example
18
19
  * ```tsx
19
20
  * function AuthCallback() {
20
- * const handleCallback = useHandleCallback()
21
+ * const handleAuthCallback = useHandleAuthCallback()
21
22
  * const router = useRouter() // Example router
22
23
  *
23
24
  * useEffect(() => {
24
25
  * async function processCallback() {
25
26
  * // Handle the callback and get the cleaned URL
26
- * const newUrl = await handleCallback(window.location.href)
27
+ * const newUrl = await handleAuthCallback(window.location.href)
27
28
  *
28
29
  * if (newUrl) {
29
30
  * // Replace URL without triggering navigation
@@ -32,7 +33,7 @@ import {createCallbackHook} from '../helpers/createCallbackHook'
32
33
  * }
33
34
  *
34
35
  * processCallback().catch(console.error)
35
- * }, [handleCallback, router])
36
+ * }, [handleAuthCallback, router])
36
37
  *
37
38
  * return <div>Completing login...</div>
38
39
  * }
@@ -41,4 +42,4 @@ import {createCallbackHook} from '../helpers/createCallbackHook'
41
42
  * @returns A callback handler function that processes OAuth redirects
42
43
  * @public
43
44
  */
44
- export const useHandleCallback = createCallbackHook(handleCallback)
45
+ export const useHandleAuthCallback = createCallbackHook(handleAuthCallback)
@@ -7,8 +7,8 @@ import {createCallbackHook} from '../helpers/createCallbackHook'
7
7
  vi.mock('../helpers/createCallbackHook', () => ({createCallbackHook: vi.fn(identity)}))
8
8
  vi.mock('@sanity/sdk', () => ({logout: vi.fn()}))
9
9
 
10
- describe('useHandleCallback', () => {
11
- it('calls `createCallbackHook` with `handleCallback`', async () => {
10
+ describe('useLogOut', () => {
11
+ it('calls `createCallbackHook` with `logout`', async () => {
12
12
  const {useLogOut} = await import('./useLogOut')
13
13
  expect(createCallbackHook).toHaveBeenCalledWith(logout)
14
14
  expect(useLogOut).toBe(logout)
@@ -4,7 +4,7 @@ import {createCallbackHook} from '../helpers/createCallbackHook'
4
4
 
5
5
  /**
6
6
  * Hook to log out of the current session
7
- * @public
7
+ * @internal
8
8
  * @returns A function to log out of the current session
9
9
  */
10
10
  export const useLogOut = createCallbackHook(logout)
@@ -4,6 +4,7 @@ import {useMemo, useSyncExternalStore} from 'react'
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
5
 
6
6
  /**
7
+ * @internal
7
8
  * A React hook that retrieves the available authentication provider URLs for login.
8
9
  *
9
10
  * @remarks
@@ -1,8 +1,6 @@
1
- import {type SanityClient} from '@sanity/client'
2
- import {type ClientOptions, getClient, getSubscribableClient} from '@sanity/sdk'
3
- import {useCallback, useSyncExternalStore} from 'react'
1
+ import {getClientState} from '@sanity/sdk'
4
2
 
5
- import {useSanityInstance} from '../context/useSanityInstance'
3
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
6
4
 
7
5
  /**
8
6
  * A React hook that provides a client that subscribes to changes in your application,
@@ -12,12 +10,13 @@ import {useSanityInstance} from '../context/useSanityInstance'
12
10
  * The hook uses `useSyncExternalStore` to safely subscribe to changes
13
11
  * and ensure consistency between server and client rendering.
14
12
  *
13
+ * @category Platform
15
14
  * @returns A Sanity client
16
15
  *
17
16
  * @example
18
17
  * ```tsx
19
18
  * function MyComponent() {
20
- * const client = useClient()
19
+ * const client = useClient({apiVersion: '2024-11-12'})
21
20
  * const [document, setDocument] = useState(null)
22
21
  * useEffect(async () => {
23
22
  * const doc = client.fetch('*[_id == "myDocumentId"]')
@@ -28,29 +27,9 @@ import {useSanityInstance} from '../context/useSanityInstance'
28
27
  * ```
29
28
  *
30
29
  * @public
30
+ * @function
31
31
  */
32
- export function useClient(options: ClientOptions): SanityClient {
33
- const instance = useSanityInstance()
34
-
35
- const subscribe = useCallback(
36
- (onStoreChange: () => void) => {
37
- const client$ = getSubscribableClient(instance, options)
38
- const subscription = client$.subscribe({
39
- next: onStoreChange,
40
- error: (error) => {
41
- // @TODO: We should tackle error handling / error boundaries soon
42
- // eslint-disable-next-line no-console
43
- console.error('Error in useClient subscription:', error)
44
- },
45
- })
46
- return () => subscription.unsubscribe()
47
- },
48
- [instance, options],
49
- )
50
-
51
- const getSnapshot = useCallback(() => {
52
- return getClient(instance, options)
53
- }, [instance, options])
54
-
55
- return useSyncExternalStore(subscribe, getSnapshot)
56
- }
32
+ export const useClient = createStateSourceHook({
33
+ getState: getClientState,
34
+ getResourceId: (e) => e.resourceId,
35
+ })
@@ -0,0 +1,167 @@
1
+ import {type ChannelInstance, type Controller, type Status} from '@sanity/comlink'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {act, renderHook} from '../../../test/test-utils'
5
+ import {useFrameConnection} from './useFrameConnection'
6
+
7
+ vi.mock(import('@sanity/sdk'), async (importOriginal) => {
8
+ const actual = await importOriginal()
9
+ return {
10
+ ...actual,
11
+ getOrCreateChannel: vi.fn(),
12
+ getOrCreateController: vi.fn(),
13
+ releaseChannel: vi.fn(),
14
+ }
15
+ })
16
+
17
+ const {getOrCreateChannel, getOrCreateController, releaseChannel} = await import('@sanity/sdk')
18
+
19
+ interface TestControllerMessage {
20
+ type: 'TEST_MESSAGE'
21
+ data: {
22
+ someData: string
23
+ }
24
+ }
25
+
26
+ interface TestNodeMessage {
27
+ type: 'NODE_MESSAGE'
28
+ data: {
29
+ someData: string
30
+ }
31
+ }
32
+
33
+ describe('useFrameController', () => {
34
+ let channel: ChannelInstance<TestControllerMessage, TestNodeMessage>
35
+ let controller: Controller
36
+ let removeTargetMock: ReturnType<typeof vi.fn>
37
+ let statusCallback:
38
+ | (({status, connection}: {status: Status; connection: string}) => void)
39
+ | null = null
40
+
41
+ function createMockChannel() {
42
+ return {
43
+ on: vi.fn(() => () => {}),
44
+ post: vi.fn(),
45
+ stop: vi.fn(),
46
+ onStatus: vi.fn((callback) => {
47
+ statusCallback = callback
48
+ return () => {}
49
+ }),
50
+ } as unknown as ChannelInstance<TestControllerMessage, TestNodeMessage>
51
+ }
52
+
53
+ beforeEach(() => {
54
+ channel = createMockChannel()
55
+ removeTargetMock = vi.fn()
56
+ controller = {
57
+ addTarget: vi.fn(() => removeTargetMock),
58
+ destroy: vi.fn(),
59
+ } as unknown as Controller
60
+ vi.mocked(getOrCreateChannel).mockReturnValue(channel)
61
+ vi.mocked(getOrCreateController).mockReturnValue(controller)
62
+ })
63
+
64
+ it('should call onStatus callback when status changes', () => {
65
+ const onStatusMock = vi.fn()
66
+ renderHook(() =>
67
+ useFrameConnection({
68
+ name: 'test',
69
+ connectTo: 'iframe',
70
+ targetOrigin: '*',
71
+ onStatus: onStatusMock,
72
+ }),
73
+ )
74
+
75
+ act(() => {
76
+ statusCallback?.({status: 'connected', connection: 'test'})
77
+ })
78
+ expect(onStatusMock).toHaveBeenCalledWith('connected')
79
+
80
+ act(() => {
81
+ statusCallback?.({status: 'disconnected', connection: 'test'})
82
+ })
83
+ expect(onStatusMock).toHaveBeenCalledWith('disconnected')
84
+ })
85
+
86
+ it('should not throw if onStatus is not provided', () => {
87
+ renderHook(() =>
88
+ useFrameConnection({
89
+ name: 'test',
90
+ connectTo: 'iframe',
91
+ targetOrigin: '*',
92
+ }),
93
+ )
94
+
95
+ expect(() => {
96
+ act(() => {
97
+ statusCallback?.({status: 'connected', connection: 'test'})
98
+ })
99
+ }).not.toThrow()
100
+ })
101
+
102
+ it('should register and execute message handlers', () => {
103
+ const mockHandler = vi.fn()
104
+ const mockData = {someData: 'test'}
105
+ renderHook(() =>
106
+ useFrameConnection({
107
+ name: 'test',
108
+ connectTo: 'iframe',
109
+ targetOrigin: '*',
110
+ onMessage: {
111
+ TEST_MESSAGE: mockHandler,
112
+ },
113
+ }),
114
+ )
115
+
116
+ const onCallback = vi.mocked(channel.on).mock.calls[0][1]
117
+ onCallback(mockData)
118
+ expect(mockHandler).toHaveBeenCalledWith(mockData)
119
+ })
120
+
121
+ it('should handle connecting frames and cleanup on disconnect', () => {
122
+ const {result} = renderHook(() =>
123
+ useFrameConnection({
124
+ name: 'test',
125
+ connectTo: 'iframe',
126
+ targetOrigin: '*',
127
+ }),
128
+ )
129
+
130
+ const mockWindow = {} as Window
131
+ const cleanup = result.current.connect(mockWindow)
132
+
133
+ expect(controller.addTarget).toHaveBeenCalledWith(mockWindow)
134
+
135
+ // Test cleanup
136
+ cleanup()
137
+ expect(removeTargetMock).toHaveBeenCalled()
138
+ })
139
+
140
+ it('should send messages correctly', () => {
141
+ const {result} = renderHook(() =>
142
+ useFrameConnection<TestControllerMessage, TestNodeMessage>({
143
+ name: 'test',
144
+ connectTo: 'iframe',
145
+ targetOrigin: '*',
146
+ }),
147
+ )
148
+
149
+ const mockData = {someData: 'test'}
150
+ result.current.sendMessage('TEST_MESSAGE', mockData)
151
+
152
+ expect(channel.post).toHaveBeenCalledWith('TEST_MESSAGE', mockData)
153
+ })
154
+
155
+ it('should cleanup on unmount', () => {
156
+ const {unmount} = renderHook(() =>
157
+ useFrameConnection({
158
+ name: 'test',
159
+ connectTo: 'iframe',
160
+ targetOrigin: '*',
161
+ }),
162
+ )
163
+
164
+ unmount()
165
+ expect(releaseChannel).toHaveBeenCalled()
166
+ })
167
+ })
@@ -0,0 +1,107 @@
1
+ import {type ChannelInstance, type Controller, type Status} from '@sanity/comlink'
2
+ import {
3
+ type FrameMessage,
4
+ getOrCreateChannel,
5
+ getOrCreateController,
6
+ releaseChannel,
7
+ type WindowMessage,
8
+ } from '@sanity/sdk'
9
+ import {useCallback, useEffect, useRef} from 'react'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+
13
+ /**
14
+ * @internal
15
+ */
16
+ export type FrameMessageHandler<TWindowMessage extends WindowMessage> = (
17
+ event: TWindowMessage['data'],
18
+ ) => TWindowMessage['response'] | Promise<TWindowMessage['response']>
19
+
20
+ /**
21
+ * @internal
22
+ */
23
+ export interface UseFrameConnectionOptions<TWindowMessage extends WindowMessage> {
24
+ name: string
25
+ connectTo: string
26
+ targetOrigin: string
27
+ onMessage?: {
28
+ [K in TWindowMessage['type']]: (data: Extract<TWindowMessage, {type: K}>['data']) => void
29
+ }
30
+ heartbeat?: boolean
31
+ onStatus?: (status: Status) => void
32
+ }
33
+
34
+ /**
35
+ * @internal
36
+ */
37
+ export interface FrameConnection<TFrameMessage extends FrameMessage> {
38
+ connect: (frameWindow: Window) => () => void // Return cleanup function
39
+ sendMessage: <T extends TFrameMessage['type']>(
40
+ ...params: Extract<TFrameMessage, {type: T}>['data'] extends undefined
41
+ ? [type: T]
42
+ : [type: T, data: Extract<TFrameMessage, {type: T}>['data']]
43
+ ) => void
44
+ }
45
+
46
+ /**
47
+ * @internal
48
+ */
49
+ export function useFrameConnection<
50
+ TFrameMessage extends FrameMessage,
51
+ TWindowMessage extends WindowMessage,
52
+ >(options: UseFrameConnectionOptions<TWindowMessage>): FrameConnection<TFrameMessage> {
53
+ const {onMessage, targetOrigin, name, connectTo, heartbeat, onStatus} = options
54
+ const instance = useSanityInstance()
55
+ const controllerRef = useRef<Controller | null>(null)
56
+ const channelRef = useRef<ChannelInstance<TFrameMessage, TWindowMessage> | null>(null)
57
+
58
+ useEffect(() => {
59
+ const controller = getOrCreateController(instance, targetOrigin)
60
+ const channel = getOrCreateChannel(instance, {name, connectTo, heartbeat})
61
+ controllerRef.current = controller
62
+ channelRef.current = channel
63
+
64
+ channel.onStatus((event) => {
65
+ onStatus?.(event.status)
66
+ })
67
+
68
+ const messageUnsubscribers: Array<() => void> = []
69
+
70
+ if (onMessage) {
71
+ Object.entries(onMessage).forEach(([type, handler]) => {
72
+ const unsubscribe = channel.on(type, handler as FrameMessageHandler<TWindowMessage>)
73
+ messageUnsubscribers.push(unsubscribe)
74
+ })
75
+ }
76
+
77
+ return () => {
78
+ // Clean up all subscriptions and stop controller/channel
79
+ messageUnsubscribers.forEach((unsub) => unsub())
80
+ releaseChannel(instance, name)
81
+ channelRef.current = null
82
+ controllerRef.current = null
83
+ }
84
+ }, [targetOrigin, name, connectTo, heartbeat, onMessage, instance, onStatus])
85
+
86
+ const connect = useCallback((frameWindow: Window) => {
87
+ const removeTarget = controllerRef.current?.addTarget(frameWindow)
88
+ return () => {
89
+ removeTarget?.()
90
+ }
91
+ }, [])
92
+
93
+ const sendMessage = useCallback(
94
+ <T extends TFrameMessage['type']>(
95
+ type: T,
96
+ data?: Extract<TFrameMessage, {type: T}>['data'],
97
+ ) => {
98
+ channelRef.current?.post(type, data)
99
+ },
100
+ [],
101
+ )
102
+
103
+ return {
104
+ connect,
105
+ sendMessage,
106
+ }
107
+ }
@@ -0,0 +1,111 @@
1
+ import {type Message, type Node, type Status} from '@sanity/comlink'
2
+ import {getOrCreateNode} from '@sanity/sdk'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {act, renderHook} from '../../../test/test-utils'
6
+ import {useManageFavorite} from './useManageFavorite'
7
+
8
+ vi.mock(import('@sanity/sdk'), async (importOriginal) => {
9
+ const actual = await importOriginal()
10
+ return {
11
+ ...actual,
12
+ getOrCreateNode: vi.fn(),
13
+ releaseNode: vi.fn(),
14
+ }
15
+ })
16
+
17
+ describe('useManageFavorite', () => {
18
+ let node: Node<Message, Message>
19
+ let statusCallback: ((status: Status) => void) | null = null
20
+
21
+ const mockDocumentHandle = {
22
+ documentId: 'mock-id',
23
+ documentType: 'mock-type',
24
+ resourceType: 'studio' as const,
25
+ }
26
+
27
+ function createMockNode() {
28
+ return {
29
+ on: vi.fn(() => () => {}),
30
+ post: vi.fn(),
31
+ stop: vi.fn(),
32
+ onStatus: vi.fn((callback) => {
33
+ statusCallback = callback
34
+ return () => {}
35
+ }),
36
+ } as unknown as Node<Message, Message>
37
+ }
38
+
39
+ beforeEach(() => {
40
+ statusCallback = null
41
+ node = createMockNode()
42
+ vi.mocked(getOrCreateNode).mockReturnValue(node)
43
+ })
44
+
45
+ it('should initialize with default states', () => {
46
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
47
+
48
+ expect(result.current.isFavorited).toBe(false)
49
+ expect(result.current.isConnected).toBe(false)
50
+ })
51
+
52
+ it('should handle favorite action', () => {
53
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
54
+
55
+ act(() => {
56
+ result.current.favorite()
57
+ })
58
+
59
+ expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
60
+ documentId: 'mock-id',
61
+ documentType: 'mock-type',
62
+ eventType: 'added',
63
+ resourceType: 'studio',
64
+ resourceId: undefined,
65
+ })
66
+ expect(result.current.isFavorited).toBe(true)
67
+ })
68
+
69
+ it('should handle unfavorite action', () => {
70
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
71
+
72
+ act(() => {
73
+ result.current.unfavorite()
74
+ })
75
+
76
+ expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
77
+ documentId: 'mock-id',
78
+ documentType: 'mock-type',
79
+ eventType: 'removed',
80
+ resourceType: 'studio',
81
+ resourceId: undefined,
82
+ })
83
+ expect(result.current.isFavorited).toBe(false)
84
+ })
85
+
86
+ it('should throw error during favorite/unfavorite actions', () => {
87
+ const errorMessage = 'Failed to update favorite status'
88
+
89
+ vi.mocked(node.post).mockImplementationOnce(() => {
90
+ throw new Error(errorMessage)
91
+ })
92
+
93
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
94
+
95
+ act(() => {
96
+ expect(() => result.current.favorite()).toThrow(errorMessage)
97
+ })
98
+ })
99
+
100
+ it('should update connection status', () => {
101
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
102
+
103
+ expect(result.current.isConnected).toBe(false)
104
+
105
+ act(() => {
106
+ statusCallback?.('connected')
107
+ })
108
+
109
+ expect(result.current.isConnected).toBe(true)
110
+ })
111
+ })