@sanity/sdk-react 2.3.1 → 2.4.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.
- package/dist/index.d.ts +210 -4
- package/dist/index.js +129 -36
- package/dist/index.js.map +1 -1
- package/package.json +15 -14
- package/src/_exports/sdk-react.ts +8 -0
- package/src/components/auth/LoginError.tsx +27 -7
- package/src/context/ComlinkTokenRefresh.test.tsx +107 -23
- package/src/hooks/agent/agentActions.test.tsx +78 -0
- package/src/hooks/agent/agentActions.ts +136 -0
- package/src/hooks/client/useClient.test.tsx +42 -0
- package/src/hooks/client/useClient.ts +11 -4
- package/src/hooks/dashboard/types.ts +12 -0
- package/src/hooks/dashboard/useDispatchIntent.test.ts +242 -0
- package/src/hooks/dashboard/useDispatchIntent.ts +158 -0
- package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.test.ts +155 -0
- package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.ts +53 -0
- package/src/hooks/document/useApplyDocumentActions.ts +31 -0
- package/src/hooks/projection/useDocumentProjection.ts +14 -3
- package/src/hooks/users/useUsers.ts +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {canvasSource, type DocumentHandle, mediaLibrarySource} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {type DocumentHandleWithSource} from './types'
|
|
6
|
+
import {useDispatchIntent} from './useDispatchIntent'
|
|
7
|
+
|
|
8
|
+
// Mock the useWindowConnection hook
|
|
9
|
+
const mockSendMessage = vi.fn()
|
|
10
|
+
vi.mock('../comlink/useWindowConnection', () => ({
|
|
11
|
+
useWindowConnection: vi.fn(() => ({
|
|
12
|
+
sendMessage: mockSendMessage,
|
|
13
|
+
})),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('useDispatchIntent', () => {
|
|
17
|
+
const mockDocumentHandle: DocumentHandle = {
|
|
18
|
+
documentId: 'test-document-id',
|
|
19
|
+
documentType: 'test-document-type',
|
|
20
|
+
projectId: 'test-project-id',
|
|
21
|
+
dataset: 'test-dataset',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks()
|
|
26
|
+
// Reset mock implementation to default behavior
|
|
27
|
+
mockSendMessage.mockImplementation(() => {})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should return dispatchIntent function', () => {
|
|
31
|
+
const {result} = renderHook(() =>
|
|
32
|
+
useDispatchIntent({action: 'edit', documentHandle: mockDocumentHandle}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(result.current).toEqual({
|
|
36
|
+
dispatchIntent: expect.any(Function),
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should throw error when neither action nor intentId is provided', () => {
|
|
41
|
+
const {result} = renderHook(() =>
|
|
42
|
+
// @ts-expect-error - Testing runtime error when neither is provided
|
|
43
|
+
useDispatchIntent({documentHandle: mockDocumentHandle}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(() => result.current.dispatchIntent()).toThrow(
|
|
47
|
+
'useDispatchIntent: Either `action` or `intentId` must be provided.',
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should handle errors gracefully', () => {
|
|
52
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
53
|
+
mockSendMessage.mockImplementation(() => {
|
|
54
|
+
throw new Error('Test error')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const {result} = renderHook(() =>
|
|
58
|
+
useDispatchIntent({action: 'edit', documentHandle: mockDocumentHandle}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(() => result.current.dispatchIntent()).toThrow('Test error')
|
|
62
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to dispatch intent:', expect.any(Error))
|
|
63
|
+
|
|
64
|
+
consoleErrorSpy.mockRestore()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should use memoized dispatchIntent function', () => {
|
|
68
|
+
const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
|
|
69
|
+
initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const firstDispatchIntent = result.current.dispatchIntent
|
|
73
|
+
|
|
74
|
+
// Rerender with the same params
|
|
75
|
+
rerender({params: {action: 'edit' as const, documentHandle: mockDocumentHandle}})
|
|
76
|
+
|
|
77
|
+
expect(result.current.dispatchIntent).toBe(firstDispatchIntent)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should create new dispatchIntent function when documentHandle changes', () => {
|
|
81
|
+
const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
|
|
82
|
+
initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const firstDispatchIntent = result.current.dispatchIntent
|
|
86
|
+
|
|
87
|
+
const newDocumentHandle: DocumentHandle = {
|
|
88
|
+
documentId: 'new-document-id',
|
|
89
|
+
documentType: 'new-document-type',
|
|
90
|
+
projectId: 'new-project-id',
|
|
91
|
+
dataset: 'new-dataset',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
rerender({params: {action: 'edit' as const, documentHandle: newDocumentHandle}})
|
|
95
|
+
|
|
96
|
+
expect(result.current.dispatchIntent).not.toBe(firstDispatchIntent)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should warn if both action and intentId are provided', () => {
|
|
100
|
+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
101
|
+
const {result} = renderHook(() =>
|
|
102
|
+
useDispatchIntent({
|
|
103
|
+
action: 'edit' as const,
|
|
104
|
+
intentId: 'custom-intent' as never, // test runtime error when both are provided
|
|
105
|
+
documentHandle: mockDocumentHandle,
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
result.current.dispatchIntent()
|
|
109
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
110
|
+
'useDispatchIntent: Both `action` and `intentId` were provided. Using `intentId` and ignoring `action`.',
|
|
111
|
+
)
|
|
112
|
+
consoleWarnSpy.mockRestore()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should send intent message with action and params when both are provided', () => {
|
|
116
|
+
const intentParams = {view: 'editor', tab: 'content'}
|
|
117
|
+
const {result} = renderHook(() =>
|
|
118
|
+
useDispatchIntent({
|
|
119
|
+
action: 'edit',
|
|
120
|
+
documentHandle: mockDocumentHandle,
|
|
121
|
+
parameters: intentParams,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
result.current.dispatchIntent()
|
|
126
|
+
|
|
127
|
+
expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
|
|
128
|
+
action: 'edit',
|
|
129
|
+
document: {
|
|
130
|
+
id: 'test-document-id',
|
|
131
|
+
type: 'test-document-type',
|
|
132
|
+
},
|
|
133
|
+
resource: {
|
|
134
|
+
id: 'test-project-id.test-dataset',
|
|
135
|
+
},
|
|
136
|
+
parameters: intentParams,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should send intent message with intentId and params when both are provided', () => {
|
|
141
|
+
const intentParams = {view: 'editor', tab: 'content'}
|
|
142
|
+
const {result} = renderHook(() =>
|
|
143
|
+
useDispatchIntent({
|
|
144
|
+
intentId: 'custom-intent',
|
|
145
|
+
documentHandle: mockDocumentHandle,
|
|
146
|
+
parameters: intentParams,
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
result.current.dispatchIntent()
|
|
151
|
+
|
|
152
|
+
expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
|
|
153
|
+
intentId: 'custom-intent',
|
|
154
|
+
document: {
|
|
155
|
+
id: 'test-document-id',
|
|
156
|
+
type: 'test-document-type',
|
|
157
|
+
},
|
|
158
|
+
resource: {
|
|
159
|
+
id: 'test-project-id.test-dataset',
|
|
160
|
+
},
|
|
161
|
+
parameters: intentParams,
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should send intent message with media library source', () => {
|
|
166
|
+
const mockMediaLibraryHandle: DocumentHandleWithSource = {
|
|
167
|
+
documentId: 'test-asset-id',
|
|
168
|
+
documentType: 'sanity.asset',
|
|
169
|
+
source: mediaLibrarySource('mlPGY7BEqt52'),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const {result} = renderHook(() =>
|
|
173
|
+
useDispatchIntent({
|
|
174
|
+
action: 'edit',
|
|
175
|
+
documentHandle: mockMediaLibraryHandle,
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result.current.dispatchIntent()
|
|
180
|
+
|
|
181
|
+
expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
|
|
182
|
+
action: 'edit',
|
|
183
|
+
document: {
|
|
184
|
+
id: 'test-asset-id',
|
|
185
|
+
type: 'sanity.asset',
|
|
186
|
+
},
|
|
187
|
+
resource: {
|
|
188
|
+
id: 'mlPGY7BEqt52',
|
|
189
|
+
type: 'media-library',
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should send intent message with canvas source', () => {
|
|
195
|
+
const mockCanvasHandle: DocumentHandleWithSource = {
|
|
196
|
+
documentId: 'test-canvas-document-id',
|
|
197
|
+
documentType: 'sanity.canvas.document',
|
|
198
|
+
source: canvasSource('canvas123'),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const {result} = renderHook(() =>
|
|
202
|
+
useDispatchIntent({
|
|
203
|
+
action: 'edit',
|
|
204
|
+
documentHandle: mockCanvasHandle,
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
result.current.dispatchIntent()
|
|
209
|
+
|
|
210
|
+
expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
|
|
211
|
+
action: 'edit',
|
|
212
|
+
document: {
|
|
213
|
+
id: 'test-canvas-document-id',
|
|
214
|
+
type: 'sanity.canvas.document',
|
|
215
|
+
},
|
|
216
|
+
resource: {
|
|
217
|
+
id: 'canvas123',
|
|
218
|
+
type: 'canvas',
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('error handling', () => {
|
|
224
|
+
it('should throw error when neither source nor projectId/dataset is provided', () => {
|
|
225
|
+
const invalidHandle = {
|
|
226
|
+
documentId: 'test-document-id',
|
|
227
|
+
documentType: 'test-document-type',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const {result} = renderHook(() =>
|
|
231
|
+
useDispatchIntent({
|
|
232
|
+
action: 'edit',
|
|
233
|
+
documentHandle: invalidHandle as unknown as DocumentHandleWithSource,
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
expect(() => result.current.dispatchIntent()).toThrow(
|
|
238
|
+
'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
+
import {type FrameMessage} from '@sanity/sdk'
|
|
3
|
+
import {useCallback} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
6
|
+
import {type DocumentHandleWithSource} from './types'
|
|
7
|
+
import {getResourceIdFromDocumentHandle} from './utils/getResourceIdFromDocumentHandle'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Message type for sending intents to the dashboard
|
|
11
|
+
* @beta
|
|
12
|
+
*/
|
|
13
|
+
interface IntentMessage {
|
|
14
|
+
type: 'dashboard/v1/events/intents/dispatch-intent'
|
|
15
|
+
data: {
|
|
16
|
+
action?: 'edit'
|
|
17
|
+
intentId?: string
|
|
18
|
+
document: {
|
|
19
|
+
id: string
|
|
20
|
+
type: string
|
|
21
|
+
}
|
|
22
|
+
resource?: {
|
|
23
|
+
id: string
|
|
24
|
+
type?: 'media-library' | 'canvas'
|
|
25
|
+
}
|
|
26
|
+
parameters?: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return type for the useDispatchIntent hook
|
|
32
|
+
* @beta
|
|
33
|
+
*/
|
|
34
|
+
interface DispatchIntent {
|
|
35
|
+
dispatchIntent: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parameters for the useDispatchIntent hook
|
|
40
|
+
* @beta
|
|
41
|
+
*/
|
|
42
|
+
interface UseDispatchIntentParams {
|
|
43
|
+
action?: 'edit'
|
|
44
|
+
intentId?: string
|
|
45
|
+
documentHandle: DocumentHandleWithSource
|
|
46
|
+
parameters?: Record<string, unknown>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @beta
|
|
51
|
+
*
|
|
52
|
+
* A hook for dispatching intent messages to the Dashboard with a document handle.
|
|
53
|
+
* This allows applications to signal their intent to pass the referenced document to other applications that have registered the ability to perform specific actions on that document.
|
|
54
|
+
*
|
|
55
|
+
* @param params - Object containing:
|
|
56
|
+
* - `action` - Action to perform (currently only 'edit' is supported). Will prompt a picker if multiple handlers are available.
|
|
57
|
+
* - `intentId` - Specific ID of the intent to dispatch. Either `action` or `intentId` is required.
|
|
58
|
+
* - `documentHandle` - The document handle containing document ID, type, and either:
|
|
59
|
+
* - `projectId` and `dataset` for traditional dataset sources, like `{documentId: '123', documentType: 'book', projectId: 'abc123', dataset: 'production'}`
|
|
60
|
+
* - `source` for media library, canvas, or dataset sources, like `{documentId: '123', documentType: 'sanity.asset', source: mediaLibrarySource('ml123')}` or `{documentId: '123', documentType: 'sanity.canvas.document', source: canvasSource('canvas123')}`
|
|
61
|
+
* - `paremeters` - Optional parameters to include in the dispatch; will be passed to the resolved intent handler
|
|
62
|
+
* @returns An object containing:
|
|
63
|
+
* - `dispatchIntent` - Function to dispatch the intent message
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* import {useDispatchIntent} from '@sanity/sdk-react'
|
|
68
|
+
* import {Button} from '@sanity/ui'
|
|
69
|
+
* import {Suspense} from 'react'
|
|
70
|
+
*
|
|
71
|
+
* function DispatchIntentButton({documentId, documentType, projectId, dataset}) {
|
|
72
|
+
* const {dispatchIntent} = useDispatchIntent({
|
|
73
|
+
* action: 'edit',
|
|
74
|
+
* documentHandle: {documentId, documentType, projectId, dataset},
|
|
75
|
+
* })
|
|
76
|
+
*
|
|
77
|
+
* return (
|
|
78
|
+
* <Button
|
|
79
|
+
* onClick={() => dispatchIntent()}
|
|
80
|
+
* text="Dispatch Intent"
|
|
81
|
+
* />
|
|
82
|
+
* )
|
|
83
|
+
* }
|
|
84
|
+
*
|
|
85
|
+
* // Wrap the component with Suspense since the hook may suspend
|
|
86
|
+
* function MyDocumentAction({documentId, documentType, projectId, dataset}) {
|
|
87
|
+
* return (
|
|
88
|
+
* <Suspense fallback={<Button text="Loading..." disabled />}>
|
|
89
|
+
* <DispatchIntentButton
|
|
90
|
+
* documentId={documentId}
|
|
91
|
+
* documentType={documentType}
|
|
92
|
+
* projectId={projectId}
|
|
93
|
+
* dataset={dataset}
|
|
94
|
+
* />
|
|
95
|
+
* </Suspense>
|
|
96
|
+
* )
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function useDispatchIntent(params: UseDispatchIntentParams): DispatchIntent {
|
|
101
|
+
const {action, intentId, documentHandle, parameters} = params
|
|
102
|
+
const {sendMessage} = useWindowConnection<IntentMessage, FrameMessage>({
|
|
103
|
+
name: SDK_NODE_NAME,
|
|
104
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const dispatchIntent = useCallback(() => {
|
|
108
|
+
try {
|
|
109
|
+
if (!action && !intentId) {
|
|
110
|
+
throw new Error('useDispatchIntent: Either `action` or `intentId` must be provided.')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const {projectId, dataset, source} = documentHandle
|
|
114
|
+
|
|
115
|
+
if (action && intentId) {
|
|
116
|
+
// eslint-disable-next-line no-console -- warn if both action and intentId are provided
|
|
117
|
+
console.warn(
|
|
118
|
+
'useDispatchIntent: Both `action` and `intentId` were provided. Using `intentId` and ignoring `action`.',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!source && (!projectId || !dataset)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resource = getResourceIdFromDocumentHandle(documentHandle)
|
|
129
|
+
|
|
130
|
+
const message: IntentMessage = {
|
|
131
|
+
type: 'dashboard/v1/events/intents/dispatch-intent',
|
|
132
|
+
data: {
|
|
133
|
+
...(action && !intentId ? {action} : {}),
|
|
134
|
+
...(intentId ? {intentId} : {}),
|
|
135
|
+
document: {
|
|
136
|
+
id: documentHandle.documentId,
|
|
137
|
+
type: documentHandle.documentType,
|
|
138
|
+
},
|
|
139
|
+
resource: {
|
|
140
|
+
id: resource.id,
|
|
141
|
+
...(resource.type ? {type: resource.type} : {}),
|
|
142
|
+
},
|
|
143
|
+
...(parameters && Object.keys(parameters).length > 0 ? {parameters} : {}),
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
sendMessage(message.type, message.data)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// eslint-disable-next-line no-console
|
|
150
|
+
console.error('Failed to dispatch intent:', error)
|
|
151
|
+
throw error
|
|
152
|
+
}
|
|
153
|
+
}, [action, intentId, documentHandle, parameters, sendMessage])
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
dispatchIntent,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canvasSource,
|
|
3
|
+
datasetSource,
|
|
4
|
+
type DocumentHandle,
|
|
5
|
+
type DocumentSource,
|
|
6
|
+
mediaLibrarySource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {describe, expect, it} from 'vitest'
|
|
9
|
+
|
|
10
|
+
import {type DocumentHandleWithSource} from '../types'
|
|
11
|
+
import {getResourceIdFromDocumentHandle} from './getResourceIdFromDocumentHandle'
|
|
12
|
+
|
|
13
|
+
describe('getResourceIdFromDocumentHandle', () => {
|
|
14
|
+
describe('with traditional DocumentHandle (projectId/dataset)', () => {
|
|
15
|
+
it('should return resource ID from projectId and dataset', () => {
|
|
16
|
+
const documentHandle: DocumentHandle = {
|
|
17
|
+
documentId: 'test-document-id',
|
|
18
|
+
documentType: 'test-document-type',
|
|
19
|
+
projectId: 'test-project-id',
|
|
20
|
+
dataset: 'test-dataset',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
id: 'test-project-id.test-dataset',
|
|
27
|
+
type: undefined,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('with DocumentHandleWithSource - media library', () => {
|
|
33
|
+
it('should return media library ID and resourceType when media library source is provided', () => {
|
|
34
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
35
|
+
documentId: 'test-asset-id',
|
|
36
|
+
documentType: 'sanity.asset',
|
|
37
|
+
source: mediaLibrarySource('mlPGY7BEqt52'),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
id: 'mlPGY7BEqt52',
|
|
44
|
+
type: 'media-library',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should prioritize source over projectId/dataset when both are provided', () => {
|
|
49
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
50
|
+
documentId: 'test-asset-id',
|
|
51
|
+
documentType: 'sanity.asset',
|
|
52
|
+
projectId: 'test-project-id',
|
|
53
|
+
dataset: 'test-dataset',
|
|
54
|
+
source: mediaLibrarySource('mlPGY7BEqt52'),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual({
|
|
60
|
+
id: 'mlPGY7BEqt52',
|
|
61
|
+
type: 'media-library',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('with DocumentHandleWithSource - canvas', () => {
|
|
67
|
+
it('should return canvas ID and resourceType when canvas source is provided', () => {
|
|
68
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
69
|
+
documentId: 'test-canvas-document-id',
|
|
70
|
+
documentType: 'sanity.canvas.document',
|
|
71
|
+
source: canvasSource('canvas123'),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
id: 'canvas123',
|
|
78
|
+
type: 'canvas',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('with DocumentHandleWithSource - dataset source', () => {
|
|
84
|
+
it('should return dataset resource ID when dataset source is provided', () => {
|
|
85
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
86
|
+
documentId: 'test-document-id',
|
|
87
|
+
documentType: 'test-document-type',
|
|
88
|
+
source: datasetSource('source-project-id', 'source-dataset'),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
id: 'source-project-id.source-dataset',
|
|
95
|
+
type: undefined,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should use dataset source over projectId/dataset when both are provided', () => {
|
|
100
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
101
|
+
documentId: 'test-document-id',
|
|
102
|
+
documentType: 'test-document-type',
|
|
103
|
+
projectId: 'test-project-id',
|
|
104
|
+
dataset: 'test-dataset',
|
|
105
|
+
source: datasetSource('source-project-id', 'source-dataset'),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual({
|
|
111
|
+
id: 'source-project-id.source-dataset',
|
|
112
|
+
type: undefined,
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('edge cases', () => {
|
|
118
|
+
it('should handle DocumentHandleWithSource with undefined source', () => {
|
|
119
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
120
|
+
documentId: 'test-document-id',
|
|
121
|
+
documentType: 'test-document-type',
|
|
122
|
+
projectId: 'test-project-id',
|
|
123
|
+
dataset: 'test-dataset',
|
|
124
|
+
source: undefined,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
128
|
+
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
id: 'test-project-id.test-dataset',
|
|
131
|
+
type: undefined,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should fall back to projectId/dataset when source is not recognized', () => {
|
|
136
|
+
const documentHandle: DocumentHandleWithSource = {
|
|
137
|
+
documentId: 'test-document-id',
|
|
138
|
+
documentType: 'test-document-type',
|
|
139
|
+
projectId: 'test-project-id',
|
|
140
|
+
dataset: 'test-dataset',
|
|
141
|
+
source: {
|
|
142
|
+
__sanity_internal_sourceId: 'unknown-format',
|
|
143
|
+
} as unknown as DocumentSource,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = getResourceIdFromDocumentHandle(documentHandle)
|
|
147
|
+
|
|
148
|
+
// Falls back to projectId.dataset when source format is not recognized
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
id: 'test-project-id.test-dataset',
|
|
151
|
+
type: undefined,
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {type DocumentHandle, type DocumentSource} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {type DocumentHandleWithSource} from '../types'
|
|
4
|
+
|
|
5
|
+
// Internal constant for accessing source ID
|
|
6
|
+
const SOURCE_ID = '__sanity_internal_sourceId' as const
|
|
7
|
+
|
|
8
|
+
interface DashboardMessageResource {
|
|
9
|
+
id: string
|
|
10
|
+
type?: 'media-library' | 'canvas'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isDocumentHandleWithSource = (
|
|
14
|
+
documentHandle: DocumentHandle | DocumentHandleWithSource,
|
|
15
|
+
): documentHandle is DocumentHandleWithSource => {
|
|
16
|
+
return 'source' in documentHandle
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Currently only used for dispatching intents to the dashboard,
|
|
20
|
+
* but could easily be extended to other dashboard hooks
|
|
21
|
+
* @beta
|
|
22
|
+
*/
|
|
23
|
+
export function getResourceIdFromDocumentHandle(
|
|
24
|
+
documentHandle: DocumentHandle | DocumentHandleWithSource,
|
|
25
|
+
): DashboardMessageResource {
|
|
26
|
+
let source: DocumentSource | undefined
|
|
27
|
+
|
|
28
|
+
const {projectId, dataset} = documentHandle
|
|
29
|
+
if (isDocumentHandleWithSource(documentHandle)) {
|
|
30
|
+
source = documentHandle.source
|
|
31
|
+
}
|
|
32
|
+
let resourceId: string = projectId + '.' + dataset
|
|
33
|
+
let resourceType: 'media-library' | 'canvas' | undefined
|
|
34
|
+
|
|
35
|
+
if (source) {
|
|
36
|
+
const sourceId = (source as Record<string, unknown>)[SOURCE_ID]
|
|
37
|
+
if (Array.isArray(sourceId)) {
|
|
38
|
+
if (sourceId[0] === 'media-library' || sourceId[0] === 'canvas') {
|
|
39
|
+
resourceType = sourceId[0] as 'media-library' | 'canvas'
|
|
40
|
+
resourceId = sourceId[1] as string
|
|
41
|
+
}
|
|
42
|
+
} else if (sourceId && typeof sourceId === 'object' && 'projectId' in sourceId) {
|
|
43
|
+
const datasetSource = sourceId as {projectId: string; dataset: string}
|
|
44
|
+
// don't create type since it's ambiguous for project / dataset docs
|
|
45
|
+
resourceId = `${datasetSource.projectId}.${datasetSource.dataset}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
id: resourceId,
|
|
51
|
+
type: resourceType,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -118,6 +118,37 @@ interface UseApplyDocumentActions {
|
|
|
118
118
|
* )
|
|
119
119
|
* }
|
|
120
120
|
* ```
|
|
121
|
+
*
|
|
122
|
+
* @example Create a document with initial field values
|
|
123
|
+
* ```tsx
|
|
124
|
+
* import {
|
|
125
|
+
* createDocument,
|
|
126
|
+
* createDocumentHandle,
|
|
127
|
+
* useApplyDocumentActions
|
|
128
|
+
* } from '@sanity/sdk-react'
|
|
129
|
+
*
|
|
130
|
+
* function CreateArticleButton() {
|
|
131
|
+
* const apply = useApplyDocumentActions()
|
|
132
|
+
*
|
|
133
|
+
* const handleCreateArticle = () => {
|
|
134
|
+
* const newDocHandle = createDocumentHandle({
|
|
135
|
+
* documentId: crypto.randomUUID(),
|
|
136
|
+
* documentType: 'article'
|
|
137
|
+
* })
|
|
138
|
+
*
|
|
139
|
+
* // Create document with initial values in one action
|
|
140
|
+
* apply(
|
|
141
|
+
* createDocument(newDocHandle, {
|
|
142
|
+
* title: 'New Article',
|
|
143
|
+
* author: 'John Doe',
|
|
144
|
+
* publishedAt: new Date().toISOString(),
|
|
145
|
+
* })
|
|
146
|
+
* )
|
|
147
|
+
* }
|
|
148
|
+
*
|
|
149
|
+
* return <button onClick={handleCreateArticle}>Create Article</button>
|
|
150
|
+
* }
|
|
151
|
+
* ```
|
|
121
152
|
*/
|
|
122
153
|
export const useApplyDocumentActions = createCallbackHook(
|
|
123
154
|
applyDocumentActions,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
|
|
2
2
|
import {type SanityProjectionResult} from 'groq'
|
|
3
|
-
import {useCallback, useSyncExternalStore} from 'react'
|
|
3
|
+
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
4
4
|
import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
|
|
5
5
|
|
|
6
6
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
@@ -177,10 +177,21 @@ export function useDocumentProjection<TData extends object>({
|
|
|
177
177
|
...docHandle
|
|
178
178
|
}: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
|
|
179
179
|
const instance = useSanityInstance(docHandle)
|
|
180
|
-
|
|
180
|
+
|
|
181
|
+
// Normalize projection string to handle template literals with whitespace
|
|
182
|
+
// This ensures that the same projection content produces the same state source
|
|
183
|
+
// even if the string reference changes (e.g., from inline template literals)
|
|
184
|
+
const normalizedProjection = useMemo(() => projection.trim(), [projection])
|
|
185
|
+
|
|
186
|
+
// Memoize stateSource based on normalized projection and docHandle properties
|
|
187
|
+
// This prevents creating a new StateSource on every render when projection content is the same
|
|
188
|
+
const stateSource = useMemo(
|
|
189
|
+
() => getProjectionState<TData>(instance, {...docHandle, projection: normalizedProjection}),
|
|
190
|
+
[instance, normalizedProjection, docHandle],
|
|
191
|
+
)
|
|
181
192
|
|
|
182
193
|
if (stateSource.getCurrent()?.data === null) {
|
|
183
|
-
throw resolveProjection(instance, {...docHandle, projection})
|
|
194
|
+
throw resolveProjection(instance, {...docHandle, projection: normalizedProjection})
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
// Create subscribe function for useSyncExternalStore
|
|
@@ -62,7 +62,7 @@ export interface UsersResult {
|
|
|
62
62
|
* <address>{user.profile.email}</address>
|
|
63
63
|
* </figure>
|
|
64
64
|
* ))}
|
|
65
|
-
* {hasMore && <button onClick={loadMore}>{isPending ? 'Loading...' : 'Load More'</button>}
|
|
65
|
+
* {hasMore && <button onClick={loadMore}>{isPending ? 'Loading...' : 'Load More'}</button>}
|
|
66
66
|
* </div>
|
|
67
67
|
* )
|
|
68
68
|
* ```
|