@sanity/sdk-react 0.0.0-rc.6 → 0.0.0-rc.8

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.
Files changed (40) hide show
  1. package/README.md +5 -57
  2. package/dist/index.d.ts +998 -437
  3. package/dist/index.js +324 -258
  4. package/dist/index.js.map +1 -1
  5. package/package.json +16 -15
  6. package/src/_exports/sdk-react.ts +4 -1
  7. package/src/components/SDKProvider.tsx +6 -1
  8. package/src/components/SanityApp.test.tsx +29 -47
  9. package/src/components/SanityApp.tsx +12 -11
  10. package/src/components/auth/AuthBoundary.test.tsx +177 -7
  11. package/src/components/auth/AuthBoundary.tsx +31 -1
  12. package/src/components/auth/ConfigurationError.ts +22 -0
  13. package/src/components/auth/LoginError.tsx +9 -3
  14. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  15. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  16. package/src/hooks/client/useClient.ts +3 -3
  17. package/src/hooks/comlink/useManageFavorite.test.ts +276 -27
  18. package/src/hooks/comlink/useManageFavorite.ts +102 -51
  19. package/src/hooks/comlink/useWindowConnection.ts +3 -2
  20. package/src/hooks/document/useApplyDocumentActions.ts +105 -31
  21. package/src/hooks/document/useDocument.test.ts +41 -4
  22. package/src/hooks/document/useDocument.ts +198 -114
  23. package/src/hooks/document/useDocumentEvent.test.ts +5 -5
  24. package/src/hooks/document/useDocumentEvent.ts +67 -23
  25. package/src/hooks/document/useDocumentPermissions.ts +47 -8
  26. package/src/hooks/document/useDocumentSyncStatus.test.ts +12 -5
  27. package/src/hooks/document/useDocumentSyncStatus.ts +41 -14
  28. package/src/hooks/document/useEditDocument.test.ts +24 -6
  29. package/src/hooks/document/useEditDocument.ts +238 -133
  30. package/src/hooks/documents/useDocuments.test.tsx +1 -1
  31. package/src/hooks/documents/useDocuments.ts +153 -44
  32. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +1 -1
  33. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +120 -47
  34. package/src/hooks/projection/useProjection.ts +134 -46
  35. package/src/hooks/query/useQuery.test.tsx +4 -4
  36. package/src/hooks/query/useQuery.ts +115 -43
  37. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  38. package/src/hooks/releases/useActiveReleases.ts +39 -0
  39. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  40. package/src/hooks/releases/usePerspective.ts +49 -0
@@ -7,17 +7,21 @@ import {
7
7
  SDK_NODE_NAME,
8
8
  type StudioResource,
9
9
  } from '@sanity/message-protocol'
10
- import {type DocumentHandle, type FrameMessage} from '@sanity/sdk'
11
- import {useCallback, useEffect, useState} from 'react'
10
+ import {
11
+ type DocumentHandle,
12
+ type FavoriteStatusResponse,
13
+ type FrameMessage,
14
+ getFavoritesState,
15
+ resolveFavoritesState,
16
+ } from '@sanity/sdk'
17
+ import {useCallback, useMemo, useState, useSyncExternalStore} from 'react'
12
18
 
13
19
  import {useSanityInstance} from '../context/useSanityInstance'
14
20
  import {useWindowConnection} from './useWindowConnection'
15
21
 
16
- // should we import this whole type from the message protocol?
17
-
18
- interface ManageFavorite {
19
- favorite: () => void
20
- unfavorite: () => void
22
+ interface ManageFavorite extends FavoriteStatusResponse {
23
+ favorite: () => Promise<void>
24
+ unfavorite: () => Promise<void>
21
25
  isFavorited: boolean
22
26
  isConnected: boolean
23
27
  }
@@ -46,7 +50,7 @@ interface UseManageFavoriteProps extends DocumentHandle {
46
50
  *
47
51
  * @example
48
52
  * ```tsx
49
- * function MyDocumentAction(props: DocumentActionProps) {
53
+ * function FavoriteButton(props: DocumentActionProps) {
50
54
  * const {documentId, documentType} = props
51
55
  * const {favorite, unfavorite, isFavorited, isConnected} = useManageFavorite({
52
56
  * documentId,
@@ -61,6 +65,22 @@ interface UseManageFavoriteProps extends DocumentHandle {
61
65
  * />
62
66
  * )
63
67
  * }
68
+ *
69
+ * // Wrap the component with Suspense since the hook may suspend
70
+ * function MyDocumentAction(props: DocumentActionProps) {
71
+ * return (
72
+ * <Suspense
73
+ * fallback={
74
+ * <Button
75
+ * text="Loading..."
76
+ * disabled
77
+ * />
78
+ * }
79
+ * >
80
+ * <FavoriteButton {...props} />
81
+ * </Suspense>
82
+ * )
83
+ * }
64
84
  * ```
65
85
  */
66
86
  export function useManageFavorite({
@@ -72,10 +92,8 @@ export function useManageFavorite({
72
92
  resourceType,
73
93
  schemaName,
74
94
  }: UseManageFavoriteProps): ManageFavorite {
75
- const [isFavorited, setIsFavorited] = useState(false) // should load this from a comlink fetch
76
95
  const [status, setStatus] = useState<Status>('idle')
77
- const [resourceId, setResourceId] = useState<string>(paramResourceId || '')
78
- const {sendMessage} = useWindowConnection<Events.FavoriteMessage, FrameMessage>({
96
+ const {fetch} = useWindowConnection<Events.FavoriteMessage, FrameMessage>({
79
97
  name: SDK_NODE_NAME,
80
98
  connectTo: SDK_CHANNEL_NAME,
81
99
  onStatus: setStatus,
@@ -90,65 +108,98 @@ export function useManageFavorite({
90
108
  if (resourceType === 'studio' && (!projectId || !dataset)) {
91
109
  throw new Error('projectId and dataset are required for studio resources')
92
110
  }
111
+ // Compute the final resourceId
112
+ const resourceId =
113
+ resourceType === 'studio' && !paramResourceId ? `${projectId}.${dataset}` : paramResourceId
93
114
 
94
- useEffect(() => {
95
- // If resourceType is studio and the resourceId is not provided,
96
- // use the projectId and dataset to generate a resourceId
97
- if (resourceType === 'studio' && !paramResourceId) {
98
- setResourceId(`${projectId}.${dataset}`)
99
- } else if (paramResourceId) {
100
- setResourceId(paramResourceId)
101
- } else {
102
- // For other resource types, resourceId is required
103
- throw new Error('resourceId is required for media-library and canvas resources')
104
- }
105
- }, [resourceType, paramResourceId, projectId, dataset])
115
+ if (!resourceId) {
116
+ throw new Error('resourceId is required for media-library and canvas resources')
117
+ }
118
+
119
+ // used for favoriteStore functions like getFavoritesState and resolveFavoritesState
120
+ const context = useMemo(
121
+ () => ({
122
+ documentId,
123
+ documentType,
124
+ resourceId,
125
+ resourceType,
126
+ schemaName,
127
+ }),
128
+ [documentId, documentType, resourceId, resourceType, schemaName],
129
+ )
130
+
131
+ // Get favorite status using StateSource
132
+ const favoriteState = getFavoritesState(instance, context)
133
+ const state = useSyncExternalStore(favoriteState.subscribe, favoriteState.getCurrent)
134
+
135
+ const isFavorited = state?.isFavorited ?? false
106
136
 
107
137
  const handleFavoriteAction = useCallback(
108
- (action: 'added' | 'removed', setFavoriteState: boolean) => {
109
- if (!documentId || !documentType || !resourceType) return
138
+ async (action: 'added' | 'removed') => {
139
+ if (status !== 'connected' || !fetch || !documentId || !documentType || !resourceType) return
110
140
 
111
141
  try {
112
- const message: Events.FavoriteMessage = {
113
- type: 'dashboard/v1/events/favorite/mutate',
114
- data: {
115
- eventType: action,
116
- document: {
117
- id: documentId,
118
- type: documentType,
119
- resource: {
142
+ const payload = {
143
+ eventType: action,
144
+ document: {
145
+ id: documentId,
146
+ type: documentType,
147
+ resource: {
148
+ ...{
120
149
  id: resourceId,
121
150
  type: resourceType,
122
- schemaName,
123
151
  },
152
+ ...(schemaName ? {schemaName} : {}),
124
153
  },
125
154
  },
126
- response: {
127
- success: true,
128
- },
129
155
  }
130
156
 
131
- sendMessage(message.type, message.data)
132
- setIsFavorited(setFavoriteState)
157
+ const res = await fetch<{success: boolean}>('dashboard/v1/events/favorite/mutate', payload)
158
+ if (res.success) {
159
+ // Force a re-fetch of the favorite status after successful mutation
160
+ await resolveFavoritesState(instance, context)
161
+ }
133
162
  } catch (err) {
134
- const error = err instanceof Error ? err : new Error('Failed to update favorite status')
135
163
  // eslint-disable-next-line no-console
136
- console.error(
137
- `Failed to ${action === 'added' ? 'favorite' : 'unfavorite'} document:`,
138
- error,
139
- )
140
- throw error
164
+ console.error(`Failed to ${action === 'added' ? 'favorite' : 'unfavorite'} document:`, err)
165
+ throw err
141
166
  }
142
167
  },
143
- [documentId, documentType, resourceId, resourceType, sendMessage, schemaName],
168
+ [
169
+ fetch,
170
+ documentId,
171
+ documentType,
172
+ resourceId,
173
+ resourceType,
174
+ schemaName,
175
+ instance,
176
+ context,
177
+ status,
178
+ ],
144
179
  )
145
180
 
146
- const favorite = useCallback(() => handleFavoriteAction('added', true), [handleFavoriteAction])
181
+ const favorite = useCallback(() => handleFavoriteAction('added'), [handleFavoriteAction])
182
+ const unfavorite = useCallback(() => handleFavoriteAction('removed'), [handleFavoriteAction])
147
183
 
148
- const unfavorite = useCallback(
149
- () => handleFavoriteAction('removed', false),
150
- [handleFavoriteAction],
151
- )
184
+ // if state is undefined, we should suspend
185
+ if (!state) {
186
+ try {
187
+ const promise = resolveFavoritesState(instance, context)
188
+ throw promise
189
+ } catch (err) {
190
+ // If we get a timeout error, return a fallback state instead of suspending
191
+ if (err instanceof Error && err.message === 'Favorites service connection timeout') {
192
+ return {
193
+ favorite: async () => {},
194
+ unfavorite: async () => {},
195
+ isFavorited: false,
196
+ isConnected: false,
197
+ }
198
+ }
199
+ // For other errors, continue with suspension
200
+ throw err
201
+ }
202
+ }
152
203
 
153
204
  return {
154
205
  favorite,
@@ -63,8 +63,6 @@ export function useWindowConnection<
63
63
  const instance = useSanityInstance()
64
64
 
65
65
  useEffect(() => {
66
- // the type cast is unfortunate, but the generic type of the node is not known here.
67
- // We know that the node is a WindowMessage node, but not the generic types.
68
66
  const node = getOrCreateNode(instance, {
69
67
  name,
70
68
  connectTo,
@@ -111,6 +109,9 @@ export function useWindowConnection<
111
109
  suppressWarnings?: boolean
112
110
  },
113
111
  ): Promise<TResponse> => {
112
+ if (!nodeRef.current) {
113
+ throw new Error('Cannot fetch before connection is established')
114
+ }
114
115
  return nodeRef.current?.fetch(type, data, fetchOptions ?? {}) as Promise<TResponse>
115
116
  },
116
117
  [],
@@ -1,50 +1,124 @@
1
- import {applyDocumentActions} from '@sanity/sdk'
1
+ import {
2
+ type ActionsResult,
3
+ applyDocumentActions,
4
+ type ApplyDocumentActionsOptions,
5
+ type DocumentAction,
6
+ } from '@sanity/sdk'
7
+ import {type SanityDocumentResult} from 'groq'
2
8
 
3
9
  import {createCallbackHook} from '../helpers/createCallbackHook'
10
+ // this import is used in an `{@link useEditDocument}`
11
+ // eslint-disable-next-line unused-imports/no-unused-imports, import/consistent-type-specifier-style
12
+ import type {useEditDocument} from './useEditDocument'
13
+
14
+ /**
15
+ * @beta
16
+ */
17
+ interface UseApplyDocumentActions {
18
+ (): <
19
+ TDocumentType extends string = string,
20
+ TDataset extends string = string,
21
+ TProjectId extends string = string,
22
+ >(
23
+ action:
24
+ | DocumentAction<TDocumentType, TDataset, TProjectId>
25
+ | DocumentAction<TDocumentType, TDataset, TProjectId>[],
26
+ options?: ApplyDocumentActionsOptions,
27
+ ) => Promise<ActionsResult<SanityDocumentResult<TDocumentType, TDataset, TProjectId>>>
28
+ }
4
29
 
5
30
  /**
6
- *
7
31
  * @beta
8
32
  *
9
- * Provides a callback for applying one or more actions to a document.
33
+ * Provides a stable callback function for applying one or more document actions.
34
+ *
35
+ * This hook wraps the core `applyDocumentActions` functionality from `@sanity/sdk`,
36
+ * integrating it with the React component lifecycle and {@link SanityInstance}.
37
+ * It allows you to apply actions generated by functions like `createDocument`,
38
+ * `editDocument`, `deleteDocument`, `publishDocument`, `unpublishDocument`,
39
+ * and `discardDocument` to documents.
40
+ *
41
+ * Features:
42
+ * - Applies one or multiple `DocumentAction` objects.
43
+ * - Supports optimistic updates: Local state reflects changes immediately.
44
+ * - Handles batching: Multiple actions passed together are sent as a single atomic transaction.
45
+ * - Integrates with the collaborative editing engine for conflict resolution and state synchronization.
10
46
  *
11
47
  * @category Documents
12
- * @param dataset - An optional dataset handle with projectId and dataset. If not provided, the nearest SanityInstance from context will be used.
13
- * @returns A function that takes one more more {@link DocumentAction}s and returns a promise that resolves to an {@link ActionsResult}.
48
+ * @returns A stable callback function. When called with a single `DocumentAction` or an array of `DocumentAction`s,
49
+ * it returns a promise that resolves to an {@link ActionsResult}. The `ActionsResult` contains information about the
50
+ * outcome, including optimistic results if applicable.
51
+ *
52
+ * @remarks
53
+ * This hook is a fundamental part of interacting with document state programmatically.
54
+ * It operates within the same unified pipeline as other document hooks like `useDocument` (for reading state)
55
+ * and {@link useEditDocument} (a higher-level hook specifically for edits).
56
+ *
57
+ * When multiple actions are provided in a single call, they are guaranteed to be submitted
58
+ * as a single transaction to Content Lake. This ensures atomicity for related operations (e.g., creating and publishing a document).
59
+ *
60
+ * @function
61
+ *
14
62
  * @example Publish or unpublish a document
15
- * ```
16
- * import { publishDocument, unpublishDocument } from '@sanity/sdk'
17
- * import { useApplyDocumentActions } from '@sanity/sdk-react'
63
+ * ```tsx
64
+ * import {
65
+ * publishDocument,
66
+ * unpublishDocument,
67
+ * useApplyDocumentActions,
68
+ * type DocumentHandle
69
+ * } from '@sanity/sdk-react'
70
+ *
71
+ * // Define props using the DocumentHandle type
72
+ * interface PublishControlsProps {
73
+ * doc: DocumentHandle
74
+ * }
75
+ *
76
+ * function PublishControls({doc}: PublishControlsProps) {
77
+ * const apply = useApplyDocumentActions()
18
78
  *
19
- * const apply = useApplyDocumentActions()
20
- * const myDocument = { documentId: 'my-document-id', documentType: 'my-document-type' }
79
+ * const handlePublish = () => apply(publishDocument(doc))
80
+ * const handleUnpublish = () => apply(unpublishDocument(doc))
21
81
  *
22
- * return (
23
- * <button onClick={() => apply(publishDocument(myDocument))}>Publish</button>
24
- * <button onClick={() => apply(unpublishDocument(myDocument))}>Unpublish</button>
25
- * )
82
+ * return (
83
+ * <>
84
+ * <button onClick={handlePublish}>Publish</button>
85
+ * <button onClick={handleUnpublish}>Unpublish</button>
86
+ * </>
87
+ * )
88
+ * }
26
89
  * ```
27
90
  *
28
91
  * @example Create and publish a new document
29
- * ```
30
- * import { createDocument, publishDocument } from '@sanity/sdk'
31
- * import { useApplyDocumentActions } from '@sanity/sdk-react'
92
+ * ```tsx
93
+ * import {
94
+ * createDocument,
95
+ * publishDocument,
96
+ * createDocumentHandle,
97
+ * useApplyDocumentActions
98
+ * } from '@sanity/sdk-react'
32
99
  *
33
- * const apply = useApplyDocumentActions()
100
+ * function CreateAndPublishButton({documentType}: {documentType: string}) {
101
+ * const apply = useApplyDocumentActions()
34
102
  *
35
- * const handleCreateAndPublish = () => {
36
- * const handle = { documentId: window.crypto.randomUUID(), documentType: 'my-document-type' }
37
- * apply([
38
- * createDocument(handle),
39
- * publishDocument(handle),
40
- * ])
41
- * }
103
+ * const handleCreateAndPublish = () => {
104
+ * // Create a new handle inside the handler
105
+ * const newDocHandle = createDocumentHandle({ documentId: crypto.randomUUID(), documentType })
42
106
  *
43
- * return (
44
- * <button onClick={handleCreateAndPublish}>
45
- * I'm feeling lucky
46
- * </button>
47
- * )
107
+ * // Apply multiple actions for the new handle as a single transaction
108
+ * apply([
109
+ * createDocument(newDocHandle),
110
+ * publishDocument(newDocHandle),
111
+ * ])
112
+ * }
113
+ *
114
+ * return (
115
+ * <button onClick={handleCreateAndPublish}>
116
+ * I'm feeling lucky
117
+ * </button>
118
+ * )
119
+ * }
48
120
  * ```
49
121
  */
50
- export const useApplyDocumentActions = createCallbackHook(applyDocumentActions)
122
+ export const useApplyDocumentActions = createCallbackHook(
123
+ applyDocumentActions,
124
+ ) as UseApplyDocumentActions
@@ -7,6 +7,7 @@ import {
7
7
  } from '@sanity/sdk'
8
8
  import {type SanityDocument} from '@sanity/types'
9
9
  import {renderHook} from '@testing-library/react'
10
+ import {type DatasetScoped} from 'groq'
10
11
  import {beforeEach, describe, expect, it, vi} from 'vitest'
11
12
 
12
13
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -21,9 +22,45 @@ vi.mock('../context/useSanityInstance', () => ({
21
22
  useSanityInstance: vi.fn(),
22
23
  }))
23
24
 
25
+ // Define a single generic TestDocument type
26
+ type UseDocumentTestType = DatasetScoped<
27
+ SanityDocument & {
28
+ _type: 'use-document-test-type'
29
+ foo?: string
30
+ extra?: boolean
31
+ title?: string
32
+ nested?: {
33
+ value?: number
34
+ }
35
+ },
36
+ 'use-document-test-dataset',
37
+ 'p'
38
+ >
39
+
40
+ type UseDocumentTestTypeAlt = DatasetScoped<
41
+ SanityDocument & {
42
+ _type: 'use-document-test-type'
43
+ bar: string[]
44
+ nested?: {
45
+ value?: number
46
+ }
47
+ },
48
+ 'use-document-test-alt-dataset',
49
+ 'p'
50
+ >
51
+
52
+ // Scope the TestDocument type to the project/datasets used in tests
53
+
54
+ declare module 'groq' {
55
+ interface SanitySchemas {
56
+ 'p:use-document-test-dataset': UseDocumentTestType
57
+ 'p:use-document-test-alt-dataset': UseDocumentTestTypeAlt
58
+ }
59
+ }
60
+
24
61
  // Create a fake instance to be returned by useSanityInstance.
25
62
  const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
26
- const doc: SanityDocument = {
63
+ const book: SanityDocument = {
27
64
  _id: 'doc1',
28
65
  foo: 'bar',
29
66
  _type: 'book',
@@ -39,7 +76,7 @@ describe('useDocument hook', () => {
39
76
  })
40
77
 
41
78
  it('returns the current document when ready (without a path)', () => {
42
- const getCurrent = vi.fn().mockReturnValue(doc)
79
+ const getCurrent = vi.fn().mockReturnValue(book)
43
80
  const subscribe = vi.fn().mockReturnValue(vi.fn())
44
81
  vi.mocked(getDocumentState).mockReturnValue({
45
82
  getCurrent,
@@ -48,7 +85,7 @@ describe('useDocument hook', () => {
48
85
 
49
86
  const {result} = renderHook(() => useDocument({documentId: 'doc1', documentType: 'book'}))
50
87
 
51
- expect(result.current).toEqual(doc)
88
+ expect(result.current).toEqual(book)
52
89
  expect(getCurrent).toHaveBeenCalled()
53
90
  expect(subscribe).toHaveBeenCalled()
54
91
  })
@@ -61,7 +98,7 @@ describe('useDocument hook', () => {
61
98
  subscribe,
62
99
  } as unknown as StateSource<unknown>)
63
100
 
64
- const resolveDocPromise = Promise.resolve(doc)
101
+ const resolveDocPromise = Promise.resolve(book)
65
102
 
66
103
  // Also, simulate resolveDocument to return a known promise.
67
104
  vi.mocked(resolveDocument).mockReturnValue(resolveDocPromise)