@sanity/sdk-react 2.9.0 → 2.11.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 +338 -215
- package/dist/index.js +564 -342
- package/dist/index.js.map +1 -1
- package/package.json +9 -14
- package/src/_exports/index.ts +2 -0
- package/src/_exports/sdk-react.ts +8 -0
- package/src/components/SDKProvider.test.tsx +5 -12
- package/src/components/SDKProvider.tsx +58 -28
- package/src/components/SanityApp.tsx +2 -2
- package/src/components/auth/AuthBoundary.tsx +8 -1
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +100 -56
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +55 -0
- package/src/constants.ts +5 -0
- package/src/context/DefaultResourceContext.ts +10 -0
- package/src/context/PerspectiveContext.ts +12 -0
- package/src/context/ResourceProvider.test.tsx +2 -2
- package/src/context/ResourceProvider.tsx +56 -51
- package/src/context/ResourcesContext.tsx +7 -0
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/hooks/agent/agentActions.ts +55 -38
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +42 -111
- package/src/hooks/context/useSanityInstance.ts +28 -50
- package/src/hooks/dashboard/useDispatchIntent.test.ts +11 -7
- package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
- package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -17
- package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +46 -81
- package/src/hooks/document/useApplyDocumentActions.ts +33 -67
- package/src/hooks/document/useDocument.ts +4 -6
- package/src/hooks/document/useDocumentEvent.ts +8 -7
- package/src/hooks/document/useDocumentPermissions.test.tsx +60 -152
- package/src/hooks/document/useDocumentPermissions.ts +78 -55
- package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
- package/src/hooks/document/useEditDocument.test.tsx +25 -60
- package/src/hooks/document/useEditDocument.ts +3 -3
- package/src/hooks/documents/useDocuments.ts +19 -11
- package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +169 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
- package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
- package/src/hooks/organizations/useOrganization.test.ts +65 -0
- package/src/hooks/organizations/useOrganization.ts +40 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
- package/src/hooks/organizations/useOrganizations.test.ts +85 -0
- package/src/hooks/organizations/useOrganizations.ts +45 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +29 -14
- package/src/hooks/presence/usePresence.test.tsx +56 -9
- package/src/hooks/presence/usePresence.ts +16 -4
- package/src/hooks/preview/useDocumentPreview.tsx +8 -10
- package/src/hooks/projection/useDocumentProjection.ts +7 -9
- package/src/hooks/projects/useProject.test-d.ts +49 -0
- package/src/hooks/projects/useProject.ts +33 -41
- package/src/hooks/projects/useProjects.test-d.ts +49 -0
- package/src/hooks/projects/useProjects.ts +17 -23
- package/src/hooks/query/useQuery.ts +11 -10
- package/src/hooks/releases/useActiveReleases.ts +14 -14
- package/src/hooks/releases/usePerspective.ts +11 -16
- package/src/hooks/users/useUser.ts +1 -1
- package/src/hooks/users/useUsers.ts +1 -1
- package/src/context/SourcesContext.tsx +0 -7
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -43,16 +43,14 @@
|
|
|
43
43
|
"browserslist": "extends @sanity/browserslist-config",
|
|
44
44
|
"prettier": "@sanity/prettier-config",
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@sanity/client": "^7.
|
|
47
|
-
"@sanity/message-protocol": "^0.
|
|
46
|
+
"@sanity/client": "^7.22.0",
|
|
47
|
+
"@sanity/message-protocol": "^0.23.0",
|
|
48
48
|
"@sanity/types": "^5.2.0",
|
|
49
|
-
"@types/lodash-es": "^4.17.12",
|
|
50
49
|
"groq": "3.88.1-typegen-experimental.0",
|
|
51
|
-
"lodash-es": "^4.17.21",
|
|
52
50
|
"react-compiler-runtime": "19.1.0-rc.2",
|
|
53
51
|
"react-error-boundary": "^5.0.0",
|
|
54
52
|
"rxjs": "^7.8.2",
|
|
55
|
-
"@sanity/sdk": "2.
|
|
53
|
+
"@sanity/sdk": "2.11.0"
|
|
56
54
|
},
|
|
57
55
|
"devDependencies": {
|
|
58
56
|
"@sanity/browserslist-config": "^1.0.5",
|
|
@@ -65,31 +63,28 @@
|
|
|
65
63
|
"@types/react": "^19.2.7",
|
|
66
64
|
"@types/react-dom": "^19.2.3",
|
|
67
65
|
"@vitejs/plugin-react": "^4.7.0",
|
|
68
|
-
"@vitest/coverage-v8": "
|
|
66
|
+
"@vitest/coverage-v8": "4.1.5",
|
|
69
67
|
"babel-plugin-react-compiler": "19.1.0-rc.1",
|
|
70
68
|
"eslint": "^9.22.0",
|
|
71
69
|
"groq-js": "^1.22.0",
|
|
72
|
-
"jsdom": "^
|
|
70
|
+
"jsdom": "^29.0.2",
|
|
73
71
|
"prettier": "^3.7.3",
|
|
74
72
|
"react": "^19.2.1",
|
|
75
73
|
"react-dom": "^19.2.1",
|
|
76
74
|
"rollup-plugin-visualizer": "^5.14.0",
|
|
77
75
|
"typescript": "^5.8.3",
|
|
78
76
|
"vite": "^7.0.0",
|
|
79
|
-
"vitest": "^
|
|
80
|
-
"@repo/tsconfig": "0.0.1",
|
|
81
|
-
"@repo/package.config": "0.0.1",
|
|
77
|
+
"vitest": "^4.1.4",
|
|
82
78
|
"@repo/config-test": "0.0.1",
|
|
83
79
|
"@repo/package.bundle": "3.82.0",
|
|
80
|
+
"@repo/package.config": "0.0.1",
|
|
81
|
+
"@repo/tsconfig": "0.0.1",
|
|
84
82
|
"@repo/config-eslint": "0.0.0"
|
|
85
83
|
},
|
|
86
84
|
"peerDependencies": {
|
|
87
85
|
"react": "^18.0.0 || ^19.0.0",
|
|
88
86
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
89
87
|
},
|
|
90
|
-
"engines": {
|
|
91
|
-
"node": ">=20.19"
|
|
92
|
-
},
|
|
93
88
|
"publishConfig": {
|
|
94
89
|
"access": "public"
|
|
95
90
|
},
|
package/src/_exports/index.ts
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
|
|
5
5
|
export {SanityApp, type SanityAppProps} from '../components/SanityApp'
|
|
6
6
|
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
|
|
7
|
+
export {type DocumentHandle, type DocumentTypeHandle, type ResourceHandle} from '../config/handles'
|
|
7
8
|
export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
|
|
8
9
|
export {renderSanityApp} from '../context/renderSanityApp'
|
|
9
10
|
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
|
|
11
|
+
export {
|
|
12
|
+
SanityInstanceProvider,
|
|
13
|
+
type SanityInstanceProviderProps,
|
|
14
|
+
} from '../context/SanityInstanceProvider'
|
|
10
15
|
export {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
|
|
11
16
|
export {
|
|
12
17
|
useAgentGenerate,
|
|
@@ -40,6 +45,7 @@ export {
|
|
|
40
45
|
type WindowConnection,
|
|
41
46
|
type WindowMessageHandler,
|
|
42
47
|
} from '../hooks/comlink/useWindowConnection'
|
|
48
|
+
export {useResource} from '../hooks/context/useResource'
|
|
43
49
|
export {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
44
50
|
export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
|
|
45
51
|
export {useDispatchIntent} from '../hooks/dashboard/useDispatchIntent'
|
|
@@ -63,6 +69,8 @@ export {
|
|
|
63
69
|
type DocumentsResponse,
|
|
64
70
|
useDocuments,
|
|
65
71
|
} from '../hooks/documents/useDocuments'
|
|
72
|
+
export {useOrganization} from '../hooks/organizations/useOrganization'
|
|
73
|
+
export {useOrganizations} from '../hooks/organizations/useOrganizations'
|
|
66
74
|
export {
|
|
67
75
|
type PaginatedDocumentsOptions,
|
|
68
76
|
type PaginatedDocumentsResponse,
|
|
@@ -62,7 +62,7 @@ describe('SDKProvider', () => {
|
|
|
62
62
|
})
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('renders
|
|
65
|
+
it('renders a single ResourceProvider using the first config when multiple configs are provided', () => {
|
|
66
66
|
const configs = [
|
|
67
67
|
{
|
|
68
68
|
projectId: 'project-1',
|
|
@@ -80,22 +80,15 @@ describe('SDKProvider', () => {
|
|
|
80
80
|
</SDKProvider>,
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
// Should create
|
|
83
|
+
// Should create a single ResourceProvider using the first config
|
|
84
84
|
const providers = getAllByTestId('resource-provider')
|
|
85
|
-
expect(providers.length).toBe(
|
|
85
|
+
expect(providers.length).toBe(1)
|
|
86
86
|
|
|
87
|
-
// Should create an AuthBoundary inside
|
|
87
|
+
// Should create an AuthBoundary inside
|
|
88
88
|
expect(getByTestId('auth-boundary')).toBeInTheDocument()
|
|
89
89
|
|
|
90
|
-
// Verify
|
|
91
|
-
// The first provider contains config[1]
|
|
90
|
+
// Verify the provider uses the first config
|
|
92
91
|
expect(JSON.parse(providers[0].getAttribute('data-config') || '{}')).toEqual({
|
|
93
|
-
projectId: 'project-2',
|
|
94
|
-
dataset: 'staging',
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// The second provider contains config[0]
|
|
98
|
-
expect(JSON.parse(providers[1].getAttribute('data-config') || '{}')).toEqual({
|
|
99
92
|
projectId: 'project-1',
|
|
100
93
|
dataset: 'production',
|
|
101
94
|
})
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {type
|
|
2
|
-
import {type ReactElement, type ReactNode, useMemo} from 'react'
|
|
1
|
+
import {type DocumentResource, isImportError, type SanityConfig} from '@sanity/sdk'
|
|
2
|
+
import {type ReactElement, type ReactNode, useEffect, useMemo} from 'react'
|
|
3
|
+
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
3
4
|
|
|
5
|
+
import {DEFAULT_RESOURCE_NAME} from '../constants'
|
|
4
6
|
import {ResourceProvider} from '../context/ResourceProvider'
|
|
5
|
-
import {
|
|
7
|
+
import {ResourcesContext} from '../context/ResourcesContext'
|
|
6
8
|
import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
|
|
9
|
+
import {ChunkLoadError} from './errors/ChunkLoadError'
|
|
10
|
+
import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* @internal
|
|
@@ -12,15 +16,25 @@ export interface SDKProviderProps extends AuthBoundaryProps {
|
|
|
12
16
|
children: ReactNode
|
|
13
17
|
config: SanityConfig | SanityConfig[]
|
|
14
18
|
fallback: ReactNode
|
|
15
|
-
|
|
19
|
+
resources?: Record<string, DocumentResource>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clears the chunk-reload flag once children render successfully past the
|
|
24
|
+
* top-level boundary, so a future incident in the same session can trigger
|
|
25
|
+
* another automatic reload.
|
|
26
|
+
*/
|
|
27
|
+
function ResetChunkReloadFlagOnMount(): null {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
clearChunkReloadFlag()
|
|
30
|
+
}, [])
|
|
31
|
+
return null
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/**
|
|
19
35
|
* @internal
|
|
20
36
|
*
|
|
21
37
|
* Top-level context provider that provides access to the Sanity SDK.
|
|
22
|
-
* Creates a hierarchy of ResourceProviders, each providing a SanityInstance that can be
|
|
23
|
-
* accessed by hooks. The first configuration in the array becomes the default instance.
|
|
24
38
|
*/
|
|
25
39
|
export function SDKProvider({
|
|
26
40
|
children,
|
|
@@ -28,30 +42,46 @@ export function SDKProvider({
|
|
|
28
42
|
fallback,
|
|
29
43
|
...props
|
|
30
44
|
}: SDKProviderProps): ReactElement {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const allConfigs = Array.isArray(config) ? config : [config]
|
|
46
|
+
const resolvedConfig = allConfigs[0]
|
|
47
|
+
const projectIds = allConfigs.map((c) => c.projectId).filter((id): id is string => !!id)
|
|
48
|
+
|
|
49
|
+
// Extract static fields so the memo below doesn't take a reference dependency
|
|
50
|
+
// on `config` — inline config objects change identity on every render.
|
|
51
|
+
const singleConfig = Array.isArray(config) ? null : config
|
|
52
|
+
const defaultProjectId = singleConfig?.projectId
|
|
53
|
+
const defaultDataset = singleConfig?.dataset
|
|
54
|
+
|
|
55
|
+
// For a single config, synthesize a 'default' resource from its projectId/dataset
|
|
56
|
+
// so that hooks can resolve it via resourceName: 'default' or fall back to it
|
|
57
|
+
// automatically when no resource info is provided.
|
|
58
|
+
const resourcesValue = useMemo(() => {
|
|
59
|
+
const explicit = props.resources ?? {}
|
|
60
|
+
if (defaultProjectId && defaultDataset && !Object.hasOwn(explicit, DEFAULT_RESOURCE_NAME)) {
|
|
61
|
+
return {
|
|
62
|
+
[DEFAULT_RESOURCE_NAME]: {projectId: defaultProjectId, dataset: defaultDataset},
|
|
63
|
+
...explicit,
|
|
64
|
+
}
|
|
47
65
|
}
|
|
66
|
+
return explicit
|
|
67
|
+
}, [defaultProjectId, defaultDataset, props.resources])
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
return (
|
|
70
|
+
<ErrorBoundary FallbackComponent={ChunkAwareFallback}>
|
|
71
|
+
<ResetChunkReloadFlagOnMount />
|
|
72
|
+
<ResourceProvider {...resolvedConfig} fallback={fallback}>
|
|
73
|
+
<AuthBoundary {...props} projectIds={projectIds}>
|
|
74
|
+
<ResourcesContext.Provider value={resourcesValue}>{children}</ResourcesContext.Provider>
|
|
75
|
+
</AuthBoundary>
|
|
52
76
|
</ResourceProvider>
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
</ErrorBoundary>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
55
80
|
|
|
56
|
-
|
|
81
|
+
function ChunkAwareFallback(fallbackProps: FallbackProps): ReactElement {
|
|
82
|
+
if (isImportError(fallbackProps.error)) {
|
|
83
|
+
return <ChunkLoadError {...fallbackProps} />
|
|
84
|
+
}
|
|
85
|
+
// Re-throw so downstream boundaries (e.g. AuthBoundary) handle other errors.
|
|
86
|
+
throw fallbackProps.error
|
|
57
87
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type
|
|
1
|
+
import {type DocumentResource, isStudioConfig, type SanityConfig} from '@sanity/sdk'
|
|
2
2
|
import {type ReactElement, useContext, useEffect, useMemo} from 'react'
|
|
3
3
|
|
|
4
4
|
import {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
|
|
@@ -19,7 +19,7 @@ export interface SanityAppProps {
|
|
|
19
19
|
config?: SanityConfig | SanityConfig[]
|
|
20
20
|
/** @deprecated use the `config` prop instead. */
|
|
21
21
|
sanityConfigs?: SanityConfig[]
|
|
22
|
-
|
|
22
|
+
resources?: Record<string, DocumentResource>
|
|
23
23
|
children: React.ReactNode
|
|
24
24
|
/* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
|
|
25
25
|
fallback: React.ReactNode
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {CorsOriginError} from '@sanity/client'
|
|
2
|
-
import {AuthStateType, getCorsErrorProjectId, isStudioConfig} from '@sanity/sdk'
|
|
2
|
+
import {AuthStateType, getCorsErrorProjectId, isImportError, isStudioConfig} from '@sanity/sdk'
|
|
3
3
|
import {useEffect, useMemo} from 'react'
|
|
4
4
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
5
5
|
|
|
@@ -8,6 +8,7 @@ import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
|
8
8
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
9
9
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
10
10
|
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
11
|
+
import {ChunkLoadError} from '../errors/ChunkLoadError'
|
|
11
12
|
import {CorsErrorComponent} from '../errors/CorsErrorComponent'
|
|
12
13
|
import {isInIframe} from '../utils'
|
|
13
14
|
import {AuthError} from './AuthError'
|
|
@@ -110,6 +111,12 @@ export function AuthBoundary({
|
|
|
110
111
|
}: AuthBoundaryProps): React.ReactNode {
|
|
111
112
|
const FallbackComponent = useMemo(() => {
|
|
112
113
|
return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
|
|
114
|
+
// Chunk-load errors from any lazy-loaded code beneath this boundary
|
|
115
|
+
// (typically the consumer's app) get the chunk-aware fallback instead
|
|
116
|
+
// of being misreported as auth errors.
|
|
117
|
+
if (isImportError(fallbackProps.error)) {
|
|
118
|
+
return <ChunkLoadError {...fallbackProps} />
|
|
119
|
+
}
|
|
113
120
|
if (fallbackProps.error instanceof CorsOriginError) {
|
|
114
121
|
return (
|
|
115
122
|
<CorsErrorComponent
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
+
import {useEffect} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
|
|
5
|
+
|
|
6
|
+
interface DashboardAccessRequestProps {
|
|
7
|
+
projectId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sends a `dashboard/v1/auth/access/request` message to the dashboard via
|
|
12
|
+
* comlink so the user can request access to a project they don't belong to.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally isolated in its own component because
|
|
15
|
+
* `useWindowConnection` suspends until a comlink node is available, which
|
|
16
|
+
* never happens outside the dashboard. Callers must gate rendering on
|
|
17
|
+
* `getIsInDashboardState(...).getCurrent()` and wrap this in a
|
|
18
|
+
* {@link https://react.dev/reference/react/Suspense | Suspense} boundary
|
|
19
|
+
* so the suspension stays local instead of bubbling up to the app shell.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export function DashboardAccessRequest({projectId}: DashboardAccessRequestProps): null {
|
|
24
|
+
const {fetch} = useWindowConnection({
|
|
25
|
+
name: SDK_NODE_NAME,
|
|
26
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
fetch('dashboard/v1/auth/access/request', {
|
|
31
|
+
resourceType: 'project',
|
|
32
|
+
resourceId: projectId,
|
|
33
|
+
})
|
|
34
|
+
}, [fetch, projectId])
|
|
35
|
+
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
@@ -1,19 +1,51 @@
|
|
|
1
|
+
import {ClientError} from '@sanity/client'
|
|
2
|
+
import {getIsInDashboardState} from '@sanity/sdk'
|
|
1
3
|
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
|
2
|
-
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
3
5
|
|
|
4
6
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
5
7
|
import {AuthError} from './AuthError'
|
|
6
8
|
import {LoginError} from './LoginError'
|
|
7
9
|
|
|
10
|
+
vi.mock('@sanity/sdk', async () => {
|
|
11
|
+
const actual = await vi.importActual('@sanity/sdk')
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
getIsInDashboardState: vi.fn(() => ({getCurrent: vi.fn(() => false)})),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const mockLogout = vi.fn(async () => {})
|
|
8
19
|
vi.mock('../../hooks/auth/useLogOut', () => ({
|
|
9
|
-
useLogOut: vi.fn(() =>
|
|
20
|
+
useLogOut: vi.fn(() => mockLogout),
|
|
10
21
|
}))
|
|
11
22
|
|
|
23
|
+
const mockWindowConnectionFetch = vi.fn()
|
|
12
24
|
vi.mock('../../hooks/comlink/useWindowConnection', () => ({
|
|
13
|
-
useWindowConnection: vi.fn(() => ({fetch:
|
|
25
|
+
useWindowConnection: vi.fn(() => ({fetch: mockWindowConnectionFetch})),
|
|
14
26
|
}))
|
|
15
27
|
|
|
28
|
+
const mockGetIsInDashboardState = getIsInDashboardState as Mock
|
|
29
|
+
|
|
30
|
+
function makeClientError(statusCode: number, body: unknown): ClientError {
|
|
31
|
+
return new ClientError({
|
|
32
|
+
statusCode,
|
|
33
|
+
headers: {},
|
|
34
|
+
body,
|
|
35
|
+
url: 'https://example.test',
|
|
36
|
+
method: 'GET',
|
|
37
|
+
} as ConstructorParameters<typeof ClientError>[0])
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
describe('LoginError', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
17
49
|
it('shows authentication error and retry button', async () => {
|
|
18
50
|
const mockReset = vi.fn()
|
|
19
51
|
const error = new AuthError(new Error('Test error'))
|
|
@@ -38,7 +70,6 @@ describe('LoginError', () => {
|
|
|
38
70
|
const mockReset = vi.fn()
|
|
39
71
|
const nonAuthError = new Error('Non-auth error')
|
|
40
72
|
|
|
41
|
-
// Suppress console.error during this test
|
|
42
73
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
43
74
|
|
|
44
75
|
expect(() => {
|
|
@@ -49,6 +80,161 @@ describe('LoginError', () => {
|
|
|
49
80
|
)
|
|
50
81
|
}).toThrow('Non-auth error')
|
|
51
82
|
|
|
52
|
-
consoleErrorSpy.mockRestore()
|
|
83
|
+
consoleErrorSpy.mockRestore()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// In a standalone app (not embedded in the dashboard) the dashboard access
|
|
87
|
+
// request path must not render, because useWindowConnection would suspend
|
|
88
|
+
// waiting for a comlink node that never arrives.
|
|
89
|
+
it('renders synchronously on a 401 projectUserNotFound error outside the dashboard', async () => {
|
|
90
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
|
|
91
|
+
|
|
92
|
+
const error = makeClientError(401, {
|
|
93
|
+
error: {
|
|
94
|
+
type: 'projectUserNotFoundError',
|
|
95
|
+
description: 'User is not a member of this project.',
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
render(
|
|
100
|
+
<ResourceProvider fallback={<div>SUSPENDED</div>}>
|
|
101
|
+
<LoginError error={error} resetErrorBoundary={vi.fn()} />
|
|
102
|
+
</ResourceProvider>,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(screen.getByText('User is not a member of this project.')).toBeInTheDocument()
|
|
107
|
+
})
|
|
108
|
+
// ClientError must render under the "Authentication Error" heading; it is
|
|
109
|
+
// not a ConfigurationError.
|
|
110
|
+
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
111
|
+
expect(screen.queryByText('Configuration Error')).not.toBeInTheDocument()
|
|
112
|
+
expect(screen.queryByText('SUSPENDED')).not.toBeInTheDocument()
|
|
113
|
+
expect(mockWindowConnectionFetch).not.toHaveBeenCalled()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('fires the dashboard access request on a 401 projectUserNotFound error inside the dashboard', async () => {
|
|
117
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
|
|
118
|
+
|
|
119
|
+
const error = makeClientError(401, {
|
|
120
|
+
error: {
|
|
121
|
+
type: 'projectUserNotFoundError',
|
|
122
|
+
description: 'User is not a member of this project.',
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
render(
|
|
127
|
+
<ResourceProvider projectId="abc123" dataset="production" fallback={<div>SUSPENDED</div>}>
|
|
128
|
+
<LoginError error={error} resetErrorBoundary={vi.fn()} />
|
|
129
|
+
</ResourceProvider>,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
|
|
134
|
+
resourceType: 'project',
|
|
135
|
+
resourceId: 'abc123',
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Mirrors the real production chain: AuthBoundary wraps the ClientError in
|
|
141
|
+
// an AuthError before the error boundary hands it to LoginError. The
|
|
142
|
+
// `.cause` unwrap is what makes the dashboard access request path reachable
|
|
143
|
+
// at runtime (without it, the previous `error instanceof ClientError` check
|
|
144
|
+
// was dead code in the dashboard).
|
|
145
|
+
it('fires the dashboard access request when the projectUserNotFound ClientError is wrapped in an AuthError', async () => {
|
|
146
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
|
|
147
|
+
|
|
148
|
+
const clientError = makeClientError(401, {
|
|
149
|
+
error: {
|
|
150
|
+
type: 'projectUserNotFoundError',
|
|
151
|
+
description: 'User is not a member of this project.',
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
const error = new AuthError(clientError)
|
|
155
|
+
const mockReset = vi.fn()
|
|
156
|
+
|
|
157
|
+
render(
|
|
158
|
+
<ResourceProvider projectId="abc123" dataset="production" fallback={<div>SUSPENDED</div>}>
|
|
159
|
+
<LoginError error={error} resetErrorBoundary={mockReset} />
|
|
160
|
+
</ResourceProvider>,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
|
|
165
|
+
resourceType: 'project',
|
|
166
|
+
resourceId: 'abc123',
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
171
|
+
expect(screen.getByText('User is not a member of this project.')).toBeInTheDocument()
|
|
172
|
+
// projectUserNotFound intentionally hides the Retry CTA: the user can't
|
|
173
|
+
// fix it by retrying, only by getting access granted through the
|
|
174
|
+
// dashboard access request flow above.
|
|
175
|
+
expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument()
|
|
176
|
+
// Dashboard flow must never auto-log-out; ComlinkTokenRefreshProvider is
|
|
177
|
+
// responsible for any token mutation, not LoginError.
|
|
178
|
+
expect(mockLogout).not.toHaveBeenCalled()
|
|
179
|
+
expect(mockReset).not.toHaveBeenCalled()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// In a standalone app, an invalid-token 401 (anything other than
|
|
183
|
+
// `projectUserNotFoundError`) should silently log the user out so that
|
|
184
|
+
// AuthBoundary's LOGGED_OUT effect redirects to the Sanity login URL.
|
|
185
|
+
// AuthBoundary wraps the real ClientError in an AuthError before it reaches
|
|
186
|
+
// the error boundary, so the component must unwrap `.cause` to see it.
|
|
187
|
+
it('auto-logs-out on a non-projectUserNotFound 401 outside the dashboard', async () => {
|
|
188
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
|
|
189
|
+
|
|
190
|
+
const mockReset = vi.fn()
|
|
191
|
+
const clientError = makeClientError(401, {
|
|
192
|
+
error: {type: 'someOther401Type', description: 'Token is invalid'},
|
|
193
|
+
})
|
|
194
|
+
const error = new AuthError(clientError)
|
|
195
|
+
|
|
196
|
+
render(
|
|
197
|
+
<ResourceProvider fallback={null}>
|
|
198
|
+
<LoginError error={error} resetErrorBoundary={mockReset} />
|
|
199
|
+
</ResourceProvider>,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
203
|
+
expect(await screen.findByText('Signing you out and returning to login...')).toBeInTheDocument()
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(mockLogout).toHaveBeenCalled()
|
|
206
|
+
})
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(mockReset).toHaveBeenCalled()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// In the dashboard we must not auto-log-out on a generic 401.
|
|
213
|
+
// ComlinkTokenRefreshProvider is responsible for asking the parent window
|
|
214
|
+
// for a fresh token; the Retry button stays as a manual fallback.
|
|
215
|
+
it('does not auto-log-out on a non-projectUserNotFound 401 inside the dashboard', async () => {
|
|
216
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
|
|
217
|
+
|
|
218
|
+
const mockReset = vi.fn()
|
|
219
|
+
const clientError = makeClientError(401, {
|
|
220
|
+
error: {type: 'someOther401Type', description: 'Token is invalid'},
|
|
221
|
+
})
|
|
222
|
+
const error = new AuthError(clientError)
|
|
223
|
+
|
|
224
|
+
render(
|
|
225
|
+
<ResourceProvider projectId="abc123" dataset="production" fallback={null}>
|
|
226
|
+
<LoginError error={error} resetErrorBoundary={mockReset} />
|
|
227
|
+
</ResourceProvider>,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
231
|
+
expect(
|
|
232
|
+
screen.getByText('Please try again or contact support if the problem persists.'),
|
|
233
|
+
).toBeInTheDocument()
|
|
234
|
+
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument()
|
|
235
|
+
expect(mockLogout).not.toHaveBeenCalled()
|
|
236
|
+
expect(mockReset).not.toHaveBeenCalled()
|
|
237
|
+
// Generic 401s should not trigger the dashboard access request flow.
|
|
238
|
+
expect(mockWindowConnectionFetch).not.toHaveBeenCalled()
|
|
53
239
|
})
|
|
54
240
|
})
|