Skip to content

Commit 3d5f45c

Browse files
feat(react): create useDispatchIntent hook (#595)
* feat(react): create useSendIntent hook * refactor: rename to useDispatch intent and enforce intentId / action behavior * feat: add media library source for intents and kitchensink route * Update packages/react/src/hooks/dashboard/useDispatchIntent.ts Co-authored-by: Cole Peters <cole@colepeters.com> * Update packages/react/src/hooks/dashboard/useDispatchIntent.ts Co-authored-by: Cole Peters <cole@colepeters.com> * refactor: rename params option --------- Co-authored-by: Cole Peters <cole@colepeters.com>
1 parent 92340b9 commit 3d5f45c

File tree

8 files changed

+729
-0
lines changed

8 files changed

+729
-0
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {AgentActionsRoute} from './routes/AgentActionsRoute'
1919
import {DashboardContextRoute} from './routes/DashboardContextRoute'
2020
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
2121
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
22+
import {IntentsRoute} from './routes/IntentsRoute'
2223
import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
2324
import {PerspectivesRoute} from './routes/PerspectivesRoute'
2425
import {ProjectsRoute} from './routes/ProjectsRoute'
@@ -79,6 +80,10 @@ const documentCollectionRoutes = [
7980
path: 'media-library',
8081
element: <MediaLibraryRoute />,
8182
},
83+
{
84+
path: 'intents',
85+
element: <IntentsRoute />,
86+
},
8287
]
8388

8489
const dashboardInteractionRoutes = [
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {mediaLibrarySource, SanityDocument, useDispatchIntent, useQuery} from '@sanity/sdk-react'
2+
import {Button, Card, Spinner, Text} from '@sanity/ui'
3+
import {type JSX, Suspense} from 'react'
4+
5+
// Hardcoded for demo - should be inferred from org later on
6+
const MEDIA_LIBRARY_ID = 'mlPGY7BEqt52'
7+
const MEDIA = mediaLibrarySource(MEDIA_LIBRARY_ID)
8+
const PROJECT_ID = 'ppsg7ml5'
9+
const DATASET = 'test'
10+
11+
function DatasetDocumentIntent({document}: {document: SanityDocument}): JSX.Element {
12+
const {dispatchIntent} = useDispatchIntent({
13+
action: 'edit',
14+
documentHandle: {
15+
documentId: document._id,
16+
documentType: document._type,
17+
projectId: PROJECT_ID,
18+
dataset: DATASET,
19+
},
20+
})
21+
22+
return (
23+
<Button
24+
text="Dispatch Intent for Dataset Document"
25+
tone="primary"
26+
onClick={() => dispatchIntent()}
27+
/>
28+
)
29+
}
30+
31+
function MediaLibraryAssetIntent({asset}: {asset: {_id: string; _type: string}}): JSX.Element {
32+
const {dispatchIntent} = useDispatchIntent({
33+
action: 'edit',
34+
documentHandle: {
35+
documentId: asset._id,
36+
documentType: asset._type,
37+
source: MEDIA,
38+
},
39+
})
40+
41+
return (
42+
<Button
43+
text="Dispatch Intent for Media Library Asset"
44+
tone="primary"
45+
onClick={() => dispatchIntent()}
46+
/>
47+
)
48+
}
49+
50+
function IntentsContent(): JSX.Element {
51+
// Fetch first document from project/dataset
52+
const {data: firstDocument, isPending: isDocumentPending} = useQuery<SanityDocument>({
53+
query: '*[_type == "book"][0]',
54+
projectId: PROJECT_ID,
55+
dataset: DATASET,
56+
})
57+
58+
// Fetch first asset from media library
59+
const {data: firstAsset, isPending: isAssetPending} = useQuery<SanityDocument>({
60+
query: '*[_type == "sanity.asset"][0]',
61+
source: MEDIA,
62+
})
63+
64+
const isLoading = isDocumentPending || isAssetPending
65+
66+
return (
67+
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
68+
<Text size={4} weight="bold" style={{marginBottom: '2rem', color: 'white'}}>
69+
Intent Dispatch Demo
70+
</Text>
71+
72+
<Text size={2} style={{marginBottom: '2rem'}}>
73+
This route demonstrates dispatching intents for documents from both a traditional dataset
74+
and a media library source.
75+
</Text>
76+
77+
{isLoading && (
78+
<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
79+
<div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
80+
<Spinner />
81+
<Text>Loading documents...</Text>
82+
</div>
83+
</Card>
84+
)}
85+
86+
<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
87+
<Text size={2} weight="bold" style={{marginBottom: '1rem', color: 'white'}}>
88+
Dataset Document Intent
89+
</Text>
90+
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
91+
Project: {PROJECT_ID} | Dataset: {DATASET}
92+
</Text>
93+
<div>
94+
<Text size={1} style={{marginBottom: '0.5rem', color: '#ccc'}}>
95+
Document ID: <code>{firstDocument?._id}</code>
96+
</Text>
97+
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
98+
Document Type: <code>{firstDocument?._type}</code>
99+
</Text>
100+
<DatasetDocumentIntent document={firstDocument} />
101+
</div>
102+
</Card>
103+
104+
<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
105+
<Text size={2} weight="bold" style={{marginBottom: '1rem', color: 'white'}}>
106+
Media Library Asset Intent
107+
</Text>
108+
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
109+
Media Library ID: {MEDIA_LIBRARY_ID}
110+
</Text>
111+
<div>
112+
<Text size={1} style={{marginBottom: '0.5rem', color: '#ccc'}}>
113+
Asset ID: <code>{firstAsset?._id}</code>
114+
</Text>
115+
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
116+
Asset Type: <code>{firstAsset?._type}</code>
117+
</Text>
118+
<MediaLibraryAssetIntent asset={firstAsset} />
119+
</div>
120+
</Card>
121+
</div>
122+
)
123+
}
124+
125+
export function IntentsRoute(): JSX.Element {
126+
return (
127+
<Suspense
128+
fallback={
129+
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
130+
<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
131+
<div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
132+
<Spinner />
133+
<Text>Loading...</Text>
134+
</div>
135+
</Card>
136+
</div>
137+
}
138+
>
139+
<IntentsContent />
140+
</Suspense>
141+
)
142+
}

packages/react/src/_exports/sdk-react.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
} from '../hooks/comlink/useWindowConnection'
3737
export {useSanityInstance} from '../hooks/context/useSanityInstance'
3838
export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
39+
export {useDispatchIntent} from '../hooks/dashboard/useDispatchIntent'
3940
export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
4041
export {
4142
type NavigateToStudioResult,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {type DocumentHandle, type DocumentSource} from '@sanity/sdk'
2+
/**
3+
* Document handle that optionally includes a source (e.g., media library source)
4+
* or projectId and dataset for traditional dataset sources
5+
* (but now marked optional since it's valid to just use a source)
6+
* @beta
7+
*/
8+
export interface DocumentHandleWithSource extends Omit<DocumentHandle, 'projectId' | 'dataset'> {
9+
source?: DocumentSource
10+
projectId?: string
11+
dataset?: string
12+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import {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: 'mediaLibrary',
190+
},
191+
})
192+
})
193+
194+
describe('error handling', () => {
195+
it('should throw error when neither source nor projectId/dataset is provided', () => {
196+
const invalidHandle = {
197+
documentId: 'test-document-id',
198+
documentType: 'test-document-type',
199+
}
200+
201+
const {result} = renderHook(() =>
202+
useDispatchIntent({
203+
action: 'edit',
204+
documentHandle: invalidHandle as unknown as DocumentHandleWithSource,
205+
}),
206+
)
207+
208+
expect(() => result.current.dispatchIntent()).toThrow(
209+
'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
210+
)
211+
})
212+
})
213+
})

0 commit comments

Comments
 (0)