@sanity/sdk-react 2.8.0 → 2.10.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 +232 -47
- package/dist/index.js +468 -263
- package/dist/index.js.map +1 -1
- package/package.json +8 -10
- package/src/_exports/sdk-react.ts +5 -0
- package/src/components/SDKProvider.tsx +36 -8
- package/src/components/SanityApp.tsx +3 -2
- package/src/components/auth/AuthBoundary.tsx +8 -1
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +100 -56
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/context/ResourceProvider.test.tsx +7 -1
- package/src/context/ResourceProvider.tsx +11 -4
- package/src/context/ResourcesContext.tsx +7 -0
- package/src/context/SDKStudioContext.ts +6 -0
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/dashboard/useDispatchIntent.test.ts +8 -6
- package/src/hooks/dashboard/useDispatchIntent.ts +6 -6
- package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
- package/src/hooks/dashboard/useWindowTitle.ts +112 -0
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -13
- package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
- package/src/hooks/document/useApplyDocumentActions.ts +99 -3
- package/src/hooks/document/useDocument.ts +22 -6
- package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
- package/src/hooks/document/useDocumentEvent.ts +10 -3
- package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
- package/src/hooks/document/useDocumentPermissions.ts +22 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
- package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
- package/src/hooks/document/useEditDocument.ts +34 -8
- package/src/hooks/documents/useDocuments.ts +11 -6
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +131 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +11 -8
- package/src/hooks/presence/usePresence.test.tsx +56 -9
- package/src/hooks/presence/usePresence.ts +25 -4
- package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
- package/src/hooks/preview/useDocumentPreview.tsx +40 -55
- package/src/hooks/projection/useDocumentProjection.ts +8 -6
- package/src/hooks/query/useQuery.ts +12 -9
- package/src/hooks/releases/useActiveReleases.ts +32 -13
- package/src/hooks/releases/usePerspective.ts +26 -14
- package/src/hooks/users/useUser.ts +2 -0
- package/src/hooks/users/useUsers.ts +2 -0
- package/src/context/SourcesContext.tsx +0 -7
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {render, screen} from '../../../test/test-utils'
|
|
4
|
+
import {ChunkLoadError} from './ChunkLoadError'
|
|
5
|
+
import {CHUNK_RELOAD_STORAGE_KEY} from './chunkReloadStorage'
|
|
6
|
+
|
|
7
|
+
const noop = (): void => {}
|
|
8
|
+
|
|
9
|
+
describe('ChunkLoadError', () => {
|
|
10
|
+
const reloadSpy = vi.fn()
|
|
11
|
+
const originalLocation = window.location
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
reloadSpy.mockReset()
|
|
15
|
+
window.sessionStorage.clear()
|
|
16
|
+
Object.defineProperty(window, 'location', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
value: {...originalLocation, reload: reloadSpy},
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
Object.defineProperty(window, 'location', {configurable: true, value: originalLocation})
|
|
24
|
+
window.sessionStorage.clear()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('triggers an automatic reload and renders nothing on the first occurrence', () => {
|
|
28
|
+
render(
|
|
29
|
+
<ChunkLoadError
|
|
30
|
+
error={new Error('Failed to fetch dynamically imported module')}
|
|
31
|
+
resetErrorBoundary={noop}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1')
|
|
37
|
+
expect(screen.queryByText(/new version/i)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the manual reload UI when the flag is already set', () => {
|
|
41
|
+
window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
|
|
42
|
+
|
|
43
|
+
render(
|
|
44
|
+
<ChunkLoadError
|
|
45
|
+
error={new Error('Failed to fetch dynamically imported module')}
|
|
46
|
+
resetErrorBoundary={noop}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(reloadSpy).not.toHaveBeenCalled()
|
|
51
|
+
expect(screen.getByText('A new version is available')).toBeInTheDocument()
|
|
52
|
+
|
|
53
|
+
const button = screen.getByRole('button', {name: 'Reload page'})
|
|
54
|
+
button.click()
|
|
55
|
+
|
|
56
|
+
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
|
57
|
+
expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {useEffect} from 'react'
|
|
2
|
+
import {type FallbackProps} from 'react-error-boundary'
|
|
3
|
+
|
|
4
|
+
import {clearChunkReloadFlag, readChunkReloadFlag, setChunkReloadFlag} from './chunkReloadStorage'
|
|
5
|
+
import {Error} from './Error'
|
|
6
|
+
|
|
7
|
+
function reload(): void {
|
|
8
|
+
try {
|
|
9
|
+
window.location.reload()
|
|
10
|
+
} catch {
|
|
11
|
+
// No-op: nothing useful we can do if reload itself throws.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default fallback rendered when a dynamic-import or chunk-load error
|
|
17
|
+
* bubbles up to the SDK's top-level error boundary.
|
|
18
|
+
*
|
|
19
|
+
* On the first occurrence in a session we set a flag and trigger
|
|
20
|
+
* window.location.reload(), since chunk-load errors almost always indicate a
|
|
21
|
+
* stale tab that simply needs a fresh index.html. If the flag is already set
|
|
22
|
+
* we render a manual reload UI instead, which prevents an infinite reload
|
|
23
|
+
* loop in the rare case the error is genuinely unrecoverable (network
|
|
24
|
+
* outage, CSP, etc.).
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export function ChunkLoadError(_props: FallbackProps): React.ReactNode {
|
|
29
|
+
const alreadyAttempted = readChunkReloadFlag()
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (alreadyAttempted) return
|
|
33
|
+
setChunkReloadFlag()
|
|
34
|
+
reload()
|
|
35
|
+
}, [alreadyAttempted])
|
|
36
|
+
|
|
37
|
+
if (!alreadyAttempted) {
|
|
38
|
+
// Render nothing during the brief window before the page reloads so the
|
|
39
|
+
// user does not see a flash of error UI.
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Error
|
|
45
|
+
heading="A new version is available"
|
|
46
|
+
description="The page tried to load an asset that no longer exists. Reload to continue with the latest version."
|
|
47
|
+
cta={{
|
|
48
|
+
text: 'Reload page',
|
|
49
|
+
onClick: () => {
|
|
50
|
+
clearChunkReloadFlag()
|
|
51
|
+
reload()
|
|
52
|
+
},
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-storage key tracking whether the SDK has already attempted an
|
|
3
|
+
* automatic reload in response to a chunk-load error during this session.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export const CHUNK_RELOAD_STORAGE_KEY = '__sanity_sdk_chunk_reload_attempted'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns true when this session has already triggered an automatic reload.
|
|
11
|
+
* Returns false if session storage is unreadable.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function readChunkReloadFlag(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
return window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) !== null
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Marks the session as having attempted an automatic reload, so the next
|
|
28
|
+
* chunk-load error renders the manual reload UI instead of looping.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export function setChunkReloadFlag(): void {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
|
|
35
|
+
window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
|
|
36
|
+
} catch {
|
|
37
|
+
// Storage may be unavailable (private mode quotas, disabled cookies).
|
|
38
|
+
// Falling through means the user sees the manual-reload UI instead of an
|
|
39
|
+
// automatic reload, which is the correct degradation.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clears the chunk-reload flag. Called from SDKProvider once the SDK
|
|
45
|
+
* mounts successfully past the error boundary so a future incident in the
|
|
46
|
+
* same session can trigger another automatic reload.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export function clearChunkReloadFlag(): void {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
|
|
53
|
+
window.sessionStorage.removeItem(CHUNK_RELOAD_STORAGE_KEY)
|
|
54
|
+
} catch {
|
|
55
|
+
// No-op: see setChunkReloadFlag.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {act, render, screen} from '@testing-library/react'
|
|
3
3
|
import {StrictMode, use, useEffect} from 'react'
|
|
4
|
-
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {ResourceProvider} from './ResourceProvider'
|
|
7
7
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
@@ -37,6 +37,7 @@ describe('ResourceProvider', () => {
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('shows fallback during loading', async () => {
|
|
40
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
41
|
const {promise, resolve} = promiseWithResolvers()
|
|
41
42
|
function SuspendingChild(): React.ReactNode {
|
|
42
43
|
throw promise
|
|
@@ -52,6 +53,8 @@ describe('ResourceProvider', () => {
|
|
|
52
53
|
act(() => {
|
|
53
54
|
resolve()
|
|
54
55
|
})
|
|
56
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
57
|
+
consoleSpy.mockRestore()
|
|
55
58
|
})
|
|
56
59
|
|
|
57
60
|
it('creates root instance when no parent context exists', async () => {
|
|
@@ -141,6 +144,7 @@ describe('ResourceProvider', () => {
|
|
|
141
144
|
})
|
|
142
145
|
|
|
143
146
|
it('uses default fallback when none provided', async () => {
|
|
147
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
144
148
|
const {promise, resolve} = promiseWithResolvers()
|
|
145
149
|
function SuspendingChild(): React.ReactNode {
|
|
146
150
|
throw promise
|
|
@@ -157,5 +161,7 @@ describe('ResourceProvider', () => {
|
|
|
157
161
|
act(() => {
|
|
158
162
|
resolve()
|
|
159
163
|
})
|
|
164
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
165
|
+
consoleSpy.mockRestore()
|
|
160
166
|
})
|
|
161
167
|
})
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
2
|
-
import {
|
|
2
|
+
import {initTelemetry} from '@sanity/sdk/_internal'
|
|
3
|
+
import {useContext, useEffect, useMemo, useRef} from 'react'
|
|
3
4
|
|
|
4
5
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
6
|
+
import {SanityInstanceProvider} from './SanityInstanceProvider'
|
|
5
7
|
|
|
6
8
|
const DEFAULT_FALLBACK = (
|
|
7
9
|
<>
|
|
@@ -78,6 +80,11 @@ export function ResourceProvider({
|
|
|
78
80
|
[config, parent],
|
|
79
81
|
)
|
|
80
82
|
|
|
83
|
+
const projectId = config.projectId ?? ''
|
|
84
|
+
useMemo(() => {
|
|
85
|
+
if (projectId && !parent) initTelemetry(instance, projectId)
|
|
86
|
+
}, [instance, projectId, parent])
|
|
87
|
+
|
|
81
88
|
// Ref to hold the scheduled disposal timer.
|
|
82
89
|
const disposal = useRef<{
|
|
83
90
|
instance: SanityInstance
|
|
@@ -104,8 +111,8 @@ export function ResourceProvider({
|
|
|
104
111
|
}, [instance])
|
|
105
112
|
|
|
106
113
|
return (
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
</
|
|
114
|
+
<SanityInstanceProvider instance={instance} fallback={fallback ?? DEFAULT_FALLBACK}>
|
|
115
|
+
{children}
|
|
116
|
+
</SanityInstanceProvider>
|
|
110
117
|
)
|
|
111
118
|
}
|
|
@@ -13,6 +13,12 @@ export interface StudioWorkspaceHandle {
|
|
|
13
13
|
projectId: string
|
|
14
14
|
/** The dataset name for this workspace. */
|
|
15
15
|
dataset: string
|
|
16
|
+
/**
|
|
17
|
+
* Whether the Studio has determined the user is authenticated.
|
|
18
|
+
* When `true` and the token source emits `null`, the SDK infers
|
|
19
|
+
* cookie-based auth is in use and skips the logged-out state.
|
|
20
|
+
*/
|
|
21
|
+
authenticated?: boolean
|
|
16
22
|
/** Authentication state for this workspace. */
|
|
17
23
|
auth: {
|
|
18
24
|
/**
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {act, render, screen} from '@testing-library/react'
|
|
3
|
+
import {use, useEffect} from 'react'
|
|
4
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
7
|
+
import {SanityInstanceProvider} from './SanityInstanceProvider'
|
|
8
|
+
|
|
9
|
+
function promiseWithResolvers<T = void>(): {
|
|
10
|
+
promise: Promise<T>
|
|
11
|
+
resolve: (t: T) => void
|
|
12
|
+
reject: (error: unknown) => void
|
|
13
|
+
} {
|
|
14
|
+
let resolve!: (t: T) => void
|
|
15
|
+
let reject!: (error: unknown) => void
|
|
16
|
+
const promise = new Promise<T>((res, rej) => {
|
|
17
|
+
resolve = res
|
|
18
|
+
reject = rej
|
|
19
|
+
})
|
|
20
|
+
return {resolve, reject, promise}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('SanityInstanceProvider', () => {
|
|
24
|
+
it('renders children', () => {
|
|
25
|
+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
26
|
+
|
|
27
|
+
render(
|
|
28
|
+
<SanityInstanceProvider instance={instance} fallback={<div>Loading...</div>}>
|
|
29
|
+
<div data-testid="test-child">Child Component</div>
|
|
30
|
+
</SanityInstanceProvider>,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
expect(screen.getByTestId('test-child')).toBeInTheDocument()
|
|
34
|
+
instance.dispose()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('provides the given instance via context', async () => {
|
|
38
|
+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
39
|
+
const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
|
|
40
|
+
|
|
41
|
+
const CaptureInstance = () => {
|
|
42
|
+
const ctx = use(SanityInstanceContext)
|
|
43
|
+
useEffect(() => resolve(ctx), [ctx])
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
render(
|
|
48
|
+
<SanityInstanceProvider instance={instance} fallback={null}>
|
|
49
|
+
<CaptureInstance />
|
|
50
|
+
</SanityInstanceProvider>,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const provided = await promise
|
|
54
|
+
expect(provided).toBe(instance)
|
|
55
|
+
instance.dispose()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('shows fallback during suspense', async () => {
|
|
59
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
60
|
+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
61
|
+
const {promise, resolve} = promiseWithResolvers()
|
|
62
|
+
|
|
63
|
+
function SuspendingChild(): React.ReactNode {
|
|
64
|
+
throw promise
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render(
|
|
68
|
+
<SanityInstanceProvider
|
|
69
|
+
instance={instance}
|
|
70
|
+
fallback={<div data-testid="fallback">Loading...</div>}
|
|
71
|
+
>
|
|
72
|
+
<SuspendingChild />
|
|
73
|
+
</SanityInstanceProvider>,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(screen.getByTestId('fallback')).toBeInTheDocument()
|
|
77
|
+
act(() => {
|
|
78
|
+
resolve()
|
|
79
|
+
})
|
|
80
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
81
|
+
instance.dispose()
|
|
82
|
+
consoleSpy.mockRestore()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not dispose the instance on unmount', async () => {
|
|
86
|
+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
87
|
+
|
|
88
|
+
const {unmount} = render(
|
|
89
|
+
<SanityInstanceProvider instance={instance} fallback={null}>
|
|
90
|
+
<div />
|
|
91
|
+
</SanityInstanceProvider>,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
unmount()
|
|
95
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
96
|
+
|
|
97
|
+
expect(instance.isDisposed()).toBe(false)
|
|
98
|
+
instance.dispose()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {Suspense} from 'react'
|
|
3
|
+
|
|
4
|
+
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props for the SanityInstanceProvider component
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export interface SanityInstanceProviderProps {
|
|
11
|
+
/**
|
|
12
|
+
* A pre-created SanityInstance to provide to child components.
|
|
13
|
+
* The caller owns the instance lifecycle — SanityInstanceProvider
|
|
14
|
+
* will not dispose it on unmount.
|
|
15
|
+
*/
|
|
16
|
+
instance: SanityInstance
|
|
17
|
+
/**
|
|
18
|
+
* React node to show while content is loading.
|
|
19
|
+
* Used as the fallback for the internal Suspense boundary.
|
|
20
|
+
*/
|
|
21
|
+
fallback: React.ReactNode
|
|
22
|
+
children: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provides an externally-created Sanity instance to child components through React Context.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Unlike {@link ResourceProvider}, this component does not create or dispose a SanityInstance.
|
|
32
|
+
* The caller is responsible for creating the instance via `createSanityInstance` and disposing
|
|
33
|
+
* it when appropriate. This is useful when a non-React system layer (e.g. a state machine)
|
|
34
|
+
* owns the instance and the React tree should consume it without managing its lifecycle.
|
|
35
|
+
*
|
|
36
|
+
* All SDK hooks (`useSanityInstance`, `useDocuments`, etc.) will read from the provided instance.
|
|
37
|
+
*
|
|
38
|
+
* @example Providing a pre-created instance
|
|
39
|
+
* ```tsx
|
|
40
|
+
* import { createSanityInstance, type SanityConfig } from '@sanity/sdk'
|
|
41
|
+
* import { SanityInstanceProvider } from '@sanity/sdk-react'
|
|
42
|
+
*
|
|
43
|
+
* const config: SanityConfig = {
|
|
44
|
+
* projectId: 'my-project-id',
|
|
45
|
+
* dataset: 'production',
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* const instance = createSanityInstance(config)
|
|
49
|
+
*
|
|
50
|
+
* function App() {
|
|
51
|
+
* return (
|
|
52
|
+
* <SanityInstanceProvider instance={instance} fallback={<div>Loading...</div>}>
|
|
53
|
+
* <MyApp />
|
|
54
|
+
* </SanityInstanceProvider>
|
|
55
|
+
* )
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @category Components
|
|
60
|
+
*/
|
|
61
|
+
export function SanityInstanceProvider({
|
|
62
|
+
instance,
|
|
63
|
+
fallback,
|
|
64
|
+
children,
|
|
65
|
+
}: SanityInstanceProviderProps): React.ReactNode {
|
|
66
|
+
return (
|
|
67
|
+
<SanityInstanceContext.Provider value={instance}>
|
|
68
|
+
<Suspense fallback={fallback}>{children}</Suspense>
|
|
69
|
+
</SanityInstanceContext.Provider>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -27,13 +27,20 @@ export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): s
|
|
|
27
27
|
const instance = useSanityInstance()
|
|
28
28
|
const [error, setError] = useState<string | null>(null)
|
|
29
29
|
|
|
30
|
+
const isInactive = disabled || !projectIds || projectIds.length === 0
|
|
31
|
+
|
|
32
|
+
// Reset stale errors when verification turns off so the next activation
|
|
33
|
+
// doesn't briefly leak the previous result.
|
|
34
|
+
const [prevInactive, setPrevInactive] = useState(isInactive)
|
|
35
|
+
if (prevInactive !== isInactive) {
|
|
36
|
+
setPrevInactive(isInactive)
|
|
37
|
+
if (isInactive) setError(null)
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
useEffect(() => {
|
|
31
|
-
if (
|
|
32
|
-
if (error !== null) setError(null)
|
|
33
|
-
return
|
|
34
|
-
}
|
|
41
|
+
if (isInactive) return
|
|
35
42
|
|
|
36
|
-
const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds)
|
|
43
|
+
const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds!)
|
|
37
44
|
|
|
38
45
|
const subscription = verificationObservable$.subscribe((result: OrgVerificationResult) => {
|
|
39
46
|
setError(result.error)
|
|
@@ -42,7 +49,7 @@ export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): s
|
|
|
42
49
|
return () => {
|
|
43
50
|
subscription.unsubscribe()
|
|
44
51
|
}
|
|
45
|
-
}, [instance,
|
|
52
|
+
}, [instance, isInactive, projectIds])
|
|
46
53
|
|
|
47
54
|
return error
|
|
48
55
|
}
|
|
@@ -37,11 +37,13 @@ describe('useDispatchIntent', () => {
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('should throw error when neither action nor intentId is provided', () => {
|
|
40
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
41
|
const {result} = renderHook(() => useDispatchIntent({documentHandle: mockDocumentHandle}))
|
|
41
42
|
|
|
42
43
|
expect(() => result.current.dispatchIntent()).toThrow(
|
|
43
44
|
'useDispatchIntent: Either `action` or `intentId` must be provided.',
|
|
44
45
|
)
|
|
46
|
+
consoleErrorSpy.mockRestore()
|
|
45
47
|
})
|
|
46
48
|
|
|
47
49
|
it('should handle errors gracefully', () => {
|
|
@@ -165,11 +167,11 @@ describe('useDispatchIntent', () => {
|
|
|
165
167
|
})
|
|
166
168
|
})
|
|
167
169
|
|
|
168
|
-
it('should send intent message with media library
|
|
170
|
+
it('should send intent message with media library resource', () => {
|
|
169
171
|
const mockMediaLibraryHandle = {
|
|
170
172
|
documentId: 'test-asset-id',
|
|
171
173
|
documentType: 'sanity.asset',
|
|
172
|
-
|
|
174
|
+
resourceName: 'media-library',
|
|
173
175
|
} as const
|
|
174
176
|
|
|
175
177
|
const {result} = renderHook(() =>
|
|
@@ -194,11 +196,11 @@ describe('useDispatchIntent', () => {
|
|
|
194
196
|
})
|
|
195
197
|
})
|
|
196
198
|
|
|
197
|
-
it('should send intent message with canvas
|
|
199
|
+
it('should send intent message with canvas resource', () => {
|
|
198
200
|
const mockCanvasHandle = {
|
|
199
201
|
documentId: 'test-canvas-document-id',
|
|
200
202
|
documentType: 'sanity.canvas.document',
|
|
201
|
-
|
|
203
|
+
resourceName: 'canvas',
|
|
202
204
|
} as const
|
|
203
205
|
|
|
204
206
|
const {result} = renderHook(() =>
|
|
@@ -224,7 +226,7 @@ describe('useDispatchIntent', () => {
|
|
|
224
226
|
})
|
|
225
227
|
|
|
226
228
|
describe('error handling', () => {
|
|
227
|
-
it('should throw error when neither
|
|
229
|
+
it('should throw error when neither resource nor projectId/dataset is provided', () => {
|
|
228
230
|
const invalidHandle = {
|
|
229
231
|
documentId: 'test-document-id',
|
|
230
232
|
documentType: 'test-document-type',
|
|
@@ -238,7 +240,7 @@ describe('useDispatchIntent', () => {
|
|
|
238
240
|
)
|
|
239
241
|
|
|
240
242
|
expect(() => result.current.dispatchIntent()).toThrow(
|
|
241
|
-
'useDispatchIntent: Unable to determine resource. Either `
|
|
243
|
+
'useDispatchIntent: Unable to determine resource. Either `resource`, `resourceName`, or both `projectId` and `dataset` must be provided in documentHandle.',
|
|
242
244
|
)
|
|
243
245
|
})
|
|
244
246
|
})
|
|
@@ -3,7 +3,7 @@ import {type DocumentHandle, type FrameMessage} from '@sanity/sdk'
|
|
|
3
3
|
import {useCallback} from 'react'
|
|
4
4
|
|
|
5
5
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
6
|
-
import {type
|
|
6
|
+
import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
|
|
7
7
|
import {useResourceIdFromDocumentHandle} from './utils/useResourceIdFromDocumentHandle'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -42,7 +42,7 @@ interface DispatchIntent {
|
|
|
42
42
|
interface UseDispatchIntentParams {
|
|
43
43
|
action?: 'edit'
|
|
44
44
|
intentId?: string
|
|
45
|
-
documentHandle:
|
|
45
|
+
documentHandle: WithResourceNameSupport<DocumentHandle>
|
|
46
46
|
parameters?: Record<string, unknown>
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -56,8 +56,8 @@ interface UseDispatchIntentParams {
|
|
|
56
56
|
* - `action` - Action to perform (currently only 'edit' is supported). Will prompt a picker if multiple handlers are available.
|
|
57
57
|
* - `intentId` - Specific ID of the intent to dispatch. Either `action` or `intentId` is required.
|
|
58
58
|
* - `documentHandle` - The document handle containing document ID, type, and either:
|
|
59
|
-
* - `projectId` and `dataset` for traditional dataset
|
|
60
|
-
* - `
|
|
59
|
+
* - `projectId` and `dataset` for traditional dataset resources, like `{documentId: '123', documentType: 'book', projectId: 'abc123', dataset: 'production'}`
|
|
60
|
+
* - `resource` for media library, canvas, or dataset resources, like `{documentId: '123', documentType: 'sanity.asset', resource: mediaLibrarySource('ml123')}` or `{documentId: '123', documentType: 'sanity.canvas.document', resource: canvasSource('canvas123')}`
|
|
61
61
|
* - `paremeters` - Optional parameters to include in the dispatch; will be passed to the resolved intent handler
|
|
62
62
|
* @returns An object containing:
|
|
63
63
|
* - `dispatchIntent` - Function to dispatch the intent message
|
|
@@ -119,10 +119,10 @@ export function useDispatchIntent(params: UseDispatchIntentParams): DispatchInte
|
|
|
119
119
|
)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Validate that we have a resource ID (which is computed from
|
|
122
|
+
// Validate that we have a resource ID (which is computed from resource/resourceName or projectId+dataset)
|
|
123
123
|
if (!resource.id) {
|
|
124
124
|
throw new Error(
|
|
125
|
-
'useDispatchIntent: Unable to determine resource. Either `
|
|
125
|
+
'useDispatchIntent: Unable to determine resource. Either `resource`, `resourceName`, or both `projectId` and `dataset` must be provided in documentHandle.',
|
|
126
126
|
)
|
|
127
127
|
}
|
|
128
128
|
|