@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
package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx}
RENAMED
|
@@ -4,14 +4,14 @@ import {throwError} from 'rxjs'
|
|
|
4
4
|
import {describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
|
-
import {
|
|
7
|
+
import {useOrganizationId} from './useOrganizationId'
|
|
8
8
|
|
|
9
9
|
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
10
10
|
const actual = await importOriginal()
|
|
11
11
|
return {...(actual || {}), getDashboardOrganizationId: vi.fn()}
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
-
describe('
|
|
14
|
+
describe('useOrganizationId', () => {
|
|
15
15
|
it('should return undefined when no organization ID is set', () => {
|
|
16
16
|
const subscribe = vi.fn()
|
|
17
17
|
vi.mocked(getDashboardOrganizationId).mockReturnValue({
|
|
@@ -20,7 +20,7 @@ describe('useDashboardOrganizationId', () => {
|
|
|
20
20
|
observable: throwError(() => new Error('Unexpected usage of observable')),
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
const {result} = renderHook(() =>
|
|
23
|
+
const {result} = renderHook(() => useOrganizationId(), {
|
|
24
24
|
wrapper: ({children}) => (
|
|
25
25
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
26
26
|
{children}
|
|
@@ -39,7 +39,7 @@ describe('useDashboardOrganizationId', () => {
|
|
|
39
39
|
observable: throwError(() => new Error('Unexpected usage of observable')),
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
const {result} = renderHook(() =>
|
|
42
|
+
const {result} = renderHook(() => useOrganizationId(), {
|
|
43
43
|
wrapper: ({children}) => (
|
|
44
44
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
45
45
|
{children}
|
|
@@ -11,7 +11,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
11
11
|
* @example
|
|
12
12
|
* ```tsx
|
|
13
13
|
* function DashboardComponent() {
|
|
14
|
-
* const orgId =
|
|
14
|
+
* const orgId = useOrganizationId()
|
|
15
15
|
*
|
|
16
16
|
* if (!orgId) return null
|
|
17
17
|
*
|
|
@@ -22,7 +22,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
22
22
|
* @category Dashboard
|
|
23
23
|
* @returns The dashboard organization ID (string | undefined)
|
|
24
24
|
*/
|
|
25
|
-
export function
|
|
25
|
+
export function useOrganizationId(): string | undefined {
|
|
26
26
|
const instance = useSanityInstance()
|
|
27
27
|
const {subscribe, getCurrent} = useMemo(() => getDashboardOrganizationId(instance), [instance])
|
|
28
28
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {type FavoriteStatusResponse, setFavorite} from '@sanity/sdk'
|
|
2
|
+
import {type MutationResult} from '@sanity/sdk/_internal'
|
|
3
|
+
import {act, renderHook, waitFor} from '@testing-library/react'
|
|
4
|
+
import {type ReactNode} from 'react'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
8
|
+
import {useUpdateFavorite} from './useUpdateFavorite'
|
|
9
|
+
|
|
10
|
+
vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal()
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
setFavorite: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type SetFavorite = typeof setFavorite
|
|
19
|
+
|
|
20
|
+
describe('useUpdateFavorite', () => {
|
|
21
|
+
const mockSetFavorite = setFavorite as Mock
|
|
22
|
+
|
|
23
|
+
const handle = {
|
|
24
|
+
documentId: 'mock-id',
|
|
25
|
+
documentType: 'mock-type',
|
|
26
|
+
resourceType: 'studio' as const,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const makeWrapper = (projectId?: string, dataset?: string) => {
|
|
30
|
+
return function Wrapper({children}: {children: ReactNode}) {
|
|
31
|
+
return (
|
|
32
|
+
<ResourceProvider projectId={projectId} dataset={dataset} fallback={null}>
|
|
33
|
+
{children}
|
|
34
|
+
</ResourceProvider>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const wrapper = makeWrapper('test', 'test')
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockSetFavorite.mockImplementation((async (_instance, input) => ({
|
|
42
|
+
data: {isFavorited: input.isFavorited},
|
|
43
|
+
invalidated: Promise.resolve(),
|
|
44
|
+
})) as SetFavorite)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.clearAllMocks()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('sends the added event with the resolved studio resourceId', async () => {
|
|
52
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
53
|
+
|
|
54
|
+
await act(async () => {
|
|
55
|
+
await result.current.favorite()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
59
|
+
expect.anything(),
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
documentId: 'mock-id',
|
|
62
|
+
documentType: 'mock-type',
|
|
63
|
+
resourceId: 'test.test',
|
|
64
|
+
resourceType: 'studio',
|
|
65
|
+
isFavorited: true,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('sends the removed event when unfavoriting', async () => {
|
|
71
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await result.current.unfavorite()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
78
|
+
expect.anything(),
|
|
79
|
+
expect.objectContaining({isFavorited: false}),
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('passes schemaName through when provided', async () => {
|
|
84
|
+
const {result} = renderHook(() => useUpdateFavorite({...handle, schemaName: 'testSchema'}), {
|
|
85
|
+
wrapper,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
await result.current.favorite()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
93
|
+
expect.anything(),
|
|
94
|
+
expect.objectContaining({schemaName: 'testSchema', isFavorited: true}),
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('tracks isPending across the mutation lifecycle', async () => {
|
|
99
|
+
let resolveMutation!: (value: MutationResult<FavoriteStatusResponse>) => void
|
|
100
|
+
mockSetFavorite.mockReturnValue(
|
|
101
|
+
new Promise<MutationResult<FavoriteStatusResponse>>((resolve) => {
|
|
102
|
+
resolveMutation = resolve
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
107
|
+
expect(result.current.isPending).toBe(false)
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
void result.current.favorite()
|
|
111
|
+
})
|
|
112
|
+
expect(result.current.isPending).toBe(true)
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
resolveMutation({data: {isFavorited: true}, invalidated: Promise.resolve()})
|
|
116
|
+
})
|
|
117
|
+
expect(result.current.isPending).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('surfaces mutation failures on error', async () => {
|
|
121
|
+
mockSetFavorite.mockRejectedValue(new Error('mutate failed'))
|
|
122
|
+
|
|
123
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await expect(result.current.favorite()).rejects.toThrow('mutate failed')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws when a studio resource is missing projectId or dataset', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
renderHook(() => useUpdateFavorite(handle), {wrapper: makeWrapper(undefined, undefined)}),
|
|
135
|
+
).toThrow('projectId and dataset are required for studio resources')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('throws when resourceId is missing for non-studio resources', () => {
|
|
139
|
+
expect(() =>
|
|
140
|
+
renderHook(
|
|
141
|
+
() => useUpdateFavorite({...handle, resourceType: 'media-library', resourceId: undefined}),
|
|
142
|
+
{wrapper},
|
|
143
|
+
),
|
|
144
|
+
).toThrow('resourceId is required for media-library and canvas resources')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {type FavoriteStatusResponse, setFavorite} from '@sanity/sdk'
|
|
2
|
+
import {useCallback} from 'react'
|
|
3
|
+
|
|
4
|
+
import {createMutationHook} from '../helpers/createMutationHook'
|
|
5
|
+
import {useFavoriteContext, type UseFavoriteProps} from './useFavoriteContext'
|
|
6
|
+
|
|
7
|
+
const useSetFavorite = createMutationHook(setFavorite)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The value returned by {@link useUpdateFavorite}.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export interface UpdateFavorite {
|
|
15
|
+
/** Adds the document to favorites. */
|
|
16
|
+
favorite: () => Promise<FavoriteStatusResponse>
|
|
17
|
+
/** Removes the document from favorites. */
|
|
18
|
+
unfavorite: () => Promise<FavoriteStatusResponse>
|
|
19
|
+
/** A favorite or unfavorite mutation is currently in flight. */
|
|
20
|
+
isPending: boolean
|
|
21
|
+
/** The most recent failure; cleared by the next call or `reset`. */
|
|
22
|
+
error: unknown
|
|
23
|
+
/** Clears error and pending state back to idle. */
|
|
24
|
+
reset: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @internal
|
|
29
|
+
*
|
|
30
|
+
* Adds or removes a document from favorites. The read-side counterpart is
|
|
31
|
+
* {@link useFavorite}, which reflects the change once the mutation settles.
|
|
32
|
+
*
|
|
33
|
+
* Unlike {@link useFavorite}, this hook does not suspend.
|
|
34
|
+
*
|
|
35
|
+
* @param props - The document handle plus the resource it lives in.
|
|
36
|
+
* @returns `favorite`/`unfavorite` actions and the `{isPending, error, reset}`
|
|
37
|
+
* mutation state.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* function FavoriteButton(props: DocumentActionProps) {
|
|
42
|
+
* const {documentId, documentType} = props
|
|
43
|
+
* const handle = {documentId, documentType, resourceType: 'studio'} as const
|
|
44
|
+
* const isFavorited = useFavorite(handle)
|
|
45
|
+
* const {favorite, unfavorite, isPending} = useUpdateFavorite(handle)
|
|
46
|
+
*
|
|
47
|
+
* return (
|
|
48
|
+
* <Button
|
|
49
|
+
* disabled={isPending}
|
|
50
|
+
* onClick={() => (isFavorited ? unfavorite() : favorite())}
|
|
51
|
+
* text={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
|
|
52
|
+
* />
|
|
53
|
+
* )
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* // Wrap the component with Suspense since useFavorite suspends
|
|
57
|
+
* function MyDocumentAction(props: DocumentActionProps) {
|
|
58
|
+
* return (
|
|
59
|
+
* <Suspense fallback={<Button text="Loading..." disabled />}>
|
|
60
|
+
* <FavoriteButton {...props} />
|
|
61
|
+
* </Suspense>
|
|
62
|
+
* )
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function useUpdateFavorite(props: UseFavoriteProps): UpdateFavorite {
|
|
67
|
+
const context = useFavoriteContext(props)
|
|
68
|
+
const {mutate, isPending, error, reset} = useSetFavorite()
|
|
69
|
+
|
|
70
|
+
const favorite = useCallback(() => mutate({...context, isFavorited: true}), [mutate, context])
|
|
71
|
+
const unfavorite = useCallback(() => mutate({...context, isFavorited: false}), [mutate, context])
|
|
72
|
+
|
|
73
|
+
return {favorite, unfavorite, isPending, error, reset}
|
|
74
|
+
}
|
|
@@ -42,7 +42,7 @@ function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
|
42
42
|
*
|
|
43
43
|
* @example
|
|
44
44
|
* ```tsx
|
|
45
|
-
* import {useWindowTitle} from '@sanity/sdk-react'
|
|
45
|
+
* import {useWindowTitle} from '@sanity/sdk-react/dashboard'
|
|
46
46
|
*
|
|
47
47
|
* function MoviesList() {
|
|
48
48
|
* useWindowTitle('Movies')
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
getDatasetsState,
|
|
5
|
-
resolveDatasets,
|
|
6
|
-
type StateSource,
|
|
7
|
-
} from '@sanity/sdk'
|
|
2
|
+
import {createSanityInstance, datasets, type StateSource} from '@sanity/sdk'
|
|
3
|
+
import {type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
8
4
|
import {type ReactNode} from 'react'
|
|
9
5
|
import {type Observable} from 'rxjs'
|
|
10
6
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
@@ -16,32 +12,43 @@ import {useDatasets} from './useDatasets'
|
|
|
16
12
|
|
|
17
13
|
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
14
|
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
-
return {...original,
|
|
15
|
+
return {...original, datasets: {getState: vi.fn(), resolveState: vi.fn()}}
|
|
20
16
|
})
|
|
21
17
|
|
|
22
18
|
const stateSource = (
|
|
23
19
|
current: DatasetsResponse | undefined,
|
|
24
|
-
): StateSource<DatasetsResponse
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
): StateSource<FetcherSnapshot<DatasetsResponse>> => {
|
|
21
|
+
// Cache the snapshot: useSyncExternalStore requires a referentially stable current value.
|
|
22
|
+
const snapshot = current
|
|
23
|
+
? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
|
|
24
|
+
: {
|
|
25
|
+
status: 'pending',
|
|
26
|
+
data: undefined,
|
|
27
|
+
error: undefined,
|
|
28
|
+
isFetching: true,
|
|
29
|
+
dataUpdatedAt: undefined,
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
getCurrent: vi.fn(() => snapshot),
|
|
33
|
+
subscribe: vi.fn(() => () => {}),
|
|
28
34
|
get observable(): Observable<unknown> {
|
|
29
35
|
throw new Error('Not implemented')
|
|
30
36
|
},
|
|
31
|
-
}
|
|
37
|
+
} as unknown as StateSource<FetcherSnapshot<DatasetsResponse>>
|
|
38
|
+
}
|
|
32
39
|
|
|
33
40
|
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
34
41
|
|
|
35
42
|
describe('useDatasets', () => {
|
|
36
43
|
beforeEach(() => {
|
|
37
44
|
vi.clearAllMocks()
|
|
38
|
-
vi.mocked(
|
|
45
|
+
vi.mocked(datasets.getState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
|
|
39
46
|
})
|
|
40
47
|
|
|
41
48
|
it('resolves the projectId from the instance config resource', () => {
|
|
42
49
|
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
43
50
|
renderHook(() => useDatasets())
|
|
44
|
-
expect(
|
|
51
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
45
52
|
sanityInstance,
|
|
46
53
|
expect.objectContaining({projectId: 'test'}),
|
|
47
54
|
)
|
|
@@ -49,7 +56,7 @@ describe('useDatasets', () => {
|
|
|
49
56
|
|
|
50
57
|
it('lets an explicit projectId override the ambient resource', () => {
|
|
51
58
|
renderHook(() => useDatasets({projectId: 'explicit-project'}))
|
|
52
|
-
expect(
|
|
59
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
53
60
|
sanityInstance,
|
|
54
61
|
expect.objectContaining({projectId: 'explicit-project'}),
|
|
55
62
|
)
|
|
@@ -66,7 +73,7 @@ describe('useDatasets', () => {
|
|
|
66
73
|
</ResourceProvider>
|
|
67
74
|
),
|
|
68
75
|
})
|
|
69
|
-
expect(
|
|
76
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
70
77
|
sanityInstance,
|
|
71
78
|
expect.objectContaining({projectId: 'resource-project'}),
|
|
72
79
|
)
|
|
@@ -82,7 +89,7 @@ describe('useDatasets', () => {
|
|
|
82
89
|
})
|
|
83
90
|
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
84
91
|
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
85
|
-
expect(
|
|
92
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
86
93
|
sanityInstance,
|
|
87
94
|
expect.objectContaining({projectId: 'config-project'}),
|
|
88
95
|
)
|
|
@@ -101,16 +108,16 @@ describe('useDatasets', () => {
|
|
|
101
108
|
</SanityInstanceContext.Provider>
|
|
102
109
|
),
|
|
103
110
|
})
|
|
104
|
-
expect(
|
|
111
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
105
112
|
emptyInstance,
|
|
106
113
|
expect.objectContaining({projectId: 'bare-project'}),
|
|
107
114
|
)
|
|
108
115
|
})
|
|
109
116
|
|
|
110
|
-
it('suspends via
|
|
111
|
-
vi.mocked(
|
|
112
|
-
vi.mocked(
|
|
117
|
+
it('suspends via the datasets fetcher until dataset data is available', () => {
|
|
118
|
+
vi.mocked(datasets.getState).mockReturnValue(stateSource(undefined))
|
|
119
|
+
vi.mocked(datasets.resolveState).mockReturnValue(new Promise(() => {}))
|
|
113
120
|
renderHook(() => useDatasets())
|
|
114
|
-
expect(
|
|
121
|
+
expect(datasets.resolveState).toHaveBeenCalled()
|
|
115
122
|
})
|
|
116
123
|
})
|
|
@@ -1,64 +1,42 @@
|
|
|
1
1
|
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
-
import {
|
|
3
|
-
getDatasetsState,
|
|
4
|
-
type ProjectHandle,
|
|
5
|
-
resolveDatasets,
|
|
6
|
-
type SanityInstance,
|
|
7
|
-
type StateSource,
|
|
8
|
-
} from '@sanity/sdk'
|
|
2
|
+
import {datasets, type ProjectHandle} from '@sanity/sdk'
|
|
9
3
|
|
|
10
|
-
import {
|
|
4
|
+
import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
|
|
11
5
|
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
* Returns metadata for each dataset the current user has access to.
|
|
17
|
-
*
|
|
18
|
-
* @category Datasets
|
|
19
|
-
* @param options - Optional project/resource to read datasets for. Defaults to
|
|
20
|
-
* the resource named in `ResourceProvider`/`SDKProvider`.
|
|
21
|
-
* @returns The metadata for your the datasets
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* ```tsx
|
|
25
|
-
* const datasets = useDatasets()
|
|
26
|
-
*
|
|
27
|
-
* return (
|
|
28
|
-
* <select>
|
|
29
|
-
* {datasets.map((dataset) => (
|
|
30
|
-
* <option key={dataset.name}>{dataset.name}</option>
|
|
31
|
-
* ))}
|
|
32
|
-
* </select>
|
|
33
|
-
* )
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* @remarks
|
|
37
|
-
* The `projectId` is resolved in order from:
|
|
38
|
-
* 1. an explicit `projectId` option
|
|
39
|
-
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
40
|
-
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
41
|
-
* 4. `instance.config`.
|
|
42
|
-
*/
|
|
43
|
-
(options?: ProjectHandle): DatasetsResponse
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const useDatasetsBase = createStateSourceHook({
|
|
47
|
-
getState: getDatasetsState as (
|
|
48
|
-
instance: SanityInstance,
|
|
49
|
-
projectHandle?: ProjectHandle,
|
|
50
|
-
) => StateSource<DatasetsResponse>,
|
|
51
|
-
shouldSuspend: (instance, projectHandle?: ProjectHandle) =>
|
|
52
|
-
// remove `undefined` since we're suspending when that is the case
|
|
53
|
-
getDatasetsState(instance, projectHandle).getCurrent() === undefined,
|
|
54
|
-
suspender: resolveDatasets,
|
|
55
|
-
})
|
|
7
|
+
const useDatasetsBase = createFetcherHook(datasets)
|
|
56
8
|
|
|
57
9
|
/**
|
|
10
|
+
* Returns metadata for each dataset the current user has access to.
|
|
11
|
+
*
|
|
12
|
+
* @category Datasets
|
|
13
|
+
* @param options - Optional project/resource to read datasets for. Defaults to
|
|
14
|
+
* the resource named in `ResourceProvider`/`SDKProvider`.
|
|
15
|
+
* @returns A {@link FetcherHookResult} whose `data` is the metadata for the
|
|
16
|
+
* datasets.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const {data: datasets} = useDatasets()
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <select>
|
|
24
|
+
* {datasets.map((dataset) => (
|
|
25
|
+
* <option key={dataset.name}>{dataset.name}</option>
|
|
26
|
+
* ))}
|
|
27
|
+
* </select>
|
|
28
|
+
* )
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The `projectId` is resolved in order from:
|
|
33
|
+
* 1. an explicit `projectId` option
|
|
34
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
35
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
36
|
+
* 4. `instance.config`.
|
|
58
37
|
* @public
|
|
59
|
-
* @function
|
|
60
38
|
*/
|
|
61
|
-
export
|
|
39
|
+
export function useDatasets(options?: ProjectHandle): FetcherHookResult<DatasetsResponse> {
|
|
62
40
|
const projectId = useResolvedProjectId(options)
|
|
63
41
|
return useDatasetsBase(projectId ? {...options, projectId} : options)
|
|
64
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {createDocument} from '@sanity/sdk'
|
|
2
|
+
import {randomUuid} from '@sanity/sdk/_internal'
|
|
2
3
|
import {type SanityDocument} from 'groq'
|
|
3
4
|
|
|
4
5
|
import {type DocumentHandle, type DocumentTypeHandle} from '../../config/handles'
|
|
@@ -109,7 +110,7 @@ export function useCreateDocument(
|
|
|
109
110
|
const apply = useApplyDocumentActions()
|
|
110
111
|
|
|
111
112
|
return async (initialValue, overrides) => {
|
|
112
|
-
const documentId = overrides?.documentId ?? options.documentId ??
|
|
113
|
+
const documentId = overrides?.documentId ?? options.documentId ?? randomUuid()
|
|
113
114
|
const handle: DocumentHandle = {...options, documentId}
|
|
114
115
|
await apply(createDocument(handle, initialValue))
|
|
115
116
|
return handle
|
|
@@ -52,10 +52,10 @@ describe('resolveOrgResources', () => {
|
|
|
52
52
|
await resolveOrgResources(mockInstance, ORG_ID)
|
|
53
53
|
|
|
54
54
|
expect(mockRequest).toHaveBeenCalledWith(
|
|
55
|
-
expect.objectContaining({
|
|
55
|
+
expect.objectContaining({url: '/media-libraries', query: {organizationId: ORG_ID}}),
|
|
56
56
|
)
|
|
57
57
|
expect(mockRequest).toHaveBeenCalledWith(
|
|
58
|
-
expect.objectContaining({
|
|
58
|
+
expect.objectContaining({url: '/canvases', query: {organizationId: ORG_ID}}),
|
|
59
59
|
)
|
|
60
60
|
})
|
|
61
61
|
|
|
@@ -48,12 +48,12 @@ export async function resolveOrgResources(
|
|
|
48
48
|
|
|
49
49
|
const [mediaLibrariesResult, canvasesResult] = await Promise.allSettled([
|
|
50
50
|
client.request<OrgResourcesApiResponse>({
|
|
51
|
-
|
|
51
|
+
url: `/media-libraries`,
|
|
52
52
|
query: {organizationId},
|
|
53
53
|
tag: 'org-resources.media-libraries',
|
|
54
54
|
}),
|
|
55
55
|
client.request<OrgResourcesApiResponse>({
|
|
56
|
-
|
|
56
|
+
url: `/canvases`,
|
|
57
57
|
query: {organizationId},
|
|
58
58
|
tag: 'org-resources.canvases',
|
|
59
59
|
}),
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
isWorkbenchEnvironment,
|
|
9
|
-
observeWorkbenchToken,
|
|
10
|
-
refreshWorkbenchToken,
|
|
11
|
-
} from './workbenchToken'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Keeps the SDK auth token in sync with the workbench "OS".
|
|
15
|
-
*
|
|
16
|
-
* When running as a federated remote inside the workbench the OS owns the
|
|
17
|
-
* session, so we subscribe 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 WorkbenchTokenRefresh({children}: PropsWithChildren) {
|
|
24
|
-
const instance = useSanityInstance()
|
|
25
|
-
const authState = useAuthState()
|
|
26
|
-
const processed401ErrorRef = useRef<unknown | null>(null)
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
const token$ = observeWorkbenchToken()
|
|
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
|
-
refreshWorkbenchToken()
|
|
42
|
-
} else if (!has401Error) {
|
|
43
|
-
processed401ErrorRef.current = null
|
|
44
|
-
}
|
|
45
|
-
}, [authState])
|
|
46
|
-
|
|
47
|
-
return children
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Provides workbench OS token refresh. No-op outside the workbench, where the
|
|
52
|
-
* app uses its normal auth flow.
|
|
53
|
-
* @public
|
|
54
|
-
*/
|
|
55
|
-
export const WorkbenchTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
56
|
-
if (isWorkbenchEnvironment()) {
|
|
57
|
-
return <WorkbenchTokenRefresh>{children}</WorkbenchTokenRefresh>
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return children
|
|
61
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import {from, type Observable, of} from 'rxjs'
|
|
2
|
-
import {catchError, switchMap} from 'rxjs/operators'
|
|
3
|
-
|
|
4
|
-
// The workbench host installs its shared message bus on this well-known global
|
|
5
|
-
// symbol before it loads federated remotes. It must match the key used by
|
|
6
|
-
// `@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 as a federated remote inside the workbench.
|
|
11
|
-
*
|
|
12
|
-
* Federation shares the host's realm, so the installed bus is visible on
|
|
13
|
-
* `globalThis`. This is `false` in a standalone app, where we must never import
|
|
14
|
-
* `@sanity/workbench` (it would install a bus and add bundle weight for no
|
|
15
|
-
* reason). Note: this is a different embedding model to the Core UI iframe,
|
|
16
|
-
* which is detected separately via the dashboard context — that signal is not
|
|
17
|
-
* set on the federation path.
|
|
18
|
-
*
|
|
19
|
-
* @internal
|
|
20
|
-
*/
|
|
21
|
-
export function isWorkbenchEnvironment(): boolean {
|
|
22
|
-
return typeof globalThis === 'object' && OS_BUS_KEY in globalThis
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Observes the session token issued by the workbench "OS", tracking the OS auth
|
|
27
|
-
* state over time.
|
|
28
|
-
*
|
|
29
|
-
* Returns `undefined` when the app is not embedded in the workbench, so the
|
|
30
|
-
* caller uses its normal auth flow. Inside the workbench, 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 observeWorkbenchToken(): Observable<string | null> | undefined {
|
|
39
|
-
if (!isWorkbenchEnvironment()) 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 workbench "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 observeWorkbenchToken}. No-op outside
|
|
52
|
-
* the workbench.
|
|
53
|
-
*
|
|
54
|
-
* @internal
|
|
55
|
-
*/
|
|
56
|
-
export function refreshWorkbenchToken(): void {
|
|
57
|
-
if (!isWorkbenchEnvironment()) return
|
|
58
|
-
|
|
59
|
-
void import('@sanity/workbench').then(
|
|
60
|
-
({os}) => os.emit('auth.token.refresh', undefined),
|
|
61
|
-
() => {},
|
|
62
|
-
)
|
|
63
|
-
}
|