@sanity/sdk-react 3.3.0-rc.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/dashboard-internal.d.ts +2 -2
- package/dist/_exports/dashboard.d.ts +57 -1
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +69 -38
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +5 -139
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -145
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js → useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js} +85 -5
- package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +1 -0
- package/package.json +8 -8
- package/src/_exports/dashboard-internal.ts +6 -1
- package/src/_exports/dashboard.test-d.ts +30 -4
- package/src/_exports/dashboard.ts +3 -0
- package/src/_exports/sdk-react.ts +0 -3
- package/src/components/auth/AuthBoundary.test.tsx +2 -81
- package/src/components/auth/AuthBoundary.tsx +3 -17
- package/src/components/auth/LoginCallback.test.tsx +7 -46
- package/src/components/auth/LoginCallback.tsx +4 -22
- package/src/hooks/dashboard/useApplicationBasePath.test.tsx +77 -0
- package/src/hooks/dashboard/useApplicationBasePath.ts +23 -0
- package/src/hooks/dashboard/useApplications.test.tsx +12 -3
- package/src/hooks/dashboard/useApplications.ts +5 -24
- package/src/hooks/dashboard/useAuthToken.test.tsx +48 -0
- package/src/hooks/dashboard/useAuthToken.ts +20 -0
- package/src/hooks/dashboard/useCurrentUser.test.tsx +57 -0
- package/src/hooks/dashboard/useCurrentUser.ts +22 -0
- package/src/hooks/dashboard/useOrganizationId.test.tsx +52 -1
- package/src/hooks/dashboard/useOrganizationId.tsx +15 -2
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +142 -1
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +58 -1
- package/src/hooks/dashboard/useWindowTitle.test.ts +23 -0
- package/src/hooks/dashboard/useWindowTitle.ts +14 -0
- package/src/hooks/installations/useInstallations.test-d.ts +10 -0
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +0 -1
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
- package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
- package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
- package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
- package/src/hooks/auth/useOAuthTokens.tsx +0 -95
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost, TopicError} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {Suspense} from 'react'
|
|
4
|
+
import {ErrorBoundary} from 'react-error-boundary'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {act, render, screen} from '../../../test/test-utils'
|
|
8
|
+
import {useApplicationBasePath} from './useApplicationBasePath'
|
|
9
|
+
|
|
10
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
11
|
+
|
|
12
|
+
let host: MessageBusHost
|
|
13
|
+
|
|
14
|
+
describe('useApplicationBasePath', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
17
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
resetMessageBus()
|
|
22
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
23
|
+
vi.unstubAllGlobals()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('suspends until Dashboard publishes, then follows updates', async () => {
|
|
27
|
+
function BasePath() {
|
|
28
|
+
const basePath = useApplicationBasePath()
|
|
29
|
+
expectTypeOf(basePath).toEqualTypeOf<string>()
|
|
30
|
+
return <span>{basePath}</span>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
render(
|
|
34
|
+
<Suspense fallback="Loading">
|
|
35
|
+
<BasePath />
|
|
36
|
+
</Suspense>,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
40
|
+
|
|
41
|
+
await act(async () => {
|
|
42
|
+
host.connections.subscribe((client) =>
|
|
43
|
+
client.emit('applications.base-path', {ok: true, value: '/application/app'}),
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
expect(await screen.findByText('/application/app')).toBeInTheDocument()
|
|
47
|
+
|
|
48
|
+
act(() =>
|
|
49
|
+
host.connections.subscribe((client) =>
|
|
50
|
+
client.emit('applications.base-path', {ok: true, value: '/application/app-2'}),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
expect(screen.getByText('/application/app-2')).toBeInTheDocument()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('throws a TopicError when the application is unknown', () => {
|
|
57
|
+
host.connections.subscribe((client) => client.emit('applications.base-path', {ok: false}))
|
|
58
|
+
const onError = vi.fn()
|
|
59
|
+
|
|
60
|
+
function BasePath() {
|
|
61
|
+
return <span>{useApplicationBasePath()}</span>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render(
|
|
65
|
+
<ErrorBoundary fallback={<span>Failed</span>} onError={onError}>
|
|
66
|
+
<Suspense fallback="Loading">
|
|
67
|
+
<BasePath />
|
|
68
|
+
</Suspense>
|
|
69
|
+
</ErrorBoundary>,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('Failed')).toBeInTheDocument()
|
|
73
|
+
const error = onError.mock.calls[0][0] as TopicError
|
|
74
|
+
expect(error).toBeInstanceOf(TopicError)
|
|
75
|
+
expect(error.topic).toBe('applications.base-path')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {type TopicData} from '@sanity/sdk/dashboard'
|
|
2
|
+
|
|
3
|
+
import {useTopic} from './useTopic'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the base path for an application.
|
|
7
|
+
*
|
|
8
|
+
* Suspends until Dashboard publishes the path and throws a `TopicError` when the application is
|
|
9
|
+
* unknown.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function ApplicationBasePath() {
|
|
14
|
+
* const basePath = useApplicationBasePath()
|
|
15
|
+
* return <span>{basePath}</span>
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
export function useApplicationBasePath(): TopicData<'applications.base-path'> {
|
|
22
|
+
return useTopic('applications.base-path')
|
|
23
|
+
}
|
|
@@ -134,15 +134,24 @@ describe('useApplications', () => {
|
|
|
134
134
|
vi.restoreAllMocks()
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
-
it('returns
|
|
137
|
+
it('returns full applications with loadable views and web workers', () => {
|
|
138
138
|
emitApplications([application, nonFederatedApplication, nonSingletonApplication])
|
|
139
139
|
|
|
140
140
|
const {result} = renderHook(() => useApplications())
|
|
141
141
|
|
|
142
142
|
expectTypeOf(result.current).toEqualTypeOf<DashboardApplication[]>()
|
|
143
|
+
expectTypeOf<
|
|
144
|
+
Extract<keyof DashboardApplication, 'activeDeployment' | 'config'>
|
|
145
|
+
>().toEqualTypeOf<'activeDeployment' | 'config'>()
|
|
143
146
|
const [federated, nonFederated, nonSingleton] = result.current
|
|
144
|
-
expect(federated).
|
|
145
|
-
|
|
147
|
+
expect(federated).toMatchObject({
|
|
148
|
+
activeDeployment: {id: 'deployment-1'},
|
|
149
|
+
config: {mfManifest: {}},
|
|
150
|
+
})
|
|
151
|
+
expect(federated?.views[0]?.application).not.toHaveProperty('activeDeployment')
|
|
152
|
+
expect(federated?.views[0]?.application).not.toHaveProperty('config')
|
|
153
|
+
expect(federated?.webWorkers[0]?.application).not.toHaveProperty('activeDeployment')
|
|
154
|
+
expect(federated?.webWorkers[0]?.application).not.toHaveProperty('config')
|
|
146
155
|
expect(federated?.views).toEqual([
|
|
147
156
|
expect.objectContaining({
|
|
148
157
|
application: expect.objectContaining({id: 'application-1'}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {type ApplicationBase} from '@sanity/sdk'
|
|
2
|
+
import {getApplicationOrigin} from '@sanity/sdk/_internal'
|
|
2
3
|
import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
4
|
import {useMemo} from 'react'
|
|
4
5
|
|
|
@@ -38,31 +39,11 @@ export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: '
|
|
|
38
39
|
* The minimal Brett application fields with its loadable views and web workers.
|
|
39
40
|
* @public
|
|
40
41
|
*/
|
|
41
|
-
export type DashboardApplication =
|
|
42
|
+
export type DashboardApplication = DashboardTopicApplication & {
|
|
42
43
|
readonly views: DashboardView[]
|
|
43
44
|
readonly webWorkers: DashboardWebWorker[]
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
|
|
47
|
-
|
|
48
|
-
// `null` when neither address exists: the caller exposes no modules for that application
|
|
49
|
-
// rather than failing the whole list on one bad record.
|
|
50
|
-
const applicationOrigin = (application: ApplicationBase): string | null => {
|
|
51
|
-
if (application.externalUrl !== null) return new URL(application.externalUrl).origin
|
|
52
|
-
if (application.slug === null) return null
|
|
53
|
-
|
|
54
|
-
// Read at runtime, not via a bundler define: a remote is built once and runs in whichever
|
|
55
|
-
// host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
|
|
56
|
-
const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
|
|
57
|
-
if (application.isSingleton) {
|
|
58
|
-
const domain = staging ? 'run.sanity.work' : 'sanity.run'
|
|
59
|
-
return `https://${application.slug}-apps-${application.organizationId}.${domain}`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
|
|
63
|
-
return `https://${application.slug}.${domain}`
|
|
64
|
-
}
|
|
65
|
-
|
|
66
47
|
// Only a federated deployment (one with a module federation manifest) exposes loadable modules.
|
|
67
48
|
const loadableInterfaces = ({
|
|
68
49
|
activeDeployment,
|
|
@@ -74,8 +55,8 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
|
|
|
74
55
|
const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
|
|
75
56
|
const interfaces = loadableInterfaces(application)
|
|
76
57
|
// Nothing to load without interfaces or an origin to load them from.
|
|
77
|
-
const entry = interfaces.length === 0 ? null :
|
|
78
|
-
if (entry === null) return {...
|
|
58
|
+
const entry = interfaces.length === 0 ? null : getApplicationOrigin(applicationBase)
|
|
59
|
+
if (entry === null) return {...application, views: [], webWorkers: []}
|
|
79
60
|
|
|
80
61
|
const views: DashboardView[] = []
|
|
81
62
|
const webWorkers: DashboardWebWorker[] = []
|
|
@@ -103,7 +84,7 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
|
|
|
103
84
|
} as DashboardView)
|
|
104
85
|
}
|
|
105
86
|
|
|
106
|
-
return {...
|
|
87
|
+
return {...application, views, webWorkers}
|
|
107
88
|
}
|
|
108
89
|
|
|
109
90
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {Suspense} from 'react'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {act, render, screen} from '../../../test/test-utils'
|
|
7
|
+
import {useAuthToken} from './useAuthToken'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
describe('useAuthToken', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
16
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
17
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
resetMessageBus()
|
|
22
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
23
|
+
vi.unstubAllGlobals()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('suspends until Dashboard publishes, then follows updates', async () => {
|
|
27
|
+
function Token() {
|
|
28
|
+
const token = useAuthToken()
|
|
29
|
+
expectTypeOf(token).toEqualTypeOf<string | null>()
|
|
30
|
+
return <span>{token ?? 'none'}</span>
|
|
31
|
+
}
|
|
32
|
+
render(
|
|
33
|
+
<Suspense fallback="Loading">
|
|
34
|
+
<Token />
|
|
35
|
+
</Suspense>,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
39
|
+
|
|
40
|
+
await act(async () => {
|
|
41
|
+
host.connections.subscribe((client) => client.emit('auth.token', null))
|
|
42
|
+
})
|
|
43
|
+
expect(await screen.findByText('none')).toBeInTheDocument()
|
|
44
|
+
|
|
45
|
+
act(() => host.connections.subscribe((client) => client.emit('auth.token', 'token-1')))
|
|
46
|
+
expect(screen.getByText('token-1')).toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {useTopic} from './useTopic'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the session token for the reading connection, or `null` while signed out.
|
|
5
|
+
*
|
|
6
|
+
* Suspends until Dashboard publishes the token.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function AuthToken() {
|
|
11
|
+
* const token = useAuthToken()
|
|
12
|
+
* return <span>{token ?? 'Signed out'}</span>
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export function useAuthToken(): string | null {
|
|
19
|
+
return useTopic('auth.token')
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {type CurrentUser} from '@sanity/types'
|
|
4
|
+
import {Suspense} from 'react'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {act, render, screen} from '../../../test/test-utils'
|
|
8
|
+
import {useCurrentUser} from './useCurrentUser'
|
|
9
|
+
|
|
10
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
11
|
+
|
|
12
|
+
const user: CurrentUser = {
|
|
13
|
+
id: 'user-1',
|
|
14
|
+
name: 'Ada Lovelace',
|
|
15
|
+
email: 'ada@example.com',
|
|
16
|
+
role: '',
|
|
17
|
+
roles: [],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let host: MessageBusHost
|
|
21
|
+
|
|
22
|
+
describe('useCurrentUser', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
25
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
26
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
resetMessageBus()
|
|
31
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
32
|
+
vi.unstubAllGlobals()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('suspends until Dashboard publishes, then follows updates', async () => {
|
|
36
|
+
function User() {
|
|
37
|
+
const current = useCurrentUser()
|
|
38
|
+
expectTypeOf(current).toEqualTypeOf<CurrentUser | null>()
|
|
39
|
+
return <span>{current?.name ?? 'none'}</span>
|
|
40
|
+
}
|
|
41
|
+
render(
|
|
42
|
+
<Suspense fallback="Loading">
|
|
43
|
+
<User />
|
|
44
|
+
</Suspense>,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
48
|
+
|
|
49
|
+
await act(async () => {
|
|
50
|
+
host.connections.subscribe((client) => client.emit('users.current', null))
|
|
51
|
+
})
|
|
52
|
+
expect(await screen.findByText('none')).toBeInTheDocument()
|
|
53
|
+
|
|
54
|
+
act(() => host.connections.subscribe((client) => client.emit('users.current', user)))
|
|
55
|
+
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {type CurrentUser} from '@sanity/types'
|
|
2
|
+
|
|
3
|
+
import {useTopic} from './useTopic'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the signed-in user, or `null` while signed out.
|
|
7
|
+
*
|
|
8
|
+
* Suspends until Dashboard publishes the current user.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* function CurrentUser() {
|
|
13
|
+
* const user = useCurrentUser()
|
|
14
|
+
* return <span>{user?.name ?? 'Signed out'}</span>
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export function useCurrentUser(): CurrentUser | null {
|
|
21
|
+
return useTopic('users.current')
|
|
22
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {getDashboardOrganizationId} from '@sanity/sdk'
|
|
2
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
3
|
+
import {type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
2
4
|
import {renderHook} from '@testing-library/react'
|
|
3
5
|
import {throwError} from 'rxjs'
|
|
4
|
-
import {describe, expect, it, vi} from 'vitest'
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
7
|
|
|
8
|
+
import {act} from '../../../test/test-utils'
|
|
6
9
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
10
|
import {useOrganizationId} from './useOrganizationId'
|
|
8
11
|
|
|
@@ -11,6 +14,8 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
|
11
14
|
return {...(actual || {}), getDashboardOrganizationId: vi.fn()}
|
|
12
15
|
})
|
|
13
16
|
|
|
17
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
18
|
+
|
|
14
19
|
describe('useOrganizationId', () => {
|
|
15
20
|
it('should return undefined when no organization ID is set', () => {
|
|
16
21
|
const subscribe = vi.fn()
|
|
@@ -49,3 +54,49 @@ describe('useOrganizationId', () => {
|
|
|
49
54
|
expect(result.current).toBe(mockOrgId)
|
|
50
55
|
})
|
|
51
56
|
})
|
|
57
|
+
|
|
58
|
+
describe('useOrganizationId (message bus)', () => {
|
|
59
|
+
let host: MessageBusHost
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
63
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
resetMessageBus()
|
|
68
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
69
|
+
vi.unstubAllGlobals()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
73
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
74
|
+
{children}
|
|
75
|
+
</ResourceProvider>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
it('is undefined before the host publishes the current organization', () => {
|
|
79
|
+
const {result} = renderHook(() => useOrganizationId(), {wrapper})
|
|
80
|
+
expect(result.current).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns the organization id once the host publishes it', () => {
|
|
84
|
+
const {result} = renderHook(() => useOrganizationId(), {wrapper})
|
|
85
|
+
|
|
86
|
+
act(() =>
|
|
87
|
+
host.connections.subscribe((client) =>
|
|
88
|
+
client.emit('organizations.current', {id: 'org_123', name: 'Org', slug: 'org'}),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result.current).toBe('org_123')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is undefined when the host publishes no active organization', () => {
|
|
96
|
+
const {result} = renderHook(() => useOrganizationId(), {wrapper})
|
|
97
|
+
|
|
98
|
+
act(() => host.connections.subscribe((client) => client.emit('organizations.current', null)))
|
|
99
|
+
|
|
100
|
+
expect(result.current).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import {getDashboardOrganizationId} from '@sanity/sdk'
|
|
1
|
+
import {getDashboardOrganizationId, type OrganizationBase} from '@sanity/sdk'
|
|
2
|
+
import {getTopicState, isDashboardEnvironment} from '@sanity/sdk/_internal'
|
|
2
3
|
import {useMemo, useSyncExternalStore} from 'react'
|
|
3
4
|
|
|
4
5
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
6
|
|
|
7
|
+
type CurrentOrganization = Pick<OrganizationBase, 'id' | 'name' | 'slug'> | null | undefined
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* @public
|
|
8
11
|
*
|
|
9
12
|
* A React hook that retrieves the dashboard organization ID that is currently selected in the Sanity Dashboard.
|
|
10
13
|
*
|
|
14
|
+
* Works in both Dashboard runtimes: it reads the `organizations.current` message bus topic when a
|
|
15
|
+
* host has installed the bus, and falls back to the Comlink connection otherwise.
|
|
16
|
+
*
|
|
11
17
|
* @example
|
|
12
18
|
* ```tsx
|
|
13
19
|
* function DashboardComponent() {
|
|
@@ -24,7 +30,14 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
24
30
|
*/
|
|
25
31
|
export function useOrganizationId(): string | undefined {
|
|
26
32
|
const instance = useSanityInstance()
|
|
27
|
-
const {subscribe, getCurrent} = useMemo(() =>
|
|
33
|
+
const {subscribe, getCurrent} = useMemo(() => {
|
|
34
|
+
if (!isDashboardEnvironment()) return getDashboardOrganizationId(instance)
|
|
35
|
+
const source = getTopicState(instance, 'organizations.current')
|
|
36
|
+
return {
|
|
37
|
+
subscribe: source.subscribe,
|
|
38
|
+
getCurrent: () => (source.getCurrent() as CurrentOrganization)?.id ?? undefined,
|
|
39
|
+
}
|
|
40
|
+
}, [instance])
|
|
28
41
|
|
|
29
42
|
return useSyncExternalStore(subscribe, getCurrent)
|
|
30
43
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost, TopicError, type ValueOf} from '@sanity/sdk/dashboard'
|
|
1
3
|
import {renderHook, waitFor} from '@testing-library/react'
|
|
2
|
-
import {
|
|
4
|
+
import {Suspense} from 'react'
|
|
5
|
+
import {ErrorBoundary} from 'react-error-boundary'
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
7
|
|
|
8
|
+
import {render, renderHook as renderHookWithInstance, screen} from '../../../test/test-utils'
|
|
4
9
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
5
10
|
import {useStudioWorkspacesByProjectIdDataset} from './useStudioWorkspacesByProjectIdDataset'
|
|
6
11
|
|
|
@@ -218,3 +223,139 @@ describe('useStudioWorkspacesByResourceId', () => {
|
|
|
218
223
|
})
|
|
219
224
|
})
|
|
220
225
|
})
|
|
226
|
+
|
|
227
|
+
describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
|
|
228
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
229
|
+
let host: MessageBusHost
|
|
230
|
+
|
|
231
|
+
const deployment = {
|
|
232
|
+
id: 'deployment-1',
|
|
233
|
+
applicationId: 'studio-1',
|
|
234
|
+
size: null,
|
|
235
|
+
version: '4.0.0',
|
|
236
|
+
isAutoUpdating: true,
|
|
237
|
+
isActiveDeployment: true,
|
|
238
|
+
deployedBy: null,
|
|
239
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
240
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
241
|
+
interfaces: [],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const workspace = {
|
|
245
|
+
id: 'workspace-1',
|
|
246
|
+
name: 'production',
|
|
247
|
+
title: 'Production',
|
|
248
|
+
subtitle: null,
|
|
249
|
+
projectId: 'project1',
|
|
250
|
+
dataset: 'dataset1',
|
|
251
|
+
schemaDescriptorId: null,
|
|
252
|
+
basePath: '/production',
|
|
253
|
+
icon: null,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const studio = {
|
|
257
|
+
id: 'studio-1',
|
|
258
|
+
type: 'studio',
|
|
259
|
+
title: 'My Studio',
|
|
260
|
+
name: 'my-studio',
|
|
261
|
+
reference: 'organization-1/my-studio',
|
|
262
|
+
icon: null,
|
|
263
|
+
isSingleton: false,
|
|
264
|
+
visibility: 'default',
|
|
265
|
+
slug: 'my-studio',
|
|
266
|
+
externalUrl: null,
|
|
267
|
+
organizationId: 'organization-1',
|
|
268
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
269
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
270
|
+
config: {},
|
|
271
|
+
activeDeployment: {
|
|
272
|
+
...deployment,
|
|
273
|
+
workspaces: [
|
|
274
|
+
workspace,
|
|
275
|
+
{...workspace, id: 'workspace-2', name: 'staging', title: null, basePath: null},
|
|
276
|
+
{...workspace, id: 'workspace-3', projectId: 'project2', dataset: 'dataset2'},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const coreApp = {
|
|
282
|
+
...studio,
|
|
283
|
+
id: 'app-1',
|
|
284
|
+
type: 'coreApp',
|
|
285
|
+
activeDeployment: {...deployment, applicationId: 'app-1', workspaces: [workspace]},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const emitApplications = (value: unknown[] | null) =>
|
|
289
|
+
host.connections.subscribe((client) =>
|
|
290
|
+
client.emit(
|
|
291
|
+
'applications.list',
|
|
292
|
+
(value === null ? null : {ok: true, value}) as ValueOf<'applications.list'>,
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
298
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
afterEach(() => {
|
|
302
|
+
resetMessageBus()
|
|
303
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
304
|
+
vi.unstubAllGlobals()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('maps studio workspaces to resources keyed by projectId:dataset', () => {
|
|
308
|
+
emitApplications([studio, coreApp])
|
|
309
|
+
|
|
310
|
+
const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
|
|
311
|
+
|
|
312
|
+
expect(useWindowConnection).not.toHaveBeenCalled()
|
|
313
|
+
expect(result.current.error).toBeNull()
|
|
314
|
+
expect(result.current.workspacesByProjectIdAndDataset).toEqual({
|
|
315
|
+
'project1:dataset1': [
|
|
316
|
+
{
|
|
317
|
+
id: 'workspace-1',
|
|
318
|
+
name: 'production',
|
|
319
|
+
title: 'Production',
|
|
320
|
+
basePath: '/production',
|
|
321
|
+
projectId: 'project1',
|
|
322
|
+
dataset: 'dataset1',
|
|
323
|
+
type: 'studio',
|
|
324
|
+
userApplicationId: 'studio-1',
|
|
325
|
+
url: 'https://my-studio.sanity.studio',
|
|
326
|
+
},
|
|
327
|
+
expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
|
|
328
|
+
],
|
|
329
|
+
'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('returns an empty map when the dashboard clears its applications', () => {
|
|
334
|
+
emitApplications(null)
|
|
335
|
+
|
|
336
|
+
const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
|
|
337
|
+
|
|
338
|
+
expect(result.current.workspacesByProjectIdAndDataset).toEqual({})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('throws a TopicError to the error boundary when the dashboard fails to load applications', () => {
|
|
342
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
|
|
343
|
+
const onError = vi.fn()
|
|
344
|
+
|
|
345
|
+
function Workspaces() {
|
|
346
|
+
const {workspacesByProjectIdAndDataset} = useStudioWorkspacesByProjectIdDataset()
|
|
347
|
+
return <span>{Object.keys(workspacesByProjectIdAndDataset).length} workspaces</span>
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
render(
|
|
351
|
+
<ErrorBoundary fallback={<span>Failed</span>} onError={onError}>
|
|
352
|
+
<Suspense fallback="Loading">
|
|
353
|
+
<Workspaces />
|
|
354
|
+
</Suspense>
|
|
355
|
+
</ErrorBoundary>,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
expect(screen.getByText('Failed')).toBeInTheDocument()
|
|
359
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(TopicError)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
/* eslint-disable react-compiler/react-compiler -- the transport branch in `useStudioWorkspacesByProjectIdDataset` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
|
|
1
2
|
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
-
import {
|
|
3
|
+
import {getApplicationOrigin, isDashboardEnvironment} from '@sanity/sdk/_internal'
|
|
4
|
+
import {type TopicData} from '@sanity/sdk/dashboard'
|
|
5
|
+
import {useEffect, useMemo, useState} from 'react'
|
|
3
6
|
|
|
4
7
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
8
|
+
import {useTopic} from './useTopic'
|
|
5
9
|
|
|
6
10
|
export interface DashboardResource {
|
|
7
11
|
id: string
|
|
@@ -24,8 +28,13 @@ interface StudioWorkspacesResult {
|
|
|
24
28
|
error: string | null
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
type DashboardApplications = NonNullable<TopicData<'applications.list'>>
|
|
32
|
+
|
|
27
33
|
/**
|
|
28
34
|
* Hook that fetches studio workspaces and organizes them by projectId:dataset
|
|
35
|
+
*
|
|
36
|
+
* Works in both Dashboard runtimes: it derives workspaces from the `applications.list` message
|
|
37
|
+
* bus topic when a host has installed the bus, and falls back to the Comlink connection otherwise.
|
|
29
38
|
* @internal
|
|
30
39
|
*
|
|
31
40
|
* @example
|
|
@@ -59,6 +68,54 @@ interface StudioWorkspacesResult {
|
|
|
59
68
|
* ```
|
|
60
69
|
*/
|
|
61
70
|
export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult {
|
|
71
|
+
// The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
|
|
72
|
+
// always runs and the other never does.
|
|
73
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
74
|
+
if (isDashboardEnvironment()) return useBusStudioWorkspaces()
|
|
75
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
76
|
+
return useComlinkStudioWorkspaces()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The legacy Comlink protocol models studios at the workspace level, so each workspace of a
|
|
80
|
+
// studio's active deployment becomes one resource, addressed by the studio's origin.
|
|
81
|
+
function toResources(application: DashboardApplications[number]): DashboardResource[] {
|
|
82
|
+
if (application.type !== 'studio') return []
|
|
83
|
+
const url = getApplicationOrigin(application) ?? ''
|
|
84
|
+
return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
|
|
85
|
+
id: workspace.id,
|
|
86
|
+
name: workspace.name,
|
|
87
|
+
title: workspace.title ?? application.title,
|
|
88
|
+
basePath: workspace.basePath ?? '',
|
|
89
|
+
projectId: workspace.projectId,
|
|
90
|
+
dataset: workspace.dataset,
|
|
91
|
+
type: 'studio',
|
|
92
|
+
userApplicationId: application.id,
|
|
93
|
+
url,
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toWorkspaceMap(applications: DashboardApplications): WorkspacesByProjectIdDataset {
|
|
98
|
+
const workspaceMap: WorkspacesByProjectIdDataset = {}
|
|
99
|
+
for (const resource of applications.flatMap(toResources)) {
|
|
100
|
+
const key = `${resource.projectId}:${resource.dataset}` as const
|
|
101
|
+
workspaceMap[key] ??= []
|
|
102
|
+
workspaceMap[key].push(resource)
|
|
103
|
+
}
|
|
104
|
+
return workspaceMap
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Suspends until the host publishes its application list and throws a `TopicError` on failure,
|
|
108
|
+
// like every bus hook, so `error` is always `null` on this path.
|
|
109
|
+
function useBusStudioWorkspaces(): StudioWorkspacesResult {
|
|
110
|
+
const applications = useTopic('applications.list')
|
|
111
|
+
const workspacesByProjectIdAndDataset = useMemo(
|
|
112
|
+
() => toWorkspaceMap(applications ?? []),
|
|
113
|
+
[applications],
|
|
114
|
+
)
|
|
115
|
+
return {workspacesByProjectIdAndDataset, error: null}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function useComlinkStudioWorkspaces(): StudioWorkspacesResult {
|
|
62
119
|
const [workspacesByProjectIdAndDataset, setWorkspacesByProjectIdAndDataset] =
|
|
63
120
|
useState<WorkspacesByProjectIdDataset>({})
|
|
64
121
|
const [error, setError] = useState<string | null>(null)
|