@sanity/sdk-react 0.0.0-alpha.8 → 0.0.0-chore-react-18-compat.0

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 (100) hide show
  1. package/README.md +33 -126
  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 +23 -45
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +90 -0
  8. package/src/components/Login/LoginLinks.tsx +58 -0
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +104 -2
  12. package/src/components/SanityApp.tsx +54 -17
  13. package/src/components/auth/AuthBoundary.test.tsx +4 -4
  14. package/src/components/auth/AuthBoundary.tsx +13 -3
  15. package/src/components/auth/Login.test.tsx +1 -1
  16. package/src/components/auth/Login.tsx +11 -26
  17. package/src/components/auth/LoginCallback.test.tsx +3 -3
  18. package/src/components/auth/LoginCallback.tsx +8 -11
  19. package/src/components/auth/LoginError.tsx +12 -8
  20. package/src/components/auth/LoginFooter.tsx +13 -20
  21. package/src/components/auth/LoginLayout.tsx +8 -9
  22. package/src/components/auth/authTestHelpers.tsx +1 -8
  23. package/src/components/utils.ts +22 -0
  24. package/src/context/SanityInstanceContext.ts +4 -0
  25. package/src/context/SanityProvider.test.tsx +1 -1
  26. package/src/context/SanityProvider.tsx +10 -8
  27. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  28. package/src/hooks/auth/useAuthState.tsx +0 -2
  29. package/src/hooks/auth/useCurrentUser.tsx +27 -20
  30. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  31. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  32. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  33. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +6 -6
  34. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  35. package/src/hooks/client/useClient.ts +9 -30
  36. package/src/hooks/comlink/useFrameConnection.test.tsx +55 -10
  37. package/src/hooks/comlink/useFrameConnection.ts +39 -43
  38. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  39. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  40. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  42. package/src/hooks/comlink/useWindowConnection.test.ts +53 -12
  43. package/src/hooks/comlink/useWindowConnection.ts +69 -29
  44. package/src/hooks/context/useSanityInstance.test.tsx +1 -1
  45. package/src/hooks/context/useSanityInstance.ts +21 -5
  46. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  48. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  50. package/src/hooks/datasets/useDatasets.ts +40 -0
  51. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  52. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  53. package/src/hooks/document/useDocument.test.ts +81 -0
  54. package/src/hooks/document/useDocument.ts +107 -0
  55. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  56. package/src/hooks/document/useDocumentEvent.ts +54 -0
  57. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  58. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  59. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  60. package/src/hooks/document/useEditDocument.test.ts +179 -0
  61. package/src/hooks/document/useEditDocument.ts +195 -0
  62. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  63. package/src/hooks/documents/useDocuments.ts +174 -0
  64. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  65. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  66. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  67. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  68. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  69. package/src/hooks/preview/usePreview.test.tsx +6 -6
  70. package/src/hooks/preview/usePreview.tsx +12 -9
  71. package/src/hooks/projection/useProjection.test.tsx +218 -0
  72. package/src/hooks/projection/useProjection.ts +147 -0
  73. package/src/hooks/projects/useProject.ts +48 -0
  74. package/src/hooks/projects/useProjects.ts +45 -0
  75. package/src/hooks/query/useQuery.test.tsx +188 -0
  76. package/src/hooks/query/useQuery.ts +103 -0
  77. package/src/hooks/users/useUsers.test.ts +163 -0
  78. package/src/hooks/users/useUsers.ts +107 -0
  79. package/src/utils/getEnv.ts +21 -0
  80. package/src/version.ts +8 -0
  81. package/dist/_chunks-es/context.js +0 -8
  82. package/dist/_chunks-es/context.js.map +0 -1
  83. package/dist/_chunks-es/useLogOut.js +0 -44
  84. package/dist/_chunks-es/useLogOut.js.map +0 -1
  85. package/dist/components.d.ts +0 -111
  86. package/dist/components.js +0 -153
  87. package/dist/components.js.map +0 -1
  88. package/dist/context.d.ts +0 -45
  89. package/dist/context.js +0 -5
  90. package/dist/context.js.map +0 -1
  91. package/dist/hooks.d.ts +0 -3485
  92. package/dist/hooks.js +0 -167
  93. package/dist/hooks.js.map +0 -1
  94. package/src/_exports/components.ts +0 -2
  95. package/src/_exports/context.ts +0 -2
  96. package/src/_exports/hooks.ts +0 -27
  97. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  98. package/src/hooks/client/useClient.test.tsx +0 -130
  99. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  100. package/src/hooks/documentCollection/useDocuments.ts +0 -135
@@ -1,7 +1,7 @@
1
- import {type ChannelInstance, type Controller} from '@sanity/comlink'
1
+ import {type ChannelInstance, type Controller, type Status} from '@sanity/comlink'
2
2
  import {beforeEach, describe, expect, it, vi} from 'vitest'
3
3
 
4
- import {renderHook} from '../../../test/test-utils'
4
+ import {act, renderHook} from '../../../test/test-utils'
5
5
  import {useFrameConnection} from './useFrameConnection'
6
6
 
7
7
  vi.mock(import('@sanity/sdk'), async (importOriginal) => {
@@ -30,18 +30,25 @@ interface TestNodeMessage {
30
30
  }
31
31
  }
32
32
 
33
- function createMockChannel() {
34
- return {
35
- on: vi.fn(() => () => {}),
36
- post: vi.fn(),
37
- stop: vi.fn(),
38
- } as unknown as ChannelInstance<TestControllerMessage, TestNodeMessage>
39
- }
40
-
41
33
  describe('useFrameController', () => {
42
34
  let channel: ChannelInstance<TestControllerMessage, TestNodeMessage>
43
35
  let controller: Controller
44
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
+ }
45
52
 
46
53
  beforeEach(() => {
47
54
  channel = createMockChannel()
@@ -54,6 +61,44 @@ describe('useFrameController', () => {
54
61
  vi.mocked(getOrCreateController).mockReturnValue(controller)
55
62
  })
56
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
+
57
102
  it('should register and execute message handlers', () => {
58
103
  const mockHandler = vi.fn()
59
104
  const mockData = {someData: 'test'}
@@ -1,3 +1,4 @@
1
+ import {type ChannelInstance, type Controller, type Status} from '@sanity/comlink'
1
2
  import {
2
3
  type FrameMessage,
3
4
  getOrCreateChannel,
@@ -5,7 +6,7 @@ import {
5
6
  releaseChannel,
6
7
  type WindowMessage,
7
8
  } from '@sanity/sdk'
8
- import {useCallback, useEffect, useMemo} from 'react'
9
+ import {useCallback, useEffect, useRef} from 'react'
9
10
 
10
11
  import {useSanityInstance} from '../context/useSanityInstance'
11
12
 
@@ -23,7 +24,11 @@ export interface UseFrameConnectionOptions<TWindowMessage extends WindowMessage>
23
24
  name: string
24
25
  connectTo: string
25
26
  targetOrigin: string
26
- onMessage?: Record<string, FrameMessageHandler<TWindowMessage>>
27
+ onMessage?: {
28
+ [K in TWindowMessage['type']]: (data: Extract<TWindowMessage, {type: K}>['data']) => void
29
+ }
30
+ heartbeat?: boolean
31
+ onStatus?: (status: Status) => void
27
32
  }
28
33
 
29
34
  /**
@@ -45,65 +50,56 @@ export function useFrameConnection<
45
50
  TFrameMessage extends FrameMessage,
46
51
  TWindowMessage extends WindowMessage,
47
52
  >(options: UseFrameConnectionOptions<TWindowMessage>): FrameConnection<TFrameMessage> {
48
- const {onMessage, targetOrigin, name, connectTo} = options
53
+ const {onMessage, targetOrigin, name, connectTo, heartbeat, onStatus} = options
49
54
  const instance = useSanityInstance()
50
-
51
- const controller = useMemo(
52
- () => getOrCreateController(instance, targetOrigin),
53
- [instance, targetOrigin],
54
- )
55
-
56
- const channel = useMemo(
57
- () =>
58
- getOrCreateChannel(instance, {
59
- name,
60
- connectTo,
61
- }),
62
- [instance, name, connectTo],
63
- )
55
+ const controllerRef = useRef<Controller | null>(null)
56
+ const channelRef = useRef<ChannelInstance<TFrameMessage, TWindowMessage> | null>(null)
64
57
 
65
58
  useEffect(() => {
66
- if (!channel || !onMessage) return
59
+ const controller = getOrCreateController(instance, targetOrigin)
60
+ const channel = getOrCreateChannel(instance, {name, connectTo, heartbeat})
61
+ controllerRef.current = controller
62
+ channelRef.current = channel
67
63
 
68
- const unsubscribers: Array<() => void> = []
69
-
70
- Object.entries(onMessage).forEach(([type, handler]) => {
71
- const unsubscribe = channel.on(type, handler)
72
- unsubscribers.push(unsubscribe)
64
+ channel.onStatus((event) => {
65
+ onStatus?.(event.status)
73
66
  })
74
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
+
75
77
  return () => {
76
- unsubscribers.forEach((unsub) => unsub())
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
77
83
  }
78
- }, [channel, onMessage])
84
+ }, [targetOrigin, name, connectTo, heartbeat, onMessage, instance, onStatus])
79
85
 
80
- const connect = useCallback(
81
- (frameWindow: Window) => {
82
- const removeTarget = controller?.addTarget(frameWindow)
83
- return () => {
84
- removeTarget?.()
85
- }
86
- },
87
- [controller],
88
- )
86
+ const connect = useCallback((frameWindow: Window) => {
87
+ const removeTarget = controllerRef.current?.addTarget(frameWindow)
88
+ return () => {
89
+ removeTarget?.()
90
+ }
91
+ }, [])
89
92
 
90
93
  const sendMessage = useCallback(
91
94
  <T extends TFrameMessage['type']>(
92
95
  type: T,
93
96
  data?: Extract<TFrameMessage, {type: T}>['data'],
94
97
  ) => {
95
- channel?.post(type, data)
98
+ channelRef.current?.post(type, data)
96
99
  },
97
- [channel],
100
+ [],
98
101
  )
99
102
 
100
- // cleanup channel on unmount
101
- useEffect(() => {
102
- return () => {
103
- releaseChannel(instance, name)
104
- }
105
- }, [name, instance])
106
-
107
103
  return {
108
104
  connect,
109
105
  sendMessage,
@@ -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
+ })
@@ -0,0 +1,130 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {
3
+ type CanvasResource,
4
+ type Events,
5
+ type MediaResource,
6
+ SDK_CHANNEL_NAME,
7
+ SDK_NODE_NAME,
8
+ type StudioResource,
9
+ } from '@sanity/message-protocol'
10
+ import {type FrameMessage} from '@sanity/sdk'
11
+ import {useCallback, useState} from 'react'
12
+
13
+ import {useWindowConnection} from './useWindowConnection'
14
+
15
+ // should we import this whole type from the message protocol?
16
+
17
+ interface ManageFavorite {
18
+ favorite: () => void
19
+ unfavorite: () => void
20
+ isFavorited: boolean
21
+ isConnected: boolean
22
+ }
23
+
24
+ interface UseManageFavoriteProps {
25
+ documentId: string
26
+ documentType: string
27
+ resourceId?: string
28
+ resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
29
+ }
30
+
31
+ /**
32
+ * @beta
33
+ *
34
+ * ## useManageFavorite
35
+ * This hook provides functionality to add and remove documents from favorites,
36
+ * and tracks the current favorite status of the document.
37
+ * @category Dashboard Communication
38
+ * @param documentHandle - The document handle containing document ID and type, like `{_id: '123', _type: 'book'}`
39
+ * @returns An object containing:
40
+ * - `favorite` - Function to add document to favorites
41
+ * - `unfavorite` - Function to remove document from favorites
42
+ * - `isFavorited` - Boolean indicating if document is currently favorited
43
+ * - `isConnected` - Boolean indicating if connection to Dashboard UI is established
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * function MyDocumentAction(props: DocumentActionProps) {
48
+ * const {documentId, documentType} = props
49
+ * const {favorite, unfavorite, isFavorited, isConnected} = useManageFavorite({
50
+ * documentId,
51
+ * documentType
52
+ * })
53
+ *
54
+ * return (
55
+ * <Button
56
+ * disabled={!isConnected}
57
+ * onClick={() => isFavorited ? unfavorite() : favorite()}
58
+ * text={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
59
+ * />
60
+ * )
61
+ * }
62
+ * ```
63
+ */
64
+ export function useManageFavorite({
65
+ documentId,
66
+ documentType,
67
+ resourceId,
68
+ resourceType,
69
+ }: UseManageFavoriteProps): ManageFavorite {
70
+ const [isFavorited, setIsFavorited] = useState(false) // should load this from a comlink fetch
71
+ const [status, setStatus] = useState<Status>('idle')
72
+ const {sendMessage} = useWindowConnection<Events.FavoriteMessage, FrameMessage>({
73
+ name: SDK_NODE_NAME,
74
+ connectTo: SDK_CHANNEL_NAME,
75
+ onStatus: setStatus,
76
+ })
77
+
78
+ if (resourceType !== 'studio' && !resourceId) {
79
+ throw new Error('resourceId is required for media-library and canvas resources')
80
+ }
81
+
82
+ const handleFavoriteAction = useCallback(
83
+ (action: 'added' | 'removed', setFavoriteState: boolean) => {
84
+ if (!documentId || !documentType || !resourceType) return
85
+
86
+ try {
87
+ const message: Events.FavoriteMessage = {
88
+ type: 'dashboard/v1/events/favorite/mutate',
89
+ data: {
90
+ eventType: action,
91
+ documentId,
92
+ documentType,
93
+ resourceType,
94
+ // Resource Id should exist for media-library and canvas resources
95
+ resourceId: resourceId!,
96
+ },
97
+ response: {
98
+ success: true,
99
+ },
100
+ }
101
+
102
+ sendMessage(message.type, message.data)
103
+ setIsFavorited(setFavoriteState)
104
+ } catch (err) {
105
+ const error = err instanceof Error ? err : new Error('Failed to update favorite status')
106
+ // eslint-disable-next-line no-console
107
+ console.error(
108
+ `Failed to ${action === 'added' ? 'favorite' : 'unfavorite'} document:`,
109
+ error,
110
+ )
111
+ throw error
112
+ }
113
+ },
114
+ [documentId, documentType, resourceId, resourceType, sendMessage],
115
+ )
116
+
117
+ const favorite = useCallback(() => handleFavoriteAction('added', true), [handleFavoriteAction])
118
+
119
+ const unfavorite = useCallback(
120
+ () => handleFavoriteAction('removed', false),
121
+ [handleFavoriteAction],
122
+ )
123
+
124
+ return {
125
+ favorite,
126
+ unfavorite,
127
+ isFavorited,
128
+ isConnected: status === 'connected',
129
+ }
130
+ }
@@ -0,0 +1,81 @@
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 {useRecordDocumentHistoryEvent} from './useRecordDocumentHistoryEvent'
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('useRecordDocumentHistoryEvent', () => {
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
+ resourceId: 'mock-resource-id',
26
+ }
27
+
28
+ function createMockNode() {
29
+ return {
30
+ on: vi.fn(() => () => {}),
31
+ post: vi.fn(),
32
+ stop: vi.fn(),
33
+ onStatus: vi.fn((callback) => {
34
+ statusCallback = callback
35
+ return () => {}
36
+ }),
37
+ } as unknown as Node<Message, Message>
38
+ }
39
+
40
+ beforeEach(() => {
41
+ statusCallback = null
42
+ node = createMockNode()
43
+ vi.mocked(getOrCreateNode).mockReturnValue(node)
44
+ })
45
+
46
+ it('should initialize with correct connection status', () => {
47
+ const {result} = renderHook(() => useRecordDocumentHistoryEvent(mockDocumentHandle))
48
+
49
+ expect(result.current.isConnected).toBe(false)
50
+
51
+ act(() => {
52
+ statusCallback?.('connected')
53
+ })
54
+
55
+ expect(result.current.isConnected).toBe(true)
56
+ })
57
+
58
+ it('should send correct message when recording events', () => {
59
+ const {result} = renderHook(() => useRecordDocumentHistoryEvent(mockDocumentHandle))
60
+
61
+ result.current.recordEvent('viewed')
62
+
63
+ expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/history', {
64
+ eventType: 'viewed',
65
+ documentId: 'mock-id',
66
+ documentType: 'mock-type',
67
+ resourceType: 'studio',
68
+ resourceId: 'mock-resource-id',
69
+ })
70
+ })
71
+
72
+ it('should handle errors when sending messages', () => {
73
+ vi.mocked(node.post).mockImplementation(() => {
74
+ throw new Error('Failed to send message')
75
+ })
76
+
77
+ const {result} = renderHook(() => useRecordDocumentHistoryEvent(mockDocumentHandle))
78
+
79
+ expect(() => result.current.recordEvent('viewed')).toThrow('Failed to send message')
80
+ })
81
+ })
@@ -0,0 +1,106 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {
3
+ type CanvasResource,
4
+ type Events,
5
+ type MediaResource,
6
+ SDK_CHANNEL_NAME,
7
+ SDK_NODE_NAME,
8
+ type StudioResource,
9
+ } from '@sanity/message-protocol'
10
+ import {type FrameMessage} from '@sanity/sdk'
11
+ import {useCallback, useState} from 'react'
12
+
13
+ import {useWindowConnection} from './useWindowConnection'
14
+
15
+ interface DocumentInteractionHistory {
16
+ recordEvent: (eventType: 'viewed' | 'edited' | 'created' | 'deleted') => void
17
+ isConnected: boolean
18
+ }
19
+
20
+ /**
21
+ * @public
22
+ */
23
+ interface UseRecordDocumentHistoryEventProps {
24
+ documentId: string
25
+ documentType: string
26
+ resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
27
+ resourceId?: string
28
+ }
29
+
30
+ /**
31
+ * @public
32
+ * Hook for managing document interaction history in Sanity Studio.
33
+ * This hook provides functionality to record document interactions.
34
+ * @category History
35
+ * @param documentHandle - The document handle containing document ID and type, like `{_id: '123', _type: 'book'}`
36
+ * @returns An object containing:
37
+ * - `recordEvent` - Function to record document interactions
38
+ * - `isConnected` - Boolean indicating if connection to Studio is established
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * function MyDocumentAction(props: DocumentActionProps) {
43
+ * const {documentId, documentType, resourceType, resourceId} = props
44
+ * const {recordEvent, isConnected} = useRecordDocumentHistoryEvent({
45
+ * documentId,
46
+ * documentType,
47
+ * resourceType,
48
+ * resourceId,
49
+ * })
50
+ *
51
+ * return (
52
+ * <Button
53
+ * disabled={!isConnected}
54
+ * onClick={() => recordEvent('viewed')}
55
+ * text={'Viewed'}
56
+ * />
57
+ * )
58
+ * }
59
+ * ```
60
+ */
61
+ export function useRecordDocumentHistoryEvent({
62
+ documentId,
63
+ documentType,
64
+ resourceType,
65
+ resourceId,
66
+ }: UseRecordDocumentHistoryEventProps): DocumentInteractionHistory {
67
+ const [status, setStatus] = useState<Status>('idle')
68
+ const {sendMessage} = useWindowConnection<Events.HistoryMessage, FrameMessage>({
69
+ name: SDK_NODE_NAME,
70
+ connectTo: SDK_CHANNEL_NAME,
71
+ onStatus: setStatus,
72
+ })
73
+
74
+ if (resourceType !== 'studio' && !resourceId) {
75
+ throw new Error('resourceId is required for media-library and canvas resources')
76
+ }
77
+
78
+ const recordEvent = useCallback(
79
+ (eventType: 'viewed' | 'edited' | 'created' | 'deleted') => {
80
+ try {
81
+ const message: Events.HistoryMessage = {
82
+ type: 'dashboard/v1/events/history',
83
+ data: {
84
+ eventType,
85
+ documentId,
86
+ documentType,
87
+ resourceType,
88
+ resourceId: resourceId!,
89
+ },
90
+ }
91
+
92
+ sendMessage(message.type, message.data)
93
+ } catch (error) {
94
+ // eslint-disable-next-line no-console
95
+ console.error('Failed to record history event:', error)
96
+ throw error
97
+ }
98
+ },
99
+ [documentId, documentType, resourceId, resourceType, sendMessage],
100
+ )
101
+
102
+ return {
103
+ recordEvent,
104
+ isConnected: status === 'connected',
105
+ }
106
+ }