Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/kitchensink-react/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {AgentActionsRoute} from './routes/AgentActionsRoute'
import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
import {IntentsRoute} from './routes/IntentsRoute'
import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
import {PerspectivesRoute} from './routes/PerspectivesRoute'
import {ProjectsRoute} from './routes/ProjectsRoute'
Expand Down Expand Up @@ -79,6 +80,10 @@ const documentCollectionRoutes = [
path: 'media-library',
element: <MediaLibraryRoute />,
},
{
path: 'intents',
element: <IntentsRoute />,
},
]

const dashboardInteractionRoutes = [
Expand Down
142 changes: 142 additions & 0 deletions apps/kitchensink-react/src/routes/IntentsRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {mediaLibrarySource, SanityDocument, useDispatchIntent, useQuery} from '@sanity/sdk-react'
import {Button, Card, Spinner, Text} from '@sanity/ui'
import {type JSX, Suspense} from 'react'

// Hardcoded for demo - should be inferred from org later on
const MEDIA_LIBRARY_ID = 'mlPGY7BEqt52'
const MEDIA = mediaLibrarySource(MEDIA_LIBRARY_ID)
const PROJECT_ID = 'ppsg7ml5'
const DATASET = 'test'

function DatasetDocumentIntent({document}: {document: SanityDocument}): JSX.Element {
const {dispatchIntent} = useDispatchIntent({
action: 'edit',
documentHandle: {
documentId: document._id,
documentType: document._type,
projectId: PROJECT_ID,
dataset: DATASET,
},
})

return (
<Button
text="Dispatch Intent for Dataset Document"
tone="primary"
onClick={() => dispatchIntent()}
/>
)
}

function MediaLibraryAssetIntent({asset}: {asset: {_id: string; _type: string}}): JSX.Element {
const {dispatchIntent} = useDispatchIntent({
action: 'edit',
documentHandle: {
documentId: asset._id,
documentType: asset._type,
source: MEDIA,
},
})

return (
<Button
text="Dispatch Intent for Media Library Asset"
tone="primary"
onClick={() => dispatchIntent()}
/>
)
}

function IntentsContent(): JSX.Element {
// Fetch first document from project/dataset
const {data: firstDocument, isPending: isDocumentPending} = useQuery<SanityDocument>({
query: '*[_type == "book"][0]',
projectId: PROJECT_ID,
dataset: DATASET,
})

// Fetch first asset from media library
const {data: firstAsset, isPending: isAssetPending} = useQuery<SanityDocument>({
query: '*[_type == "sanity.asset"][0]',
source: MEDIA,
})

const isLoading = isDocumentPending || isAssetPending

return (
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
<Text size={4} weight="bold" style={{marginBottom: '2rem', color: 'white'}}>
Intent Dispatch Demo
</Text>

<Text size={2} style={{marginBottom: '2rem'}}>
This route demonstrates dispatching intents for documents from both a traditional dataset
and a media library source.
</Text>

{isLoading && (
<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
<Spinner />
<Text>Loading documents...</Text>
</div>
</Card>
)}

<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
<Text size={2} weight="bold" style={{marginBottom: '1rem', color: 'white'}}>
Dataset Document Intent
</Text>
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
Project: {PROJECT_ID} | Dataset: {DATASET}
</Text>
<div>
<Text size={1} style={{marginBottom: '0.5rem', color: '#ccc'}}>
Document ID: <code>{firstDocument?._id}</code>
</Text>
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
Document Type: <code>{firstDocument?._type}</code>
</Text>
<DatasetDocumentIntent document={firstDocument} />
</div>
</Card>

<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
<Text size={2} weight="bold" style={{marginBottom: '1rem', color: 'white'}}>
Media Library Asset Intent
</Text>
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
Media Library ID: {MEDIA_LIBRARY_ID}
</Text>
<div>
<Text size={1} style={{marginBottom: '0.5rem', color: '#ccc'}}>
Asset ID: <code>{firstAsset?._id}</code>
</Text>
<Text size={1} style={{marginBottom: '1rem', color: '#ccc'}}>
Asset Type: <code>{firstAsset?._type}</code>
</Text>
<MediaLibraryAssetIntent asset={firstAsset} />
</div>
</Card>
</div>
)
}

export function IntentsRoute(): JSX.Element {
return (
<Suspense
fallback={
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
<Spinner />
<Text>Loading...</Text>
</div>
</Card>
</div>
}
>
<IntentsContent />
</Suspense>
)
}
1 change: 1 addition & 0 deletions packages/react/src/_exports/sdk-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export {
} from '../hooks/comlink/useWindowConnection'
export {useSanityInstance} from '../hooks/context/useSanityInstance'
export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
export {useDispatchIntent} from '../hooks/dashboard/useDispatchIntent'
export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
export {
type NavigateToStudioResult,
Expand Down
12 changes: 12 additions & 0 deletions packages/react/src/hooks/dashboard/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {type DocumentHandle, type DocumentSource} from '@sanity/sdk'
/**
* Document handle that optionally includes a source (e.g., media library source)
* or projectId and dataset for traditional dataset sources
* (but now marked optional since it's valid to just use a source)
* @beta
*/
export interface DocumentHandleWithSource extends Omit<DocumentHandle, 'projectId' | 'dataset'> {
source?: DocumentSource
projectId?: string
dataset?: string
}
213 changes: 213 additions & 0 deletions packages/react/src/hooks/dashboard/useDispatchIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {type DocumentHandle, mediaLibrarySource} from '@sanity/sdk'
import {renderHook} from '@testing-library/react'
import {beforeEach, describe, expect, it, vi} from 'vitest'

import {type DocumentHandleWithSource} from './types'
import {useDispatchIntent} from './useDispatchIntent'

// Mock the useWindowConnection hook
const mockSendMessage = vi.fn()
vi.mock('../comlink/useWindowConnection', () => ({
useWindowConnection: vi.fn(() => ({
sendMessage: mockSendMessage,
})),
}))

describe('useDispatchIntent', () => {
const mockDocumentHandle: DocumentHandle = {
documentId: 'test-document-id',
documentType: 'test-document-type',
projectId: 'test-project-id',
dataset: 'test-dataset',
}

beforeEach(() => {
vi.clearAllMocks()
// Reset mock implementation to default behavior
mockSendMessage.mockImplementation(() => {})
})

it('should return dispatchIntent function', () => {
const {result} = renderHook(() =>
useDispatchIntent({action: 'edit', documentHandle: mockDocumentHandle}),
)

expect(result.current).toEqual({
dispatchIntent: expect.any(Function),
})
})

it('should throw error when neither action nor intentId is provided', () => {
const {result} = renderHook(() =>
// @ts-expect-error - Testing runtime error when neither is provided
useDispatchIntent({documentHandle: mockDocumentHandle}),
)

expect(() => result.current.dispatchIntent()).toThrow(
'useDispatchIntent: Either `action` or `intentId` must be provided.',
)
})

it('should handle errors gracefully', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSendMessage.mockImplementation(() => {
throw new Error('Test error')
})

const {result} = renderHook(() =>
useDispatchIntent({action: 'edit', documentHandle: mockDocumentHandle}),
)

expect(() => result.current.dispatchIntent()).toThrow('Test error')
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to dispatch intent:', expect.any(Error))

consoleErrorSpy.mockRestore()
})

it('should use memoized dispatchIntent function', () => {
const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
})

const firstDispatchIntent = result.current.dispatchIntent

// Rerender with the same params
rerender({params: {action: 'edit' as const, documentHandle: mockDocumentHandle}})

expect(result.current.dispatchIntent).toBe(firstDispatchIntent)
})

it('should create new dispatchIntent function when documentHandle changes', () => {
const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
})

const firstDispatchIntent = result.current.dispatchIntent

const newDocumentHandle: DocumentHandle = {
documentId: 'new-document-id',
documentType: 'new-document-type',
projectId: 'new-project-id',
dataset: 'new-dataset',
}

rerender({params: {action: 'edit' as const, documentHandle: newDocumentHandle}})

expect(result.current.dispatchIntent).not.toBe(firstDispatchIntent)
})

it('should warn if both action and intentId are provided', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const {result} = renderHook(() =>
useDispatchIntent({
action: 'edit' as const,
intentId: 'custom-intent' as never, // test runtime error when both are provided
documentHandle: mockDocumentHandle,
}),
)
result.current.dispatchIntent()
expect(consoleWarnSpy).toHaveBeenCalledWith(
'useDispatchIntent: Both `action` and `intentId` were provided. Using `intentId` and ignoring `action`.',
)
consoleWarnSpy.mockRestore()
})

it('should send intent message with action and params when both are provided', () => {
const intentParams = {view: 'editor', tab: 'content'}
const {result} = renderHook(() =>
useDispatchIntent({
action: 'edit',
documentHandle: mockDocumentHandle,
parameters: intentParams,
}),
)

result.current.dispatchIntent()

expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
action: 'edit',
document: {
id: 'test-document-id',
type: 'test-document-type',
},
resource: {
id: 'test-project-id.test-dataset',
},
parameters: intentParams,
})
})

it('should send intent message with intentId and params when both are provided', () => {
const intentParams = {view: 'editor', tab: 'content'}
const {result} = renderHook(() =>
useDispatchIntent({
intentId: 'custom-intent',
documentHandle: mockDocumentHandle,
parameters: intentParams,
}),
)

result.current.dispatchIntent()

expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
intentId: 'custom-intent',
document: {
id: 'test-document-id',
type: 'test-document-type',
},
resource: {
id: 'test-project-id.test-dataset',
},
parameters: intentParams,
})
})

it('should send intent message with media library source', () => {
const mockMediaLibraryHandle: DocumentHandleWithSource = {
documentId: 'test-asset-id',
documentType: 'sanity.asset',
source: mediaLibrarySource('mlPGY7BEqt52'),
}

const {result} = renderHook(() =>
useDispatchIntent({
action: 'edit',
documentHandle: mockMediaLibraryHandle,
}),
)

result.current.dispatchIntent()

expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/intents/dispatch-intent', {
action: 'edit',
document: {
id: 'test-asset-id',
type: 'sanity.asset',
},
resource: {
id: 'mlPGY7BEqt52',
type: 'mediaLibrary',
},
})
})

describe('error handling', () => {
it('should throw error when neither source nor projectId/dataset is provided', () => {
const invalidHandle = {
documentId: 'test-document-id',
documentType: 'test-document-type',
}

const {result} = renderHook(() =>
useDispatchIntent({
action: 'edit',
documentHandle: invalidHandle as unknown as DocumentHandleWithSource,
}),
)

expect(() => result.current.dispatchIntent()).toThrow(
'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
)
})
})
})
Loading
Loading