@sanity/sdk-react 0.0.0-alpha.8 → 0.0.0-chore-react-18-compat.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.
Files changed (100) hide show
  1. package/README.md +33 -126
  2. package/dist/index.d.ts +4811 -2
  3. package/dist/index.js +1069 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +23 -45
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +90 -0
  8. package/src/components/Login/LoginLinks.tsx +58 -0
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +104 -2
  12. package/src/components/SanityApp.tsx +54 -17
  13. package/src/components/auth/AuthBoundary.test.tsx +4 -4
  14. package/src/components/auth/AuthBoundary.tsx +13 -3
  15. package/src/components/auth/Login.test.tsx +1 -1
  16. package/src/components/auth/Login.tsx +11 -26
  17. package/src/components/auth/LoginCallback.test.tsx +3 -3
  18. package/src/components/auth/LoginCallback.tsx +8 -11
  19. package/src/components/auth/LoginError.tsx +12 -8
  20. package/src/components/auth/LoginFooter.tsx +13 -20
  21. package/src/components/auth/LoginLayout.tsx +8 -9
  22. package/src/components/auth/authTestHelpers.tsx +1 -8
  23. package/src/components/utils.ts +22 -0
  24. package/src/context/SanityInstanceContext.ts +4 -0
  25. package/src/context/SanityProvider.test.tsx +1 -1
  26. package/src/context/SanityProvider.tsx +10 -8
  27. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  28. package/src/hooks/auth/useAuthState.tsx +0 -2
  29. package/src/hooks/auth/useCurrentUser.tsx +27 -20
  30. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  31. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  32. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  33. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +6 -6
  34. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  35. package/src/hooks/client/useClient.ts +9 -30
  36. package/src/hooks/comlink/useFrameConnection.test.tsx +55 -10
  37. package/src/hooks/comlink/useFrameConnection.ts +39 -43
  38. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  39. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  40. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  42. package/src/hooks/comlink/useWindowConnection.test.ts +53 -12
  43. package/src/hooks/comlink/useWindowConnection.ts +69 -29
  44. package/src/hooks/context/useSanityInstance.test.tsx +1 -1
  45. package/src/hooks/context/useSanityInstance.ts +21 -5
  46. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  48. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  50. package/src/hooks/datasets/useDatasets.ts +40 -0
  51. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  52. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  53. package/src/hooks/document/useDocument.test.ts +81 -0
  54. package/src/hooks/document/useDocument.ts +107 -0
  55. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  56. package/src/hooks/document/useDocumentEvent.ts +54 -0
  57. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  58. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  59. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  60. package/src/hooks/document/useEditDocument.test.ts +179 -0
  61. package/src/hooks/document/useEditDocument.ts +195 -0
  62. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  63. package/src/hooks/documents/useDocuments.ts +174 -0
  64. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  65. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  66. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  67. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  68. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  69. package/src/hooks/preview/usePreview.test.tsx +6 -6
  70. package/src/hooks/preview/usePreview.tsx +12 -9
  71. package/src/hooks/projection/useProjection.test.tsx +218 -0
  72. package/src/hooks/projection/useProjection.ts +147 -0
  73. package/src/hooks/projects/useProject.ts +48 -0
  74. package/src/hooks/projects/useProjects.ts +45 -0
  75. package/src/hooks/query/useQuery.test.tsx +188 -0
  76. package/src/hooks/query/useQuery.ts +103 -0
  77. package/src/hooks/users/useUsers.test.ts +163 -0
  78. package/src/hooks/users/useUsers.ts +107 -0
  79. package/src/utils/getEnv.ts +21 -0
  80. package/src/version.ts +8 -0
  81. package/dist/_chunks-es/context.js +0 -8
  82. package/dist/_chunks-es/context.js.map +0 -1
  83. package/dist/_chunks-es/useLogOut.js +0 -44
  84. package/dist/_chunks-es/useLogOut.js.map +0 -1
  85. package/dist/components.d.ts +0 -111
  86. package/dist/components.js +0 -153
  87. package/dist/components.js.map +0 -1
  88. package/dist/context.d.ts +0 -45
  89. package/dist/context.js +0 -5
  90. package/dist/context.js.map +0 -1
  91. package/dist/hooks.d.ts +0 -3485
  92. package/dist/hooks.js +0 -167
  93. package/dist/hooks.js.map +0 -1
  94. package/src/_exports/components.ts +0 -2
  95. package/src/_exports/context.ts +0 -2
  96. package/src/_exports/hooks.ts +0 -27
  97. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  98. package/src/hooks/client/useClient.test.tsx +0 -130
  99. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  100. package/src/hooks/documentCollection/useDocuments.ts +0 -135
@@ -0,0 +1,63 @@
1
+ // tests/useDocumentEvent.test.ts
2
+ import {
3
+ createSanityInstance,
4
+ type DocumentEvent,
5
+ type DocumentHandle,
6
+ subscribeDocumentEvents,
7
+ } from '@sanity/sdk'
8
+ import {renderHook} from '@testing-library/react'
9
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+ import {useDocumentEvent} from './useDocumentEvent'
13
+
14
+ vi.mock('@sanity/sdk', async (importOriginal) => {
15
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
16
+ return {...original, subscribeDocumentEvents: vi.fn()}
17
+ })
18
+
19
+ vi.mock('../context/useSanityInstance', () => ({
20
+ useSanityInstance: vi.fn(),
21
+ }))
22
+
23
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
24
+ const docHandle: DocumentHandle = {
25
+ _id: 'doc1',
26
+ _type: 'book',
27
+ resourceId: 'document:p.d:doc1',
28
+ }
29
+
30
+ describe('useDocumentEvent hook', () => {
31
+ beforeEach(() => {
32
+ vi.resetAllMocks()
33
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
34
+ })
35
+
36
+ it('calls subscribeDocumentEvents with instance and a stable handler', () => {
37
+ const handler = vi.fn()
38
+ const unsubscribe = vi.fn()
39
+ vi.mocked(subscribeDocumentEvents).mockReturnValue(unsubscribe)
40
+
41
+ renderHook(() => useDocumentEvent(handler, docHandle))
42
+
43
+ expect(vi.mocked(subscribeDocumentEvents)).toHaveBeenCalledTimes(1)
44
+ expect(vi.mocked(subscribeDocumentEvents).mock.calls[0][0]).toBe(instance)
45
+
46
+ const stableHandler = vi.mocked(subscribeDocumentEvents).mock.calls[0][1]
47
+ expect(typeof stableHandler).toBe('function')
48
+
49
+ const event = {type: 'edited', documentId: 'doc1', outgoing: {}} as DocumentEvent
50
+ stableHandler(event)
51
+ expect(handler).toHaveBeenCalledWith(event)
52
+ })
53
+
54
+ it('calls the unsubscribe function on unmount', () => {
55
+ const handler = vi.fn()
56
+ const unsubscribe = vi.fn()
57
+ vi.mocked(subscribeDocumentEvents).mockReturnValue(unsubscribe)
58
+
59
+ const {unmount} = renderHook(() => useDocumentEvent(handler, docHandle))
60
+ unmount()
61
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
62
+ })
63
+ })
@@ -0,0 +1,54 @@
1
+ import {
2
+ type DocumentEvent,
3
+ type DocumentHandle,
4
+ getResourceId,
5
+ subscribeDocumentEvents,
6
+ } from '@sanity/sdk'
7
+ import {useCallback, useEffect, useInsertionEffect, useRef} from 'react'
8
+
9
+ import {useSanityInstance} from '../context/useSanityInstance'
10
+
11
+ /**
12
+ *
13
+ * @beta
14
+ *
15
+ * Subscribes an event handler to events in your application’s document store, such as document
16
+ * creation, deletion, and updates.
17
+ *
18
+ * @category Documents
19
+ * @param handler - The event handler to register.
20
+ * @param doc - The document to subscribe to events for. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
21
+ * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
22
+ * @example
23
+ * ```
24
+ * import {useDocumentEvent} from '@sanity/sdk-react'
25
+ * import {type DocumentEvent} from '@sanity/sdk'
26
+ *
27
+ * useDocumentEvent((event) => {
28
+ * if (event.type === DocumentEvent.DocumentDeletedEvent) {
29
+ * alert(`Document with ID ${event.documentId} deleted!`)
30
+ * } else {
31
+ * console.log(event)
32
+ * }
33
+ * })
34
+ * ```
35
+ */
36
+ export function useDocumentEvent(
37
+ handler: (documentEvent: DocumentEvent) => void,
38
+ doc: DocumentHandle,
39
+ ): void {
40
+ const ref = useRef(handler)
41
+
42
+ useInsertionEffect(() => {
43
+ ref.current = handler
44
+ })
45
+
46
+ const stableHandler = useCallback((documentEvent: DocumentEvent) => {
47
+ return ref.current(documentEvent)
48
+ }, [])
49
+
50
+ const instance = useSanityInstance(getResourceId(doc.resourceId))
51
+ useEffect(() => {
52
+ return subscribeDocumentEvents(instance, stableHandler)
53
+ }, [instance, stableHandler])
54
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ type DocumentAction,
3
+ type DocumentPermissionsResult,
4
+ getPermissionsState,
5
+ getResourceId,
6
+ } from '@sanity/sdk'
7
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
8
+ import {filter, firstValueFrom} from 'rxjs'
9
+
10
+ import {useSanityInstance} from '../context/useSanityInstance'
11
+
12
+ /**
13
+ *
14
+ * @beta
15
+ *
16
+ * Check if the current user has the specified permissions for the given document actions.
17
+ *
18
+ * @category Permissions
19
+ * @param actions - One more more calls to a particular document action function for a given document
20
+ * @returns An object that specifies whether the action is allowed; if the action is not allowed, an explanatory message and list of reasons is also provided.
21
+ *
22
+ * @example Checking for permission to publish a document
23
+ * ```ts
24
+ * import {useDocumentPermissions, useApplyDocumentActions} from '@sanity/sdk-react'
25
+ * import {publishDocument} from '@sanity/sdk'
26
+ *
27
+ * export function PublishButton({doc}: {doc: DocumentHandle}) {
28
+ * const publishPermissions = useDocumentPermissions(publishDocument(doc))
29
+ * const applyAction = useApplyDocumentActions()
30
+ *
31
+ * return (
32
+ * <>
33
+ * <button
34
+ * disabled={!publishPermissions.allowed}
35
+ * onClick={() => applyAction(publishDocument(doc))}
36
+ * popoverTarget={`${publishPermissions.allowed ? undefined : 'publishButtonPopover'}`}
37
+ * >
38
+ * Publish
39
+ * </button>
40
+ * {!publishPermissions.allowed && (
41
+ * <div popover id="publishButtonPopover">
42
+ * {publishPermissions.message}
43
+ * </div>
44
+ * )}
45
+ * </>
46
+ * )
47
+ * }
48
+ * ```
49
+ */
50
+ export function useDocumentPermissions(
51
+ actions: DocumentAction | DocumentAction[],
52
+ ): DocumentPermissionsResult {
53
+ // if actions is an array, we need to check each action to see if the resourceId is the same
54
+ if (Array.isArray(actions)) {
55
+ const resourceIds = actions.map((action) => action.resourceId)
56
+ const uniqueResourceIds = new Set(resourceIds)
57
+ if (uniqueResourceIds.size !== 1) {
58
+ throw new Error('All actions must have the same resourceId')
59
+ }
60
+ }
61
+ const resourceId = Array.isArray(actions)
62
+ ? getResourceId(actions[0].resourceId)
63
+ : getResourceId(actions.resourceId)
64
+
65
+ const instance = useSanityInstance(resourceId)
66
+ const isDocumentReady = useCallback(
67
+ () => getPermissionsState(instance, actions).getCurrent() !== undefined,
68
+ [actions, instance],
69
+ )
70
+ if (!isDocumentReady()) {
71
+ throw firstValueFrom(
72
+ getPermissionsState(instance, actions).observable.pipe(
73
+ filter((result) => result !== undefined),
74
+ ),
75
+ )
76
+ }
77
+
78
+ const {subscribe, getCurrent} = useMemo(
79
+ () => getPermissionsState(instance, actions),
80
+ [actions, instance],
81
+ )
82
+
83
+ return useSyncExternalStore(subscribe, getCurrent) as DocumentPermissionsResult
84
+ }
@@ -0,0 +1,16 @@
1
+ import {getDocumentSyncStatus} from '@sanity/sdk'
2
+ import {identity} from 'rxjs'
3
+ import {describe, it} from 'vitest'
4
+
5
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
6
+
7
+ vi.mock('../helpers/createStateSourceHook', () => ({createStateSourceHook: vi.fn(identity)}))
8
+ vi.mock('@sanity/sdk', () => ({getDocumentSyncStatus: vi.fn()}))
9
+
10
+ describe('useDocumentSyncStatus', () => {
11
+ it('calls `createStateSourceHook` with `getTokenState`', async () => {
12
+ const {useDocumentSyncStatus} = await import('./useDocumentSyncStatus')
13
+ expect(createStateSourceHook).toHaveBeenCalledWith(getDocumentSyncStatus)
14
+ expect(useDocumentSyncStatus).toBe(getDocumentSyncStatus)
15
+ })
16
+ })
@@ -0,0 +1,33 @@
1
+ import {type DocumentHandle, getDocumentSyncStatus} from '@sanity/sdk'
2
+
3
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
+
5
+ type UseDocumentSyncStatus = {
6
+ /**
7
+ * Exposes the document’s sync status between local and remote document states.
8
+ *
9
+ * @category Documents
10
+ * @param doc - The document handle to get sync status for. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
11
+ * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
12
+ * @returns `true` if local changes are synced with remote, `false` if the changes are not synced, and `undefined` if the document is not found
13
+ * @example Disable a Save button when there are no changes to sync
14
+ * ```
15
+ * const myDocumentHandle = { _id: 'documentId', _type: 'documentType', resourceId: 'document:projectId:dataset:documentId' }
16
+ * const documentSynced = useDocumentSyncStatus(myDocumentHandle)
17
+ *
18
+ * return (
19
+ * <button disabled={documentSynced}>
20
+ * Save Changes
21
+ * </button>
22
+ * )
23
+ * ```
24
+ */
25
+ (doc: DocumentHandle): boolean | undefined
26
+ }
27
+
28
+ /**
29
+ * @beta
30
+ * @function
31
+ */
32
+ export const useDocumentSyncStatus: UseDocumentSyncStatus =
33
+ createStateSourceHook(getDocumentSyncStatus)
@@ -0,0 +1,179 @@
1
+ // tests/useEditDocument.test.ts
2
+ import {
3
+ createSanityInstance,
4
+ type DocumentHandle,
5
+ editDocument,
6
+ getDocumentState,
7
+ resolveDocument,
8
+ type StateSource,
9
+ } from '@sanity/sdk'
10
+ import {type SanityDocument} from '@sanity/types'
11
+ import {renderHook} from '@testing-library/react'
12
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
13
+
14
+ import {useSanityInstance} from '../context/useSanityInstance'
15
+ import {useApplyDocumentActions} from './useApplyDocumentActions'
16
+ import {useEditDocument} from './useEditDocument'
17
+
18
+ vi.mock('@sanity/sdk', async (importOriginal) => {
19
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
20
+ return {
21
+ ...original,
22
+ getDocumentState: vi.fn(),
23
+ resolveDocument: vi.fn(),
24
+ editDocument: vi.fn(original.editDocument),
25
+ }
26
+ })
27
+
28
+ vi.mock('../context/useSanityInstance', () => ({
29
+ useSanityInstance: vi.fn(),
30
+ }))
31
+
32
+ vi.mock('./useApplyDocumentActions', () => ({
33
+ useApplyDocumentActions: vi.fn(),
34
+ }))
35
+
36
+ // Create a fake instance to be returned by useSanityInstance.
37
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
38
+
39
+ const doc = {
40
+ _id: 'doc1',
41
+ foo: 'bar',
42
+ _type: 'book',
43
+ _rev: 'tx0',
44
+ _createdAt: '2025-02-06T00:11:00.000Z',
45
+ _updatedAt: '2025-02-06T00:11:00.000Z',
46
+ } satisfies SanityDocument
47
+
48
+ const docHandle: DocumentHandle<SanityDocument> = {
49
+ _id: 'doc1',
50
+ _type: 'book',
51
+ resourceId: 'document:p.d:doc1',
52
+ }
53
+
54
+ describe('useEditDocument hook', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks()
57
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
58
+ })
59
+
60
+ it('applies a single edit action for the given path', async () => {
61
+ const getCurrent = vi.fn().mockReturnValue(doc)
62
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
63
+ vi.mocked(getDocumentState).mockReturnValue({
64
+ getCurrent,
65
+ subscribe,
66
+ } as unknown as StateSource<SanityDocument>)
67
+
68
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx1'})
69
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
70
+
71
+ const {result} = renderHook(() => useEditDocument(docHandle, 'foo'))
72
+ const promise = result.current('newValue')
73
+ expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'newValue'}})
74
+ expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'newValue'}}))
75
+ const actionsResult = await promise
76
+ expect(actionsResult).toEqual({transactionId: 'tx1'})
77
+ })
78
+
79
+ it('applies edit actions for changed fields', async () => {
80
+ // Set up current document state.
81
+ const currentDoc = {...doc, foo: 'bar', extra: 'old'}
82
+ const getCurrent = vi.fn().mockReturnValue(currentDoc)
83
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
84
+ vi.mocked(getDocumentState).mockReturnValue({
85
+ getCurrent,
86
+ subscribe,
87
+ } as unknown as StateSource<SanityDocument>)
88
+
89
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx2'})
90
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
91
+
92
+ const {result} = renderHook(() => useEditDocument(docHandle))
93
+ const promise = result.current({...doc, foo: 'baz', extra: 'old', _id: 'doc1'})
94
+ expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
95
+ const actionsResult = await promise
96
+ expect(actionsResult).toEqual({transactionId: 'tx2'})
97
+ })
98
+
99
+ it('applies a single edit action using an updater function for the given path', async () => {
100
+ const getCurrent = vi.fn().mockReturnValue(doc.foo)
101
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
102
+ vi.mocked(getDocumentState).mockReturnValue({
103
+ getCurrent,
104
+ subscribe,
105
+ } as unknown as StateSource<SanityDocument>)
106
+
107
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx3'})
108
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
109
+
110
+ const {result} = renderHook(() => useEditDocument(docHandle, 'foo'))
111
+ const promise = result.current((prev: unknown) => `${prev}Updated`) // 'bar' becomes 'barUpdated'
112
+ expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'barUpdated'}})
113
+ expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'barUpdated'}}))
114
+ const actionsResult = await promise
115
+ expect(actionsResult).toEqual({transactionId: 'tx3'})
116
+ })
117
+
118
+ it('applies edit actions using an updater function for the entire document', async () => {
119
+ const currentDoc = {...doc, foo: 'bar', extra: 'old'}
120
+ const getCurrent = vi.fn().mockReturnValue(currentDoc)
121
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
122
+ vi.mocked(getDocumentState).mockReturnValue({
123
+ getCurrent,
124
+ subscribe,
125
+ } as unknown as StateSource<SanityDocument>)
126
+
127
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx4'})
128
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
129
+
130
+ const {result} = renderHook(() => useEditDocument(docHandle))
131
+ const promise = result.current((prevDoc) => ({...prevDoc, foo: 'baz'}))
132
+ expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
133
+ const actionsResult = await promise
134
+ expect(actionsResult).toEqual({transactionId: 'tx4'})
135
+ })
136
+
137
+ it('throws an error if next value is not an object', () => {
138
+ const getCurrent = vi.fn().mockReturnValue(doc)
139
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
140
+ vi.mocked(getDocumentState).mockReturnValue({
141
+ getCurrent,
142
+ subscribe,
143
+ } as unknown as StateSource<SanityDocument>)
144
+
145
+ const fakeApply = vi.fn()
146
+ vi.mocked(useApplyDocumentActions).mockReturnValue(fakeApply)
147
+
148
+ const {result} = renderHook(() => useEditDocument(docHandle))
149
+ expect(() => result.current('notAnObject' as unknown as SanityDocument)).toThrowError(
150
+ 'No path was provided to `useEditDocument` and the value provided was not a document object.',
151
+ )
152
+ })
153
+
154
+ it('throws a promise (suspends) when the document is not ready', () => {
155
+ const getCurrent = vi.fn().mockReturnValue(undefined)
156
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
157
+ vi.mocked(getDocumentState).mockReturnValue({
158
+ getCurrent,
159
+ subscribe,
160
+ } as unknown as StateSource<unknown>)
161
+
162
+ const resolveDocPromise = Promise.resolve(doc)
163
+
164
+ // Also, simulate resolveDocument to return a known promise.
165
+ vi.mocked(resolveDocument).mockReturnValue(resolveDocPromise)
166
+
167
+ // Render the hook and capture the thrown promise.
168
+ const {result} = renderHook(() => {
169
+ try {
170
+ return useEditDocument(docHandle)
171
+ } catch (e) {
172
+ return e
173
+ }
174
+ })
175
+
176
+ // When the document is not ready, the hook throws the promise from resolveDocument.
177
+ expect(result.current).toBe(resolveDocPromise)
178
+ })
179
+ })
@@ -0,0 +1,195 @@
1
+ import {
2
+ type ActionsResult,
3
+ type DocumentHandle,
4
+ editDocument,
5
+ getDocumentState,
6
+ getResourceId,
7
+ type JsonMatch,
8
+ type JsonMatchPath,
9
+ resolveDocument,
10
+ } from '@sanity/sdk'
11
+ import {type SanityDocument} from '@sanity/types'
12
+ import {useCallback} from 'react'
13
+
14
+ import {useSanityInstance} from '../context/useSanityInstance'
15
+ import {useApplyDocumentActions} from './useApplyDocumentActions'
16
+
17
+ const ignoredKeys = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
18
+
19
+ type Updater<TValue> = TValue | ((nextValue: TValue) => TValue)
20
+
21
+ /**
22
+ *
23
+ * @beta
24
+ *
25
+ * ## useEditDocument(doc, path)
26
+ * Edit a nested value within a document
27
+ *
28
+ * @category Documents
29
+ * @param doc - The document to be edited; either as a document handle or the document’s ID a string
30
+ * @param path - The path to the nested value to be edited
31
+ * @returns A function to update the nested value. Accepts either a new value, or an updater function that exposes the previous value and returns a new value.
32
+ * @example Update a document’s name by providing the new value directly
33
+ * ```
34
+ * const handle = { _id: 'documentId', _type: 'documentType' }
35
+ * const name = useDocument(handle, 'name')
36
+ * const editName = useEditDocument(handle, 'name')
37
+ *
38
+ * function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
39
+ * editName(event.target.value)
40
+ * }
41
+ *
42
+ * return (
43
+ * <input type='text' value={name} onChange={handleNameChange} />
44
+ * )
45
+ * ```
46
+ *
47
+ * @example Update a count on a document by providing an updater function
48
+ * ```
49
+ * const handle = { _id: 'documentId', _type: 'documentType' }
50
+ * const count = useDocument(handle, 'count')
51
+ * const editCount = useEditDocument(handle, 'count')
52
+ *
53
+ * function incrementCount() {
54
+ * editCount(previousCount => previousCount + 1)
55
+ * }
56
+ *
57
+ * return (
58
+ * <>
59
+ * <button onClick={incrementCount}>
60
+ * Increment
61
+ * </button>
62
+ * Current count: {count}
63
+ * </>
64
+ * )
65
+ * ```
66
+ */
67
+ export function useEditDocument<
68
+ TDocument extends SanityDocument,
69
+ TPath extends JsonMatchPath<TDocument>,
70
+ >(
71
+ doc: DocumentHandle<TDocument>,
72
+ path: TPath,
73
+ ): (nextValue: Updater<JsonMatch<TDocument, TPath>>) => Promise<ActionsResult<TDocument>>
74
+
75
+ /**
76
+ *
77
+ * @beta
78
+ *
79
+ * ## useEditDocument(doc)
80
+ * Edit an entire document
81
+ * @param doc - The document to be edited; either as a document handle or the document’s ID a string. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
82
+ * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
83
+ * @returns A function to update the document state. Accepts either a new document state, or an updater function that exposes the previous document state and returns the new document state.
84
+ * @example
85
+ * ```
86
+ * const myDocumentHandle = { _id: 'documentId', _type: 'documentType' }
87
+ *
88
+ * const myDocument = useDocument(myDocumentHandle)
89
+ * const { title, price } = myDocument
90
+ *
91
+ * const editMyDocument = useEditDocument(myDocumentHandle)
92
+ *
93
+ * function handleFieldChange(e: React.ChangeEvent<HTMLInputElement>) {
94
+ * const {name, value} = e.currentTarget
95
+ * // Use an updater function to update the document state based on the previous state
96
+ * editMyDocument(previousDocument => ({
97
+ * ...previousDocument,
98
+ * [name]: value
99
+ * }))
100
+ * }
101
+ *
102
+ * function handleSaleChange(e: React.ChangeEvent<HTMLInputElement>) {
103
+ * const { checked } = e.currentTarget
104
+ * if (checked) {
105
+ * // Use an updater function to add a new salePrice field;
106
+ * // set it at a 20% discount off the normal price
107
+ * editMyDocument(previousDocument => ({
108
+ * ...previousDocument,
109
+ * salePrice: previousDocument.price * 0.8,
110
+ * }))
111
+ * } else {
112
+ * // Get the document state without the salePrice field
113
+ * const { salePrice, ...rest } = myDocument
114
+ * // Update the document state to remove the salePrice field
115
+ * editMyDocument(rest)
116
+ * }
117
+ * }
118
+ *
119
+ * return (
120
+ * <>
121
+ * <form onSubmit={e => e.preventDefault()}>
122
+ * <input name='title' type='text' value={title} onChange={handleFieldChange} />
123
+ * <input name='price' type='number' value={price} onChange={handleFieldChange} />
124
+ * <input
125
+ * name='salePrice'
126
+ * type='checkbox'
127
+ * checked={Object(myDocument).hasOwnProperty('salePrice')}
128
+ * onChange={handleSaleChange}
129
+ * />
130
+ * </form>
131
+ * <pre><code>
132
+ * {JSON.stringify(myDocument, null, 2)}
133
+ * </code></pre>
134
+ * </>
135
+ * )
136
+ * ```
137
+ */
138
+ export function useEditDocument<TDocument extends SanityDocument>(
139
+ doc: DocumentHandle<TDocument>,
140
+ ): (nextValue: Updater<TDocument>) => Promise<ActionsResult<TDocument>>
141
+
142
+ /**
143
+ *
144
+ * @beta
145
+ *
146
+ * Enables editing of a document’s state.
147
+ * When called with a `path` argument, the hook will return a function for updating a nested value.
148
+ * When called without a `path` argument, the hook will return a function for updating the entire document.
149
+ */
150
+ export function useEditDocument(
151
+ doc: DocumentHandle,
152
+ path?: string,
153
+ ): (updater: Updater<unknown>) => Promise<ActionsResult> {
154
+ const resourceId = getResourceId(doc.resourceId)!
155
+ const documentId = doc._id
156
+ const instance = useSanityInstance(resourceId)
157
+ const apply = useApplyDocumentActions(resourceId)
158
+ const isDocumentReady = useCallback(
159
+ () => getDocumentState(instance, documentId).getCurrent() !== undefined,
160
+ [instance, documentId],
161
+ )
162
+ if (!isDocumentReady()) throw resolveDocument(instance, documentId)
163
+
164
+ return (updater: Updater<unknown>) => {
165
+ if (path) {
166
+ const nextValue =
167
+ typeof updater === 'function'
168
+ ? updater(getDocumentState(instance, documentId, path).getCurrent())
169
+ : updater
170
+
171
+ return apply(editDocument(doc, {set: {[path]: nextValue}}))
172
+ }
173
+
174
+ const current = getDocumentState(instance, documentId).getCurrent()
175
+ const nextValue = typeof updater === 'function' ? updater(current) : updater
176
+
177
+ if (typeof nextValue !== 'object' || !nextValue) {
178
+ throw new Error(
179
+ `No path was provided to \`useEditDocument\` and the value provided was not a document object.`,
180
+ )
181
+ }
182
+
183
+ const allKeys = Object.keys({...current, ...nextValue})
184
+ const editActions = allKeys
185
+ .filter((key) => !ignoredKeys.includes(key))
186
+ .filter((key) => current?.[key] !== nextValue[key])
187
+ .map((key) =>
188
+ key in nextValue
189
+ ? editDocument(doc, {set: {[key]: nextValue[key]}})
190
+ : editDocument(doc, {unset: [key]}),
191
+ )
192
+
193
+ return apply(editActions)
194
+ }
195
+ }