@sanity/sdk-react 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +210 -4
- package/dist/index.js +138 -43
- package/dist/index.js.map +1 -1
- package/package.json +16 -15
- package/src/_exports/sdk-react.ts +8 -0
- package/src/components/auth/AuthBoundary.tsx +9 -4
- package/src/components/auth/LoginError.tsx +27 -7
- package/src/context/ComlinkTokenRefresh.test.tsx +126 -6
- package/src/context/ComlinkTokenRefresh.tsx +2 -1
- package/src/hooks/agent/agentActions.test.tsx +78 -0
- package/src/hooks/agent/agentActions.ts +136 -0
- package/src/hooks/client/useClient.test.tsx +42 -0
- package/src/hooks/client/useClient.ts +11 -4
- package/src/hooks/dashboard/types.ts +12 -0
- package/src/hooks/dashboard/useDispatchIntent.test.ts +242 -0
- package/src/hooks/dashboard/useDispatchIntent.ts +158 -0
- package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.test.ts +155 -0
- package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.ts +53 -0
- package/src/hooks/document/useApplyDocumentActions.ts +31 -0
- package/src/hooks/projection/useDocumentProjection.ts +14 -3
- package/src/hooks/users/useUsers.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -51,33 +51,34 @@
|
|
|
51
51
|
"react-compiler-runtime": "19.1.0-rc.2",
|
|
52
52
|
"react-error-boundary": "^5.0.0",
|
|
53
53
|
"rxjs": "^7.8.2",
|
|
54
|
-
"@sanity/sdk": "2.
|
|
54
|
+
"@sanity/sdk": "2.4.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
58
|
-
"@sanity/comlink": "^3.
|
|
58
|
+
"@sanity/comlink": "^3.1.1",
|
|
59
59
|
"@sanity/pkg-utils": "^7.2.2",
|
|
60
|
-
"@sanity/prettier-config": "^1.0.
|
|
61
|
-
"@testing-library/jest-dom": "^6.
|
|
60
|
+
"@sanity/prettier-config": "^1.0.6",
|
|
61
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
62
62
|
"@testing-library/react": "^16.3.0",
|
|
63
|
-
"@types/
|
|
64
|
-
"@types/react
|
|
65
|
-
"@
|
|
66
|
-
"@
|
|
63
|
+
"@types/node": "^22.19.1",
|
|
64
|
+
"@types/react": "^19.2.7",
|
|
65
|
+
"@types/react-dom": "^19.2.3",
|
|
66
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
67
|
+
"@vitest/coverage-v8": "3.2.4",
|
|
67
68
|
"babel-plugin-react-compiler": "19.1.0-rc.1",
|
|
68
69
|
"eslint": "^9.22.0",
|
|
69
|
-
"groq-js": "^1.
|
|
70
|
+
"groq-js": "^1.22.0",
|
|
70
71
|
"jsdom": "^25.0.1",
|
|
71
|
-
"prettier": "^3.
|
|
72
|
-
"react": "^19.1
|
|
73
|
-
"react-dom": "^19.1
|
|
72
|
+
"prettier": "^3.7.3",
|
|
73
|
+
"react": "^19.2.1",
|
|
74
|
+
"react-dom": "^19.2.1",
|
|
74
75
|
"rollup-plugin-visualizer": "^5.14.0",
|
|
75
76
|
"typescript": "^5.8.3",
|
|
76
77
|
"vite": "^6.3.4",
|
|
77
|
-
"vitest": "^3.
|
|
78
|
+
"vitest": "^3.2.4",
|
|
78
79
|
"@repo/config-eslint": "0.0.0",
|
|
79
|
-
"@repo/config-test": "0.0.1",
|
|
80
80
|
"@repo/package.bundle": "3.82.0",
|
|
81
|
+
"@repo/config-test": "0.0.1",
|
|
81
82
|
"@repo/package.config": "0.0.1",
|
|
82
83
|
"@repo/tsconfig": "0.0.1"
|
|
83
84
|
},
|
|
@@ -6,6 +6,13 @@ export {SanityApp, type SanityAppProps} from '../components/SanityApp'
|
|
|
6
6
|
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
|
|
7
7
|
export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
|
|
8
8
|
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
|
|
9
|
+
export {
|
|
10
|
+
useAgentGenerate,
|
|
11
|
+
useAgentPatch,
|
|
12
|
+
useAgentPrompt,
|
|
13
|
+
useAgentTransform,
|
|
14
|
+
useAgentTranslate,
|
|
15
|
+
} from '../hooks/agent/agentActions'
|
|
9
16
|
export {useAuthState} from '../hooks/auth/useAuthState'
|
|
10
17
|
export {useAuthToken} from '../hooks/auth/useAuthToken'
|
|
11
18
|
export {useCurrentUser} from '../hooks/auth/useCurrentUser'
|
|
@@ -29,6 +36,7 @@ export {
|
|
|
29
36
|
} from '../hooks/comlink/useWindowConnection'
|
|
30
37
|
export {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
31
38
|
export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
|
|
39
|
+
export {useDispatchIntent} from '../hooks/dashboard/useDispatchIntent'
|
|
32
40
|
export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
|
|
33
41
|
export {
|
|
34
42
|
type NavigateToStudioResult,
|
|
@@ -7,6 +7,7 @@ import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
|
|
|
7
7
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
8
8
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
9
9
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
10
|
+
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
10
11
|
import {CorsErrorComponent} from '../errors/CorsErrorComponent'
|
|
11
12
|
import {isInIframe} from '../utils'
|
|
12
13
|
import {AuthError} from './AuthError'
|
|
@@ -154,17 +155,21 @@ function AuthSwitch({
|
|
|
154
155
|
...props
|
|
155
156
|
}: AuthSwitchProps) {
|
|
156
157
|
const authState = useAuthState()
|
|
157
|
-
const
|
|
158
|
+
const instance = useSanityInstance()
|
|
159
|
+
const studioModeEnabled = instance.config.studioMode?.enabled
|
|
160
|
+
const disableVerifyOrg =
|
|
161
|
+
!verifyOrganization || studioModeEnabled || authState.type !== AuthStateType.LOGGED_IN
|
|
162
|
+
const orgError = useVerifyOrgProjects(disableVerifyOrg, projectIds)
|
|
158
163
|
|
|
159
164
|
const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
|
|
160
165
|
const loginUrl = useLoginUrl()
|
|
161
166
|
|
|
162
167
|
useEffect(() => {
|
|
163
|
-
if (isLoggedOut && !isInIframe()) {
|
|
164
|
-
// We don't want to redirect to login if we're in the Dashboard
|
|
168
|
+
if (isLoggedOut && !isInIframe() && !studioModeEnabled) {
|
|
169
|
+
// We don't want to redirect to login if we're in the Dashboard nor in studio mode
|
|
165
170
|
window.location.href = loginUrl
|
|
166
171
|
}
|
|
167
|
-
}, [isLoggedOut, loginUrl])
|
|
172
|
+
}, [isLoggedOut, loginUrl, studioModeEnabled])
|
|
168
173
|
|
|
169
174
|
// Only check the error if verification is enabled
|
|
170
175
|
if (verifyOrganization && orgError) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import {ClientError} from '@sanity/client'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AuthStateType,
|
|
4
|
+
getClientErrorApiBody,
|
|
5
|
+
getClientErrorApiDescription,
|
|
6
|
+
isProjectUserNotFoundClientError,
|
|
7
|
+
} from '@sanity/sdk'
|
|
3
8
|
import {useCallback, useEffect, useState} from 'react'
|
|
4
9
|
import {type FallbackProps} from 'react-error-boundary'
|
|
5
10
|
|
|
@@ -35,6 +40,7 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
35
40
|
const [authErrorMessage, setAuthErrorMessage] = useState(
|
|
36
41
|
'Please try again or contact support if the problem persists.',
|
|
37
42
|
)
|
|
43
|
+
const [showRetryCta, setShowRetryCta] = useState(true)
|
|
38
44
|
|
|
39
45
|
const handleRetry = useCallback(async () => {
|
|
40
46
|
await logout()
|
|
@@ -44,18 +50,28 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
44
50
|
useEffect(() => {
|
|
45
51
|
if (error instanceof ClientError) {
|
|
46
52
|
if (error.statusCode === 401) {
|
|
47
|
-
|
|
53
|
+
// Surface a friendly message for projectUserNotFoundError (do not logout/refresh)
|
|
54
|
+
if (isProjectUserNotFoundClientError(error)) {
|
|
55
|
+
const description = getClientErrorApiDescription(error)
|
|
56
|
+
if (description) setAuthErrorMessage(description)
|
|
57
|
+
setShowRetryCta(false)
|
|
58
|
+
} else {
|
|
59
|
+
setShowRetryCta(true)
|
|
60
|
+
handleRetry()
|
|
61
|
+
}
|
|
48
62
|
} else if (error.statusCode === 404) {
|
|
49
|
-
const errorMessage = error
|
|
63
|
+
const errorMessage = getClientErrorApiBody(error)?.message || ''
|
|
50
64
|
if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
|
|
51
65
|
setAuthErrorMessage('The session ID is invalid or expired.')
|
|
52
66
|
} else {
|
|
53
67
|
setAuthErrorMessage('The login link is invalid or expired. Please try again.')
|
|
54
68
|
}
|
|
69
|
+
setShowRetryCta(true)
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
|
|
58
73
|
setAuthErrorMessage(error.message)
|
|
74
|
+
setShowRetryCta(true)
|
|
59
75
|
}
|
|
60
76
|
}, [authState, handleRetry, error])
|
|
61
77
|
|
|
@@ -63,10 +79,14 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
63
79
|
<Error
|
|
64
80
|
heading={error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'}
|
|
65
81
|
description={authErrorMessage}
|
|
66
|
-
cta={
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
cta={
|
|
83
|
+
showRetryCta
|
|
84
|
+
? {
|
|
85
|
+
text: 'Retry',
|
|
86
|
+
onClick: handleRetry,
|
|
87
|
+
}
|
|
88
|
+
: undefined
|
|
89
|
+
}
|
|
70
90
|
/>
|
|
71
91
|
)
|
|
72
92
|
}
|
|
@@ -89,7 +89,8 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
89
89
|
mockGetIsInDashboardState.mockReturnValue({getCurrent: () => true})
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
it('should initialize useWindowConnection with correct parameters', () => {
|
|
92
|
+
it('should initialize useWindowConnection with correct parameters when not in studio mode', () => {
|
|
93
|
+
// Simulate studio mode disabled by default
|
|
93
94
|
render(
|
|
94
95
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
95
96
|
<ComlinkTokenRefreshProvider>
|
|
@@ -106,13 +107,22 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
106
107
|
)
|
|
107
108
|
})
|
|
108
109
|
|
|
109
|
-
it('should handle received token', async () => {
|
|
110
|
+
it('should handle received token when not in studio mode', async () => {
|
|
110
111
|
mockUseAuthState.mockReturnValue({
|
|
111
112
|
type: AuthStateType.ERROR,
|
|
112
113
|
error: {statusCode: 401, message: 'Unauthorized'},
|
|
113
114
|
})
|
|
114
115
|
mockFetch.mockResolvedValueOnce({token: 'new-token'})
|
|
115
116
|
|
|
117
|
+
// Insert an Unauthorized error container that should be removed on success
|
|
118
|
+
const errorContainer = document.createElement('div')
|
|
119
|
+
errorContainer.id = '__sanityError'
|
|
120
|
+
const child = document.createElement('div')
|
|
121
|
+
child.textContent =
|
|
122
|
+
'Uncaught error: Unauthorized - A valid session is required for this endpoint'
|
|
123
|
+
errorContainer.appendChild(child)
|
|
124
|
+
document.body.appendChild(errorContainer)
|
|
125
|
+
|
|
116
126
|
render(
|
|
117
127
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
118
128
|
<ComlinkTokenRefreshProvider>
|
|
@@ -127,9 +137,17 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
127
137
|
|
|
128
138
|
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.any(Object), 'new-token')
|
|
129
139
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
140
|
+
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
|
|
141
|
+
// Assert setAuthToken was called with instance matching provider config
|
|
142
|
+
const instanceArg = mockSetAuthToken.mock.calls[0][0]
|
|
143
|
+
expect(instanceArg.config).toEqual(
|
|
144
|
+
expect.objectContaining({projectId: 'test-project', dataset: 'test-dataset'}),
|
|
145
|
+
)
|
|
146
|
+
// Unauthorized error container should be removed
|
|
147
|
+
expect(document.getElementById('__sanityError')).toBeNull()
|
|
130
148
|
})
|
|
131
149
|
|
|
132
|
-
it('should not set auth token if received token is null', async () => {
|
|
150
|
+
it('should not set auth token if received token is null when not in studio mode', async () => {
|
|
133
151
|
mockUseAuthState.mockReturnValue({
|
|
134
152
|
type: AuthStateType.ERROR,
|
|
135
153
|
error: {statusCode: 401, message: 'Unauthorized'},
|
|
@@ -151,7 +169,7 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
151
169
|
expect(mockSetAuthToken).not.toHaveBeenCalled()
|
|
152
170
|
})
|
|
153
171
|
|
|
154
|
-
it('should handle fetch errors gracefully', async () => {
|
|
172
|
+
it('should handle fetch errors gracefully when not in studio mode', async () => {
|
|
155
173
|
mockUseAuthState.mockReturnValue({
|
|
156
174
|
type: AuthStateType.ERROR,
|
|
157
175
|
error: {statusCode: 401, message: 'Unauthorized'},
|
|
@@ -174,7 +192,7 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
174
192
|
})
|
|
175
193
|
|
|
176
194
|
describe('Automatic token refresh', () => {
|
|
177
|
-
it('should not request new token for non-401 errors', async () => {
|
|
195
|
+
it('should not request new token for non-401 errors when not in studio mode', async () => {
|
|
178
196
|
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
179
197
|
const {rerender} = render(
|
|
180
198
|
<ResourceProvider fallback={null}>
|
|
@@ -205,7 +223,7 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
205
223
|
expect(mockFetch).not.toHaveBeenCalled()
|
|
206
224
|
})
|
|
207
225
|
|
|
208
|
-
it('should request new token on LOGGED_OUT state', async () => {
|
|
226
|
+
it('should request new token on LOGGED_OUT state when not in studio mode', async () => {
|
|
209
227
|
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
210
228
|
const {rerender} = render(
|
|
211
229
|
<ResourceProvider fallback={null}>
|
|
@@ -228,6 +246,108 @@ describe('ComlinkTokenRefresh', () => {
|
|
|
228
246
|
|
|
229
247
|
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
|
|
230
248
|
})
|
|
249
|
+
|
|
250
|
+
it('dedupes multiple 401 errors while a refresh is in progress', async () => {
|
|
251
|
+
mockUseAuthState.mockReturnValue({
|
|
252
|
+
type: AuthStateType.ERROR,
|
|
253
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
254
|
+
})
|
|
255
|
+
// Return a promise we resolve later to keep in-progress true for a bit
|
|
256
|
+
let resolveFetch: (v: {token: string | null}) => void
|
|
257
|
+
mockFetch.mockImplementation(
|
|
258
|
+
() =>
|
|
259
|
+
new Promise<{token: string | null}>((resolve) => {
|
|
260
|
+
resolveFetch = resolve
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
const {rerender} = render(
|
|
265
|
+
<ResourceProvider fallback={null}>
|
|
266
|
+
<ComlinkTokenRefreshProvider>
|
|
267
|
+
<div>Test</div>
|
|
268
|
+
</ComlinkTokenRefreshProvider>
|
|
269
|
+
</ResourceProvider>,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
// Trigger a second 401 while the first request is still in progress
|
|
273
|
+
mockUseAuthState.mockReturnValue({
|
|
274
|
+
type: AuthStateType.ERROR,
|
|
275
|
+
error: {statusCode: 401, message: 'Unauthorized again'},
|
|
276
|
+
})
|
|
277
|
+
act(() => {
|
|
278
|
+
rerender(
|
|
279
|
+
<ResourceProvider fallback={null}>
|
|
280
|
+
<ComlinkTokenRefreshProvider>
|
|
281
|
+
<div>Test</div>
|
|
282
|
+
</ComlinkTokenRefreshProvider>
|
|
283
|
+
</ResourceProvider>,
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// Only one fetch should be in-flight
|
|
288
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
289
|
+
|
|
290
|
+
// Finish the first fetch
|
|
291
|
+
await act(async () => {
|
|
292
|
+
resolveFetch!({token: null})
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('requests again after timeout if previous request did not resolve', async () => {
|
|
297
|
+
mockUseAuthState.mockReturnValue({
|
|
298
|
+
type: AuthStateType.ERROR,
|
|
299
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
300
|
+
})
|
|
301
|
+
// First call never resolves
|
|
302
|
+
mockFetch.mockImplementationOnce(() => new Promise(() => {}))
|
|
303
|
+
|
|
304
|
+
const {rerender} = render(
|
|
305
|
+
<ResourceProvider fallback={null}>
|
|
306
|
+
<ComlinkTokenRefreshProvider>
|
|
307
|
+
<div>Test</div>
|
|
308
|
+
</ComlinkTokenRefreshProvider>
|
|
309
|
+
</ResourceProvider>,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
313
|
+
|
|
314
|
+
// After timeout elapses, a subsequent 401 should trigger another fetch
|
|
315
|
+
await act(async () => {
|
|
316
|
+
await vi.advanceTimersByTimeAsync(10000)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
mockUseAuthState.mockReturnValue({
|
|
320
|
+
type: AuthStateType.ERROR,
|
|
321
|
+
error: {statusCode: 401, message: 'Unauthorized again'},
|
|
322
|
+
})
|
|
323
|
+
act(() => {
|
|
324
|
+
rerender(
|
|
325
|
+
<ResourceProvider fallback={null}>
|
|
326
|
+
<ComlinkTokenRefreshProvider>
|
|
327
|
+
<div>Test</div>
|
|
328
|
+
</ComlinkTokenRefreshProvider>
|
|
329
|
+
</ResourceProvider>,
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('when in studio mode', () => {
|
|
337
|
+
it('should not render DashboardTokenRefresh when studio mode enabled', () => {
|
|
338
|
+
render(
|
|
339
|
+
<ResourceProvider fallback={null} studioMode={{enabled: true}}>
|
|
340
|
+
<ComlinkTokenRefreshProvider>
|
|
341
|
+
<div>Test</div>
|
|
342
|
+
</ComlinkTokenRefreshProvider>
|
|
343
|
+
</ResourceProvider>,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
// In studio mode, provider should return children directly
|
|
347
|
+
// So window connection should not be initialized
|
|
348
|
+
expect(mockUseWindowConnection).not.toHaveBeenCalled()
|
|
349
|
+
})
|
|
350
|
+
})
|
|
231
351
|
})
|
|
232
352
|
})
|
|
233
353
|
})
|
|
@@ -130,8 +130,9 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
|
130
130
|
export const ComlinkTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
131
131
|
const instance = useSanityInstance()
|
|
132
132
|
const isInDashboard = useMemo(() => getIsInDashboardState(instance).getCurrent(), [instance])
|
|
133
|
+
const studioModeEnabled = !!instance.config.studioMode?.enabled
|
|
133
134
|
|
|
134
|
-
if (isInDashboard) {
|
|
135
|
+
if (isInDashboard && !studioModeEnabled) {
|
|
135
136
|
return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {of} from 'rxjs'
|
|
4
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
|
+
import {
|
|
8
|
+
useAgentGenerate,
|
|
9
|
+
useAgentPatch,
|
|
10
|
+
useAgentPrompt,
|
|
11
|
+
useAgentTransform,
|
|
12
|
+
useAgentTranslate,
|
|
13
|
+
} from './agentActions'
|
|
14
|
+
|
|
15
|
+
vi.mock('@sanity/sdk', async (orig) => {
|
|
16
|
+
const actual = await orig()
|
|
17
|
+
return {
|
|
18
|
+
...(actual as Record<string, any>),
|
|
19
|
+
agentGenerate: vi.fn(() => of('gen')),
|
|
20
|
+
agentTransform: vi.fn(() => of('xform')),
|
|
21
|
+
agentTranslate: vi.fn(() => of('xlate')),
|
|
22
|
+
agentPrompt: vi.fn(() => of('prompted')),
|
|
23
|
+
agentPatch: vi.fn(() => of('patched')),
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('agent action hooks', () => {
|
|
28
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
29
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
30
|
+
{children}
|
|
31
|
+
</ResourceProvider>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
it('useAgentGenerate returns a callable that delegates to core', async () => {
|
|
35
|
+
const {result} = renderHook(() => useAgentGenerate(), {wrapper})
|
|
36
|
+
const value = await new Promise<any>((resolve, reject) => {
|
|
37
|
+
result.current({} as any).subscribe({
|
|
38
|
+
next: (v) => resolve(v),
|
|
39
|
+
error: reject,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
expect(value).toBe('gen')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('useAgentTransform returns a callable that delegates to core', async () => {
|
|
46
|
+
const {result} = renderHook(() => useAgentTransform(), {wrapper})
|
|
47
|
+
const value = await new Promise<any>((resolve, reject) => {
|
|
48
|
+
result.current({} as any).subscribe({
|
|
49
|
+
next: (v) => resolve(v),
|
|
50
|
+
error: reject,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
expect(value).toBe('xform')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('useAgentTranslate returns a callable that delegates to core', async () => {
|
|
57
|
+
const {result} = renderHook(() => useAgentTranslate(), {wrapper})
|
|
58
|
+
const value = await new Promise<any>((resolve, reject) => {
|
|
59
|
+
result.current({} as any).subscribe({
|
|
60
|
+
next: (v) => resolve(v),
|
|
61
|
+
error: reject,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
expect(value).toBe('xlate')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('useAgentPrompt returns a callable that delegates to core', async () => {
|
|
68
|
+
const {result} = renderHook(() => useAgentPrompt(), {wrapper})
|
|
69
|
+
const value = await result.current({} as any)
|
|
70
|
+
expect(value).toBe('prompted')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('useAgentPatch returns a callable that delegates to core', async () => {
|
|
74
|
+
const {result} = renderHook(() => useAgentPatch(), {wrapper})
|
|
75
|
+
const value = await result.current({} as any)
|
|
76
|
+
expect(value).toBe('patched')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentGenerate,
|
|
3
|
+
type AgentGenerateOptions,
|
|
4
|
+
agentPatch,
|
|
5
|
+
type AgentPatchOptions,
|
|
6
|
+
type AgentPatchResult,
|
|
7
|
+
agentPrompt,
|
|
8
|
+
type AgentPromptOptions,
|
|
9
|
+
type AgentPromptResult,
|
|
10
|
+
agentTransform,
|
|
11
|
+
type AgentTransformOptions,
|
|
12
|
+
agentTranslate,
|
|
13
|
+
type AgentTranslateOptions,
|
|
14
|
+
type SanityInstance,
|
|
15
|
+
} from '@sanity/sdk'
|
|
16
|
+
import {firstValueFrom} from 'rxjs'
|
|
17
|
+
|
|
18
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
19
|
+
|
|
20
|
+
interface Subscription {
|
|
21
|
+
unsubscribe(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Observer<T> {
|
|
25
|
+
next?: (value: T) => void
|
|
26
|
+
error?: (err: unknown) => void
|
|
27
|
+
complete?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Subscribable<T> {
|
|
31
|
+
subscribe(observer: Observer<T>): Subscription
|
|
32
|
+
subscribe(
|
|
33
|
+
next: (value: T) => void,
|
|
34
|
+
error?: (err: unknown) => void,
|
|
35
|
+
complete?: () => void,
|
|
36
|
+
): Subscription
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @alpha
|
|
41
|
+
* Generates content for a document (or specific fields) via Sanity Agent Actions.
|
|
42
|
+
* - Uses instruction templates with `$variables` and supports `instructionParams` (constants, fields, documents, GROQ queries).
|
|
43
|
+
* - Can target specific paths/fields; supports image generation when targeting image fields.
|
|
44
|
+
* - Supports optional `temperature`, `async`, `noWrite`, and `conditionalPaths`.
|
|
45
|
+
*
|
|
46
|
+
* Returns a stable callback that triggers the action and yields a Subscribable stream.
|
|
47
|
+
*/
|
|
48
|
+
export const useAgentGenerate: () => (options: AgentGenerateOptions) => Subscribable<unknown> =
|
|
49
|
+
createCallbackHook(agentGenerate) as unknown as () => (
|
|
50
|
+
options: AgentGenerateOptions,
|
|
51
|
+
) => Subscribable<unknown>
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @alpha
|
|
55
|
+
* Transforms an existing document or selected fields using Sanity Agent Actions.
|
|
56
|
+
* - Accepts `instruction` and `instructionParams` (constants, fields, documents, GROQ queries).
|
|
57
|
+
* - Can write to the same or a different `targetDocument` (create/edit), and target specific paths.
|
|
58
|
+
* - Supports per-path image transform instructions and image description operations.
|
|
59
|
+
* - Optional `temperature`, `async`, `noWrite`, `conditionalPaths`.
|
|
60
|
+
*
|
|
61
|
+
* Returns a stable callback that triggers the action and yields a Subscribable stream.
|
|
62
|
+
*/
|
|
63
|
+
export const useAgentTransform: () => (options: AgentTransformOptions) => Subscribable<unknown> =
|
|
64
|
+
createCallbackHook(agentTransform) as unknown as () => (
|
|
65
|
+
options: AgentTransformOptions,
|
|
66
|
+
) => Subscribable<unknown>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @alpha
|
|
70
|
+
* Translates documents or fields using Sanity Agent Actions.
|
|
71
|
+
* - Configure `fromLanguage`/`toLanguage`, optional `styleGuide`, and `protectedPhrases`.
|
|
72
|
+
* - Can write into a different `targetDocument`, and/or store language in a field.
|
|
73
|
+
* - Optional `temperature`, `async`, `noWrite`, `conditionalPaths`.
|
|
74
|
+
*
|
|
75
|
+
* Returns a stable callback that triggers the action and yields a Subscribable stream.
|
|
76
|
+
*/
|
|
77
|
+
export const useAgentTranslate: () => (options: AgentTranslateOptions) => Subscribable<unknown> =
|
|
78
|
+
createCallbackHook(agentTranslate) as unknown as () => (
|
|
79
|
+
options: AgentTranslateOptions,
|
|
80
|
+
) => Subscribable<unknown>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @alpha
|
|
84
|
+
* Prompts the LLM using the same instruction template format as other actions.
|
|
85
|
+
* - `format`: 'string' or 'json' (instruction must contain the word "json" for JSON responses).
|
|
86
|
+
* - Optional `temperature`.
|
|
87
|
+
*
|
|
88
|
+
* Returns a stable callback that triggers the action and resolves a Promise with the prompt result.
|
|
89
|
+
*/
|
|
90
|
+
function promptAdapter(
|
|
91
|
+
instance: SanityInstance,
|
|
92
|
+
options: AgentPromptOptions,
|
|
93
|
+
): Promise<AgentPromptResult> {
|
|
94
|
+
return firstValueFrom(agentPrompt(instance, options))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @alpha
|
|
99
|
+
* Prompts the LLM using the same instruction template format as other actions.
|
|
100
|
+
* - `format`: 'string' or 'json' (instruction must contain the word "json" for JSON responses).
|
|
101
|
+
* - Optional `temperature`.
|
|
102
|
+
*
|
|
103
|
+
* Returns a stable callback that triggers the action and resolves a Promise with the prompt result.
|
|
104
|
+
*/
|
|
105
|
+
export const useAgentPrompt: () => (options: AgentPromptOptions) => Promise<AgentPromptResult> =
|
|
106
|
+
createCallbackHook(promptAdapter)
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @alpha
|
|
110
|
+
* Schema-aware patching with Sanity Agent Actions.
|
|
111
|
+
* - Validates provided paths/values against the document schema and merges object values safely.
|
|
112
|
+
* - Prevents duplicate keys and supports array appends (including after a specific keyed item).
|
|
113
|
+
* - Accepts `documentId` or `targetDocument` (mutually exclusive).
|
|
114
|
+
* - Optional `async`, `noWrite`, `conditionalPaths`.
|
|
115
|
+
*
|
|
116
|
+
* Returns a stable callback that triggers the action and resolves a Promise with the patch result.
|
|
117
|
+
*/
|
|
118
|
+
function patchAdapter(
|
|
119
|
+
instance: SanityInstance,
|
|
120
|
+
options: AgentPatchOptions,
|
|
121
|
+
): Promise<AgentPatchResult> {
|
|
122
|
+
return firstValueFrom(agentPatch(instance, options))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @alpha
|
|
127
|
+
* Schema-aware patching with Sanity Agent Actions.
|
|
128
|
+
* - Validates provided paths/values against the document schema and merges object values safely.
|
|
129
|
+
* - Prevents duplicate keys and supports array appends (including after a specific keyed item).
|
|
130
|
+
* - Accepts `documentId` or `targetDocument` (mutually exclusive).
|
|
131
|
+
* - Optional `async`, `noWrite`, `conditionalPaths`.
|
|
132
|
+
*
|
|
133
|
+
* Returns a stable callback that triggers the action and resolves a Promise with the patch result.
|
|
134
|
+
*/
|
|
135
|
+
export const useAgentPatch: () => (options: AgentPatchOptions) => Promise<AgentPatchResult> =
|
|
136
|
+
createCallbackHook(patchAdapter)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {renderHook} from '@testing-library/react'
|
|
2
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
5
|
+
import {useClient} from './useClient'
|
|
6
|
+
|
|
7
|
+
describe('useClient', () => {
|
|
8
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
9
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
10
|
+
{children}
|
|
11
|
+
</ResourceProvider>
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
it('should throw a helpful error when called without options', () => {
|
|
15
|
+
// Suppress console.error for this test since we expect an error to be thrown
|
|
16
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
17
|
+
|
|
18
|
+
expect(() => {
|
|
19
|
+
// @ts-expect-error Testing missing options
|
|
20
|
+
renderHook(() => useClient(), {wrapper})
|
|
21
|
+
}).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
|
|
22
|
+
|
|
23
|
+
consoleErrorSpy.mockRestore()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should throw a helpful error when called with undefined', () => {
|
|
27
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
28
|
+
|
|
29
|
+
expect(() => {
|
|
30
|
+
// @ts-expect-error Testing undefined options
|
|
31
|
+
renderHook(() => useClient(undefined), {wrapper})
|
|
32
|
+
}).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
|
|
33
|
+
|
|
34
|
+
consoleErrorSpy.mockRestore()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should return a client when called with valid options', () => {
|
|
38
|
+
const {result} = renderHook(() => useClient({apiVersion: '2024-11-12'}), {wrapper})
|
|
39
|
+
expect(result.current).toBeDefined()
|
|
40
|
+
expect(result.current.fetch).toBeDefined()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {getClientState} from '@sanity/sdk'
|
|
2
|
-
import {identity} from 'rxjs'
|
|
1
|
+
import {type ClientOptions, getClientState, type SanityInstance} from '@sanity/sdk'
|
|
3
2
|
|
|
4
3
|
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
5
4
|
|
|
@@ -31,6 +30,14 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
|
31
30
|
* @function
|
|
32
31
|
*/
|
|
33
32
|
export const useClient = createStateSourceHook({
|
|
34
|
-
getState:
|
|
35
|
-
|
|
33
|
+
getState: (instance: SanityInstance, options: ClientOptions) => {
|
|
34
|
+
if (!options || typeof options !== 'object') {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'useClient() requires a configuration object with at least an "apiVersion" property. ' +
|
|
37
|
+
'Example: useClient({ apiVersion: "2024-11-12" })',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
return getClientState(instance, options)
|
|
41
|
+
},
|
|
42
|
+
getConfig: (options: ClientOptions) => options,
|
|
36
43
|
})
|