@sanity/sdk-react 3.0.0-rc.2 → 3.1.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/_exports/dashboard.d.ts +250 -0
- package/dist/_exports/dashboard.d.ts.map +1 -0
- package/dist/_exports/dashboard.js +278 -0
- package/dist/_exports/dashboard.js.map +1 -0
- package/dist/index.d.ts +86 -261
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +114 -616
- package/dist/index.js.map +1 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js +317 -0
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +1 -0
- package/package.json +12 -11
- package/src/_exports/dashboard.ts +12 -0
- package/src/_exports/sdk-react.ts +2 -12
- package/src/components/SDKProvider.test.tsx +5 -5
- package/src/components/auth/AuthBoundary.tsx +5 -5
- package/src/context/{WorkbenchTokenRefresh.test.tsx → DashboardTokenRefresh.test.tsx} +26 -26
- package/src/context/DashboardTokenRefresh.tsx +95 -0
- package/src/context/OrganizationResourcesProvider.test.tsx +9 -9
- package/src/context/OrganizationResourcesProvider.tsx +2 -2
- package/src/context/dashboardToken.ts +63 -0
- package/src/hooks/comlink/useWindowConnection.ts +1 -1
- package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +1 -1
- package/src/hooks/dashboard/useFavorite.test.tsx +101 -0
- package/src/hooks/dashboard/useFavorite.ts +34 -0
- package/src/hooks/dashboard/useFavoriteContext.ts +61 -0
- package/src/hooks/dashboard/{useDashboardNavigate.test.ts → useNavigate.test.ts} +3 -3
- package/src/hooks/dashboard/{useDashboardNavigate.ts → useNavigate.ts} +5 -5
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -1
- package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx} +4 -4
- package/src/hooks/{auth/useDashboardOrganizationId.tsx → dashboard/useOrganizationId.tsx} +2 -2
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +146 -0
- package/src/hooks/dashboard/useUpdateFavorite.ts +74 -0
- package/src/hooks/dashboard/useWindowTitle.ts +1 -1
- package/src/hooks/datasets/useDatasets.test.tsx +29 -22
- package/src/hooks/datasets/useDatasets.ts +31 -53
- package/src/hooks/document/useCreateDocument.ts +2 -1
- package/src/utils/resolveOrgResources.test.ts +2 -2
- package/src/utils/resolveOrgResources.ts +2 -2
- package/src/context/WorkbenchTokenRefresh.tsx +0 -61
- package/src/context/workbenchToken.ts +0 -63
- package/src/hooks/dashboard/useManageFavorite.test.tsx +0 -379
- package/src/hooks/dashboard/useManageFavorite.ts +0 -173
- /package/src/hooks/{agent → dashboard}/useAgentResourceContext.test.tsx +0 -0
|
@@ -4,13 +4,13 @@ import {of} from 'rxjs'
|
|
|
4
4
|
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
7
|
-
import {ResourceProvider} from './ResourceProvider'
|
|
8
7
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './
|
|
13
|
-
import {
|
|
8
|
+
isDashboardEnvironment,
|
|
9
|
+
observeDashboardToken,
|
|
10
|
+
refreshDashboardToken,
|
|
11
|
+
} from './dashboardToken'
|
|
12
|
+
import {DashboardTokenRefreshProvider} from './DashboardTokenRefresh'
|
|
13
|
+
import {ResourceProvider} from './ResourceProvider'
|
|
14
14
|
|
|
15
15
|
vi.mock('@sanity/sdk', async () => {
|
|
16
16
|
const actual = await vi.importActual('@sanity/sdk')
|
|
@@ -24,28 +24,28 @@ vi.mock('../hooks/auth/useAuthState', () => ({
|
|
|
24
24
|
useAuthState: vi.fn(),
|
|
25
25
|
}))
|
|
26
26
|
|
|
27
|
-
vi.mock('./
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
vi.mock('./dashboardToken', () => ({
|
|
28
|
+
isDashboardEnvironment: vi.fn(() => false),
|
|
29
|
+
observeDashboardToken: vi.fn(() => undefined),
|
|
30
|
+
refreshDashboardToken: vi.fn(),
|
|
31
31
|
}))
|
|
32
32
|
|
|
33
33
|
const mockSetAuthToken = setAuthToken as Mock
|
|
34
34
|
const mockUseAuthState = useAuthState as Mock
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
35
|
+
const mockIsDashboardEnvironment = isDashboardEnvironment as Mock
|
|
36
|
+
const mockObserveDashboardToken = observeDashboardToken as Mock
|
|
37
|
+
const mockRefreshDashboardToken = refreshDashboardToken as Mock
|
|
38
38
|
|
|
39
39
|
const renderProvider = () =>
|
|
40
40
|
render(
|
|
41
41
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
42
|
-
<
|
|
42
|
+
<DashboardTokenRefreshProvider>
|
|
43
43
|
<div>Test</div>
|
|
44
|
-
</
|
|
44
|
+
</DashboardTokenRefreshProvider>
|
|
45
45
|
</ResourceProvider>,
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
describe('
|
|
48
|
+
describe('DashboardTokenRefreshProvider', () => {
|
|
49
49
|
beforeEach(() => {
|
|
50
50
|
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
51
51
|
})
|
|
@@ -54,9 +54,9 @@ describe('WorkbenchTokenRefreshProvider', () => {
|
|
|
54
54
|
vi.clearAllMocks()
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
describe('when not in the
|
|
57
|
+
describe('when not in the dashboard', () => {
|
|
58
58
|
it('does not subscribe to the OS token', () => {
|
|
59
|
-
|
|
59
|
+
mockIsDashboardEnvironment.mockReturnValue(false)
|
|
60
60
|
|
|
61
61
|
act(() => {
|
|
62
62
|
renderProvider()
|
|
@@ -66,23 +66,23 @@ describe('WorkbenchTokenRefreshProvider', () => {
|
|
|
66
66
|
})
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
describe('when in the
|
|
69
|
+
describe('when in the dashboard', () => {
|
|
70
70
|
beforeEach(() => {
|
|
71
|
-
|
|
71
|
+
mockIsDashboardEnvironment.mockReturnValue(true)
|
|
72
72
|
})
|
|
73
73
|
|
|
74
74
|
it('mirrors the OS token into the auth store', () => {
|
|
75
|
-
|
|
75
|
+
mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
|
|
76
76
|
|
|
77
77
|
act(() => {
|
|
78
78
|
renderProvider()
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), '
|
|
81
|
+
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'dashboard-token')
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
it('asks the OS to reissue the token on a 401', () => {
|
|
85
|
-
|
|
85
|
+
mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
|
|
86
86
|
|
|
87
87
|
const {rerender} = renderProvider()
|
|
88
88
|
|
|
@@ -93,14 +93,14 @@ describe('WorkbenchTokenRefreshProvider', () => {
|
|
|
93
93
|
act(() => {
|
|
94
94
|
rerender(
|
|
95
95
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
96
|
-
<
|
|
96
|
+
<DashboardTokenRefreshProvider>
|
|
97
97
|
<div>Test</div>
|
|
98
|
-
</
|
|
98
|
+
</DashboardTokenRefreshProvider>
|
|
99
99
|
</ResourceProvider>,
|
|
100
100
|
)
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
-
expect(
|
|
103
|
+
expect(mockRefreshDashboardToken).toHaveBeenCalledTimes(1)
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {type ClientError} from '@sanity/client'
|
|
2
|
+
import {AuthStateType, setAuthToken} from '@sanity/sdk'
|
|
3
|
+
import React, {type PropsWithChildren, useEffect, useRef} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
6
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
7
|
+
import {
|
|
8
|
+
isDashboardEnvironment,
|
|
9
|
+
observeDashboardToken,
|
|
10
|
+
refreshDashboardToken,
|
|
11
|
+
} from './dashboardToken'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Keeps the SDK auth token in sync with the dashboard "OS".
|
|
15
|
+
*
|
|
16
|
+
* When running inside the dashboard the OS owns the session, so we subscribe
|
|
17
|
+
* to its `auth.token` stream and mirror each value into
|
|
18
|
+
* the auth store — a token logs us in, `null` logs us out, and later OS
|
|
19
|
+
* sign-in/out propagates automatically. When a request is rejected with a 401
|
|
20
|
+
* (the token expired), we ask the OS to reissue rather than tearing the session
|
|
21
|
+
* down; the new token arrives back through the same subscription.
|
|
22
|
+
*/
|
|
23
|
+
function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
24
|
+
const instance = useSanityInstance()
|
|
25
|
+
const authState = useAuthState()
|
|
26
|
+
const processed401ErrorRef = useRef<unknown | null>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const token$ = observeDashboardToken()
|
|
30
|
+
if (!token$) return undefined
|
|
31
|
+
const subscription = token$.subscribe((token) => setAuthToken(instance, token))
|
|
32
|
+
return () => subscription.unsubscribe()
|
|
33
|
+
}, [instance])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const has401Error =
|
|
37
|
+
authState.type === AuthStateType.ERROR && (authState.error as ClientError)?.statusCode === 401
|
|
38
|
+
|
|
39
|
+
if (has401Error && processed401ErrorRef.current !== authState.error) {
|
|
40
|
+
processed401ErrorRef.current = authState.error
|
|
41
|
+
refreshDashboardToken()
|
|
42
|
+
} else if (!has401Error) {
|
|
43
|
+
processed401ErrorRef.current = null
|
|
44
|
+
}
|
|
45
|
+
}, [authState])
|
|
46
|
+
|
|
47
|
+
return children
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Authenticates the SDK with the Sanity Dashboard's session when the app runs
|
|
52
|
+
* inside the dashboard.
|
|
53
|
+
*
|
|
54
|
+
* The dashboard owns the session there: this provider subscribes to the token
|
|
55
|
+
* the dashboard issues, writes each new value into the SDK's auth store (where
|
|
56
|
+
* SDK hooks read it from), and asks the dashboard for a fresh token when a
|
|
57
|
+
* request fails with a 401. Outside the dashboard it renders children
|
|
58
|
+
* unchanged and the app's normal auth flow applies.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* `AuthBoundary` mounts this automatically, so most apps never need it
|
|
62
|
+
* directly. Mount it yourself only when your app runs inside the dashboard
|
|
63
|
+
* without `AuthBoundary` — that is, the app renders its own loading and error
|
|
64
|
+
* UI instead of the SDK's login flow — but still uses SDK hooks such as
|
|
65
|
+
* `useQuery`, which need the dashboard's token in the auth store to
|
|
66
|
+
* authenticate their requests.
|
|
67
|
+
*
|
|
68
|
+
* Mount it once, inside the provider that creates the Sanity instance whose
|
|
69
|
+
* store should receive the token.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```tsx
|
|
73
|
+
* import {ResourceProvider} from '@sanity/sdk-react'
|
|
74
|
+
* import {TokenRefreshProvider} from '@sanity/sdk-react/dashboard'
|
|
75
|
+
*
|
|
76
|
+
* function EmbeddedApp() {
|
|
77
|
+
* return (
|
|
78
|
+
* <ResourceProvider fallback={<Loading />}>
|
|
79
|
+
* <TokenRefreshProvider>
|
|
80
|
+
* <App />
|
|
81
|
+
* </TokenRefreshProvider>
|
|
82
|
+
* </ResourceProvider>
|
|
83
|
+
* )
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
90
|
+
if (isDashboardEnvironment()) {
|
|
91
|
+
return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return children
|
|
95
|
+
}
|
|
@@ -3,7 +3,7 @@ import {act, render} from '@testing-library/react'
|
|
|
3
3
|
import {type ReactNode, Suspense, useContext, useEffect} from 'react'
|
|
4
4
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {useOrganizationId} from '../hooks/dashboard/useOrganizationId'
|
|
7
7
|
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
8
8
|
import {OrganizationResourcesProvider} from './OrganizationResourcesProvider'
|
|
9
9
|
import {ResourcesContext} from './ResourcesContext'
|
|
@@ -28,8 +28,8 @@ vi.mock('../hooks/context/useSanityInstance', () => {
|
|
|
28
28
|
return {useSanityInstance: () => instance}
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
vi.mock('../hooks/
|
|
32
|
-
|
|
31
|
+
vi.mock('../hooks/dashboard/useOrganizationId', () => ({
|
|
32
|
+
useOrganizationId: vi.fn(),
|
|
33
33
|
}))
|
|
34
34
|
|
|
35
35
|
vi.mock('../utils/resolveOrgResources', () => ({
|
|
@@ -37,14 +37,14 @@ vi.mock('../utils/resolveOrgResources', () => ({
|
|
|
37
37
|
}))
|
|
38
38
|
|
|
39
39
|
const mockResolveOrgResources = vi.mocked(resolveOrgResources)
|
|
40
|
-
const
|
|
40
|
+
const mockUseOrganizationId = vi.mocked(useOrganizationId)
|
|
41
41
|
|
|
42
42
|
describe('OrganizationResourcesProvider', () => {
|
|
43
43
|
beforeEach(() => {
|
|
44
44
|
vi.clearAllMocks()
|
|
45
45
|
instance = createSanityInstance()
|
|
46
46
|
mockResolveOrgResources.mockResolvedValue({})
|
|
47
|
-
|
|
47
|
+
mockUseOrganizationId.mockReturnValue(undefined)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
// Captures ResourcesContext via useEffect (side-effect territory, not render)
|
|
@@ -91,7 +91,7 @@ describe('OrganizationResourcesProvider', () => {
|
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
it('suspends children while org resources are being fetched, then renders with resolved resources', async () => {
|
|
94
|
-
|
|
94
|
+
mockUseOrganizationId.mockReturnValue('org-suspend-test')
|
|
95
95
|
const {promise, resolve} = promiseWithResolvers<void>()
|
|
96
96
|
mockResolveOrgResources.mockReturnValue(
|
|
97
97
|
promise.then(() => ({
|
|
@@ -115,7 +115,7 @@ describe('OrganizationResourcesProvider', () => {
|
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
it('renders without inferred entries and logs a warning when resolveOrgResources rejects', async () => {
|
|
118
|
-
|
|
118
|
+
mockUseOrganizationId.mockReturnValue('org-warn-test')
|
|
119
119
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
120
120
|
mockResolveOrgResources.mockRejectedValue(new Error('fetch failed'))
|
|
121
121
|
|
|
@@ -135,7 +135,7 @@ describe('OrganizationResourcesProvider', () => {
|
|
|
135
135
|
})
|
|
136
136
|
|
|
137
137
|
it('caches Promise references across providers for the same instance', () => {
|
|
138
|
-
|
|
138
|
+
mockUseOrganizationId.mockReturnValue('org-cache-test')
|
|
139
139
|
render(
|
|
140
140
|
<Suspense fallback={null}>
|
|
141
141
|
<OrganizationResourcesProvider inferMediaLibraryAndCanvas>
|
|
@@ -168,7 +168,7 @@ describe('OrganizationResourcesProvider', () => {
|
|
|
168
168
|
})
|
|
169
169
|
|
|
170
170
|
it('explicit resources take precedence over inferred ones', async () => {
|
|
171
|
-
|
|
171
|
+
mockUseOrganizationId.mockReturnValue('org-override-test')
|
|
172
172
|
mockResolveOrgResources.mockResolvedValue({
|
|
173
173
|
mediaLibrary: {mediaLibraryId: 'inferred-ml'},
|
|
174
174
|
canvas: {canvasId: 'inferred-canvas'},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {type ReactElement, type ReactNode, use, useMemo} from 'react'
|
|
3
3
|
|
|
4
|
-
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
5
4
|
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
5
|
+
import {useOrganizationId} from '../hooks/dashboard/useOrganizationId'
|
|
6
6
|
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
7
7
|
import {ResourcesContext} from './ResourcesContext'
|
|
8
8
|
|
|
@@ -91,7 +91,7 @@ export function OrganizationResourcesProvider({
|
|
|
91
91
|
children: ReactNode
|
|
92
92
|
}): ReactElement {
|
|
93
93
|
const instance = useSanityInstance()
|
|
94
|
-
const orgId =
|
|
94
|
+
const orgId = useOrganizationId()
|
|
95
95
|
|
|
96
96
|
if (!inferMediaLibraryAndCanvas || !orgId) {
|
|
97
97
|
return (
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {from, type Observable, of} from 'rxjs'
|
|
2
|
+
import {catchError, switchMap} from 'rxjs/operators'
|
|
3
|
+
|
|
4
|
+
// The dashboard host installs its shared message bus on this well-known global
|
|
5
|
+
// symbol before it loads the apps it embeds in its own window. It must match
|
|
6
|
+
// the key used by `@sanity/workbench` (`Symbol.for('sanity.os.bus')`).
|
|
7
|
+
const OS_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this app is running inside the dashboard, embedded in its window.
|
|
11
|
+
*
|
|
12
|
+
* Apps embedded this way share the dashboard's realm, so the bus it installs
|
|
13
|
+
* is visible on `globalThis`. This is `false` in a standalone app, where we
|
|
14
|
+
* must never import `@sanity/workbench` (it would install a bus and add bundle
|
|
15
|
+
* weight for no reason). Note: this is a different embedding model to the Core
|
|
16
|
+
* UI iframe, which is detected separately via the dashboard context — that
|
|
17
|
+
* signal is not set for apps sharing the dashboard's window.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export function isDashboardEnvironment(): boolean {
|
|
22
|
+
return typeof globalThis === 'object' && OS_BUS_KEY in globalThis
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Observes the session token issued by the dashboard "OS", tracking the OS auth
|
|
27
|
+
* state over time.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` when the app is not embedded in the dashboard, so the
|
|
30
|
+
* caller uses its normal auth flow. Inside the dashboard, subscribes to the
|
|
31
|
+
* `auth.token` state topic, emitting the current token — or `null` when the OS
|
|
32
|
+
* is signed out — and re-emitting as the OS auth state changes, so sign-in/out
|
|
33
|
+
* propagates instead of being captured once. Any bus error is treated as "no
|
|
34
|
+
* token" (`null`). The token is used in-memory only and never persisted.
|
|
35
|
+
*
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export function observeDashboardToken(): Observable<string | null> | undefined {
|
|
39
|
+
if (!isDashboardEnvironment()) return undefined
|
|
40
|
+
|
|
41
|
+
return from(import('@sanity/workbench')).pipe(
|
|
42
|
+
switchMap(({os}) => os.subscribe('auth.token')),
|
|
43
|
+
// Any failure (importing the host bundle, or the subscription) means "no OS token".
|
|
44
|
+
catchError(() => of(null)),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Asks the dashboard "OS" to reissue the session token, e.g. after its current
|
|
50
|
+
* one was rejected with a 401. Fire-and-forget: the reissued token arrives via
|
|
51
|
+
* the `auth.token` subscription in {@link observeDashboardToken}. No-op outside
|
|
52
|
+
* the dashboard.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export function refreshDashboardToken(): void {
|
|
57
|
+
if (!isDashboardEnvironment()) return
|
|
58
|
+
|
|
59
|
+
void import('@sanity/workbench').then(
|
|
60
|
+
({os}) => os.emit('auth.token.refresh', undefined),
|
|
61
|
+
() => {},
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -66,7 +66,7 @@ const useNodeState = createStateSourceHook({
|
|
|
66
66
|
* as well as sharing a single node between invocations if they share the same name.
|
|
67
67
|
*
|
|
68
68
|
* Generally not to be used directly, but to be used as a dependency of
|
|
69
|
-
* Comlink-powered hooks like `
|
|
69
|
+
* Comlink-powered hooks like `useStudioWorkspacesByProjectIdDataset`.
|
|
70
70
|
*/
|
|
71
71
|
export function useWindowConnection<
|
|
72
72
|
TWindowMessage extends WindowMessage,
|
|
@@ -36,7 +36,7 @@ export interface AgentResourceContextOptions {
|
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```tsx
|
|
39
|
-
* import {useAgentResourceContext} from '@sanity/sdk-react'
|
|
39
|
+
* import {useAgentResourceContext} from '@sanity/sdk-react/dashboard'
|
|
40
40
|
*
|
|
41
41
|
* function MyComponent() {
|
|
42
42
|
* const documentId = 'my-document-id'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {favorites, type FavoriteStatusResponse, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
3
|
+
import {renderHook} from '@testing-library/react'
|
|
4
|
+
import {type ReactNode} from 'react'
|
|
5
|
+
import {BehaviorSubject} from 'rxjs'
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
7
|
+
|
|
8
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
9
|
+
import {useFavorite} from './useFavorite'
|
|
10
|
+
|
|
11
|
+
vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal()
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
favorites: {
|
|
16
|
+
getState: vi.fn(),
|
|
17
|
+
resolveState: vi.fn(),
|
|
18
|
+
refetch: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
} as unknown as typeof actual
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('useFavorite', () => {
|
|
24
|
+
let subject: BehaviorSubject<FavoriteStatusResponse>
|
|
25
|
+
|
|
26
|
+
const handle = {
|
|
27
|
+
documentId: 'mock-id',
|
|
28
|
+
documentType: 'mock-type',
|
|
29
|
+
resourceType: 'studio' as const,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const toSnapshot = (value: FavoriteStatusResponse): FetcherSnapshot<FavoriteStatusResponse> => ({
|
|
33
|
+
status: 'success',
|
|
34
|
+
data: value,
|
|
35
|
+
error: undefined,
|
|
36
|
+
isFetching: false,
|
|
37
|
+
dataUpdatedAt: 1,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const wrapper = ({children}: {children: ReactNode}) => (
|
|
41
|
+
<ResourceProvider projectId="test" dataset="test" fallback={null}>
|
|
42
|
+
{children}
|
|
43
|
+
</ResourceProvider>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
subject = new BehaviorSubject<FavoriteStatusResponse>({isFavorited: false})
|
|
48
|
+
vi.mocked(favorites.getState).mockImplementation(
|
|
49
|
+
() =>
|
|
50
|
+
({
|
|
51
|
+
subscribe: (callback?: () => void) => {
|
|
52
|
+
if (!callback) return () => {}
|
|
53
|
+
const subscription = subject.subscribe(() => callback())
|
|
54
|
+
callback()
|
|
55
|
+
return () => subscription.unsubscribe()
|
|
56
|
+
},
|
|
57
|
+
getCurrent: () => toSnapshot(subject.getValue()),
|
|
58
|
+
observable: subject.asObservable(),
|
|
59
|
+
}) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
subject.complete()
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns false when the document is not favorited', () => {
|
|
69
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
70
|
+
expect(result.current).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('reflects the favorited status from the store', () => {
|
|
74
|
+
subject.next({isFavorited: true})
|
|
75
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
76
|
+
expect(result.current).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('suspends until the favorite status is available', () => {
|
|
80
|
+
const pending: FetcherSnapshot<FavoriteStatusResponse> = {
|
|
81
|
+
status: 'pending',
|
|
82
|
+
data: undefined,
|
|
83
|
+
error: undefined,
|
|
84
|
+
isFetching: true,
|
|
85
|
+
dataUpdatedAt: undefined,
|
|
86
|
+
}
|
|
87
|
+
vi.mocked(favorites.getState).mockImplementation(
|
|
88
|
+
() =>
|
|
89
|
+
({
|
|
90
|
+
subscribe: () => () => {},
|
|
91
|
+
getCurrent: () => pending,
|
|
92
|
+
observable: subject.asObservable(),
|
|
93
|
+
}) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
|
|
94
|
+
)
|
|
95
|
+
vi.mocked(favorites.resolveState).mockReturnValue(new Promise(() => {}))
|
|
96
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
97
|
+
// Suspended on the initial fetch — the ResourceProvider fallback renders instead.
|
|
98
|
+
expect(result.current).toBeNull()
|
|
99
|
+
expect(favorites.resolveState).toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {favorites} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {createFetcherHook} from '../helpers/createFetcherHook'
|
|
4
|
+
import {useFavoriteContext, type UseFavoriteProps} from './useFavoriteContext'
|
|
5
|
+
|
|
6
|
+
const useFavoriteStatus = createFetcherHook(favorites)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @internal
|
|
10
|
+
*
|
|
11
|
+
* Reads whether a document is currently favorited. The write-side counterpart is
|
|
12
|
+
* {@link useUpdateFavorite}.
|
|
13
|
+
*
|
|
14
|
+
* The hook suspends until the first favorite status resolves, so wrap the
|
|
15
|
+
* component in a `<Suspense>` boundary.
|
|
16
|
+
*
|
|
17
|
+
* @param props - The document handle plus the resource it lives in.
|
|
18
|
+
* @returns `true` when the document is favorited, otherwise `false`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* function FavoriteLabel(props: DocumentActionProps) {
|
|
23
|
+
* const {documentId, documentType} = props
|
|
24
|
+
* const isFavorited = useFavorite({documentId, documentType, resourceType: 'studio'})
|
|
25
|
+
*
|
|
26
|
+
* return <span>{isFavorited ? 'Favorited' : 'Not favorited'}</span>
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function useFavorite(props: UseFavoriteProps): boolean {
|
|
31
|
+
const context = useFavoriteContext(props)
|
|
32
|
+
const {data} = useFavoriteStatus(context)
|
|
33
|
+
return data.isFavorited
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanvasResource,
|
|
3
|
+
type MediaResource,
|
|
4
|
+
type StudioResource,
|
|
5
|
+
} from '@sanity/message-protocol'
|
|
6
|
+
import {type DocumentHandle, type FavoriteDocumentContext} from '@sanity/sdk'
|
|
7
|
+
import {useMemo} from 'react'
|
|
8
|
+
|
|
9
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props shared by {@link useFavorite} and {@link useUpdateFavorite}: a document
|
|
13
|
+
* handle plus the resource it lives in.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export interface UseFavoriteProps extends DocumentHandle {
|
|
18
|
+
resourceId?: string
|
|
19
|
+
resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
|
|
20
|
+
/**
|
|
21
|
+
* The name of the schema collection this document belongs to.
|
|
22
|
+
* Typically is the name of the workspace when used in the context of a studio.
|
|
23
|
+
*/
|
|
24
|
+
schemaName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves {@link UseFavoriteProps} into the {@link FavoriteDocumentContext}
|
|
29
|
+
* shared by the favorites fetcher and mutation, defaulting a studio `resourceId`
|
|
30
|
+
* from the instance's project and dataset.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function useFavoriteContext({
|
|
35
|
+
documentId,
|
|
36
|
+
documentType,
|
|
37
|
+
projectId: paramProjectId,
|
|
38
|
+
dataset: paramDataset,
|
|
39
|
+
resourceId: paramResourceId,
|
|
40
|
+
resourceType,
|
|
41
|
+
schemaName,
|
|
42
|
+
}: UseFavoriteProps): FavoriteDocumentContext {
|
|
43
|
+
const {config} = useSanityInstance()
|
|
44
|
+
const projectId = paramProjectId ?? config?.projectId
|
|
45
|
+
const dataset = paramDataset ?? config?.dataset
|
|
46
|
+
|
|
47
|
+
if (resourceType === 'studio' && (!projectId || !dataset)) {
|
|
48
|
+
throw new Error('projectId and dataset are required for studio resources')
|
|
49
|
+
}
|
|
50
|
+
const resourceId =
|
|
51
|
+
resourceType === 'studio' && !paramResourceId ? `${projectId}.${dataset}` : paramResourceId
|
|
52
|
+
|
|
53
|
+
if (!resourceId) {
|
|
54
|
+
throw new Error('resourceId is required for media-library and canvas resources')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return useMemo(
|
|
58
|
+
() => ({documentId, documentType, resourceId, resourceType, schemaName}),
|
|
59
|
+
[documentId, documentType, resourceId, resourceType, schemaName],
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -2,7 +2,7 @@ import {type PathChangeMessage} from '@sanity/message-protocol'
|
|
|
2
2
|
import {renderHook} from '@testing-library/react'
|
|
3
3
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {useNavigate} from './useNavigate'
|
|
6
6
|
|
|
7
7
|
const mockOnMessage = vi.fn()
|
|
8
8
|
let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
|
|
@@ -22,7 +22,7 @@ vi.mock('../comlink/useWindowConnection', () => {
|
|
|
22
22
|
}
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
describe('
|
|
25
|
+
describe('useNavigate', () => {
|
|
26
26
|
const mockNavigateFn = vi.fn()
|
|
27
27
|
|
|
28
28
|
beforeEach(() => {
|
|
@@ -31,7 +31,7 @@ describe('useDashboardNavigate', () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
it('calls navigate function with correct data when message is received', () => {
|
|
34
|
-
renderHook(() =>
|
|
34
|
+
renderHook(() => useNavigate(mockNavigateFn))
|
|
35
35
|
|
|
36
36
|
const mockNavigationData = {
|
|
37
37
|
path: '/test-path',
|
|
@@ -21,13 +21,13 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
|
21
21
|
*
|
|
22
22
|
* @example
|
|
23
23
|
* ```tsx
|
|
24
|
-
* import {
|
|
25
|
-
* import {BrowserRouter, useNavigate} from 'react-router'
|
|
24
|
+
* import {useNavigate} from '@sanity/sdk-react/dashboard'
|
|
25
|
+
* import {BrowserRouter, useNavigate as useRouterNavigate} from 'react-router'
|
|
26
26
|
* import {Suspense} from 'react'
|
|
27
27
|
*
|
|
28
28
|
* function DashboardNavigationHandler() {
|
|
29
|
-
* const navigate =
|
|
30
|
-
*
|
|
29
|
+
* const navigate = useRouterNavigate()
|
|
30
|
+
* useNavigate(({path, type}) => {
|
|
31
31
|
* navigate(path, {replace: type === 'replace'})
|
|
32
32
|
* })
|
|
33
33
|
* return null
|
|
@@ -45,7 +45,7 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
|
45
45
|
* }
|
|
46
46
|
* ```
|
|
47
47
|
*/
|
|
48
|
-
export function
|
|
48
|
+
export function useNavigate(
|
|
49
49
|
navigateFn: (options: PathChangeMessage['data']) => void,
|
|
50
50
|
): void {
|
|
51
51
|
useWindowConnection<PathChangeMessage, never>({
|
|
@@ -36,7 +36,8 @@ export interface NavigateToStudioResult {
|
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```tsx
|
|
39
|
-
* import {
|
|
39
|
+
* import {type DocumentHandle} from '@sanity/sdk-react'
|
|
40
|
+
* import {useNavigateToStudioDocument} from '@sanity/sdk-react/dashboard'
|
|
40
41
|
* import {Button} from '@sanity/ui'
|
|
41
42
|
* import {Suspense} from 'react'
|
|
42
43
|
*
|