@sanity/sdk-react 3.1.0 → 3.3.0-rc.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 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +227 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -30
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/_exports/sdk-react.ts +3 -0
- package/src/components/auth/AuthBoundary.test.tsx +115 -2
- package/src/components/auth/AuthBoundary.tsx +18 -5
- package/src/components/auth/LoginCallback.test.tsx +46 -7
- package/src/components/auth/LoginCallback.tsx +22 -4
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/useHandleOAuthCallback.tsx +49 -0
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +16 -0
- package/src/hooks/auth/useOAuthAuthorize.tsx +28 -0
- package/src/hooks/auth/useOAuthTokens.test.tsx +240 -0
- package/src/hooks/auth/useOAuthTokens.tsx +95 -0
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOAuthTokensState,
|
|
3
|
+
type OAuthTokens,
|
|
4
|
+
refreshOAuthTokens,
|
|
5
|
+
revokeOAuthTokens,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {act, renderHook} from '@testing-library/react'
|
|
9
|
+
import {throwError} from 'rxjs'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
13
|
+
import {useOAuthTokens} from './useOAuthTokens'
|
|
14
|
+
|
|
15
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
17
|
+
return {
|
|
18
|
+
...original,
|
|
19
|
+
getOAuthTokensState: vi.fn(),
|
|
20
|
+
refreshOAuthTokens: vi.fn(),
|
|
21
|
+
revokeOAuthTokens: vi.fn(),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A controllable stand-in for core's token state source. `set` mimics core
|
|
27
|
+
* updating the store (refresh/revoke or a cross-tab `storage` event) and
|
|
28
|
+
* notifies subscribers so the hook re-renders.
|
|
29
|
+
*/
|
|
30
|
+
function createFakeTokenSource(initial: OAuthTokens | null) {
|
|
31
|
+
let current = initial
|
|
32
|
+
const listeners = new Set<() => void>()
|
|
33
|
+
const source: StateSource<OAuthTokens | null> & {set: (next: OAuthTokens | null) => void} = {
|
|
34
|
+
subscribe: (onStoreChanged?: () => void) => {
|
|
35
|
+
if (onStoreChanged) listeners.add(onStoreChanged)
|
|
36
|
+
return () => {
|
|
37
|
+
if (onStoreChanged) listeners.delete(onStoreChanged)
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
getCurrent: () => current,
|
|
41
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
42
|
+
set: (next) => {
|
|
43
|
+
current = next
|
|
44
|
+
for (const listener of listeners) listener()
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
return source
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const makeTokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
|
|
51
|
+
accessToken: 'access-token',
|
|
52
|
+
tokenType: 'bearer',
|
|
53
|
+
expiresIn: 3600,
|
|
54
|
+
expiresAt: new Date(Date.now() + 3600_000),
|
|
55
|
+
refreshToken: 'refresh-token',
|
|
56
|
+
...overrides,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
60
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
61
|
+
{children}
|
|
62
|
+
</ResourceProvider>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
describe('useOAuthTokens', () => {
|
|
66
|
+
const mockGetState = vi.mocked(getOAuthTokensState)
|
|
67
|
+
const mockRefresh = vi.mocked(refreshOAuthTokens)
|
|
68
|
+
const mockRevoke = vi.mocked(revokeOAuthTokens)
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.clearAllMocks()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.useRealTimers()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns the stored tokens with isExpired=false when expiresAt is in the future', () => {
|
|
79
|
+
const tokens = makeTokens()
|
|
80
|
+
mockGetState.mockReturnValue(createFakeTokenSource(tokens))
|
|
81
|
+
|
|
82
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
83
|
+
|
|
84
|
+
expect(result.current.tokens).toEqual(tokens)
|
|
85
|
+
expect(result.current.isExpired()).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('derives isExpired=true when expiresAt is in the past', () => {
|
|
89
|
+
mockGetState.mockReturnValue(
|
|
90
|
+
createFakeTokenSource(makeTokens({expiresAt: new Date(Date.now() - 1000)})),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
94
|
+
|
|
95
|
+
expect(result.current.isExpired()).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('treats expiresAt exactly equal to now as expired (<= boundary)', () => {
|
|
99
|
+
vi.useFakeTimers()
|
|
100
|
+
const now = new Date('2030-01-01T00:00:00.000Z')
|
|
101
|
+
vi.setSystemTime(now)
|
|
102
|
+
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt: new Date(now)})))
|
|
103
|
+
|
|
104
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
105
|
+
|
|
106
|
+
expect(result.current.isExpired()).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('re-evaluates isExpired against the clock at call time, with no re-render or token change', () => {
|
|
110
|
+
vi.useFakeTimers()
|
|
111
|
+
vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z'))
|
|
112
|
+
const expiresAt = new Date(Date.now() + 10_000)
|
|
113
|
+
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt})))
|
|
114
|
+
|
|
115
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
116
|
+
const {isExpired} = result.current
|
|
117
|
+
expect(isExpired()).toBe(false)
|
|
118
|
+
|
|
119
|
+
// Advance past expiry. The same function reference must now report expired,
|
|
120
|
+
// proving the read happens at call time, not render time.
|
|
121
|
+
vi.setSystemTime(new Date(expiresAt.getTime() + 1000))
|
|
122
|
+
expect(isExpired()).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('returns tokens=null and isExpired=false when there are no tokens', () => {
|
|
126
|
+
mockGetState.mockReturnValue(createFakeTokenSource(null))
|
|
127
|
+
|
|
128
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
129
|
+
|
|
130
|
+
expect(result.current.tokens).toBeNull()
|
|
131
|
+
expect(result.current.isExpired()).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('calls core refreshOAuthTokens and re-renders with the new tokens', async () => {
|
|
135
|
+
const source = createFakeTokenSource(makeTokens())
|
|
136
|
+
mockGetState.mockReturnValue(source)
|
|
137
|
+
const refreshed = makeTokens({accessToken: 'refreshed-token'})
|
|
138
|
+
mockRefresh.mockImplementation(() => {
|
|
139
|
+
source.set(refreshed)
|
|
140
|
+
return Promise.resolve(refreshed)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
144
|
+
|
|
145
|
+
let returned: OAuthTokens | null = null
|
|
146
|
+
await act(async () => {
|
|
147
|
+
returned = await result.current.refresh()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1)
|
|
151
|
+
expect(returned).toEqual(refreshed)
|
|
152
|
+
expect(result.current.tokens).toEqual(refreshed)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('resolves null from refresh when core has no refresh token, and tokens become null', async () => {
|
|
156
|
+
const existing = makeTokens({refreshToken: undefined})
|
|
157
|
+
const source = createFakeTokenSource(existing)
|
|
158
|
+
mockGetState.mockReturnValue(source)
|
|
159
|
+
// Core clears stored tokens and logs out on the no-refresh-token path.
|
|
160
|
+
mockRefresh.mockImplementation(() => {
|
|
161
|
+
source.set(null)
|
|
162
|
+
return Promise.resolve(null)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
166
|
+
|
|
167
|
+
let returned: OAuthTokens | null = existing
|
|
168
|
+
await act(async () => {
|
|
169
|
+
returned = await result.current.refresh()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(returned).toBeNull()
|
|
173
|
+
expect(result.current.tokens).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('propagates a rejected refresh and leaves tokens unchanged', async () => {
|
|
177
|
+
const existing = makeTokens()
|
|
178
|
+
mockGetState.mockReturnValue(createFakeTokenSource(existing))
|
|
179
|
+
mockRefresh.mockRejectedValue(new Error('network'))
|
|
180
|
+
|
|
181
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await expect(result.current.refresh()).rejects.toThrow('network')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(result.current.tokens).toEqual(existing)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('propagates an unrecoverable refresh rejection after core has cleared tokens', async () => {
|
|
191
|
+
const source = createFakeTokenSource(makeTokens())
|
|
192
|
+
mockGetState.mockReturnValue(source)
|
|
193
|
+
// Core clears stored tokens and logs out before rethrowing a 4xx.
|
|
194
|
+
mockRefresh.mockImplementation(() => {
|
|
195
|
+
source.set(null)
|
|
196
|
+
return Promise.reject(new Error('invalid_grant'))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
await expect(result.current.refresh()).rejects.toThrow('invalid_grant')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(result.current.tokens).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('calls core revokeOAuthTokens and re-renders with tokens=null', async () => {
|
|
209
|
+
const source = createFakeTokenSource(makeTokens())
|
|
210
|
+
mockGetState.mockReturnValue(source)
|
|
211
|
+
mockRevoke.mockImplementation(() => {
|
|
212
|
+
source.set(null)
|
|
213
|
+
return Promise.resolve()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
await result.current.revoke()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(mockRevoke).toHaveBeenCalledTimes(1)
|
|
223
|
+
expect(result.current.tokens).toBeNull()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('re-renders when tokens change externally (e.g. another tab)', () => {
|
|
227
|
+
const source = createFakeTokenSource(null)
|
|
228
|
+
mockGetState.mockReturnValue(source)
|
|
229
|
+
|
|
230
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
231
|
+
expect(result.current.tokens).toBeNull()
|
|
232
|
+
|
|
233
|
+
const otherTabTokens = makeTokens({accessToken: 'other-tab-token'})
|
|
234
|
+
act(() => {
|
|
235
|
+
source.set(otherTabTokens)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(result.current.tokens).toEqual(otherTabTokens)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOAuthTokensState,
|
|
3
|
+
type OAuthTokens,
|
|
4
|
+
refreshOAuthTokens,
|
|
5
|
+
revokeOAuthTokens,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
|
|
8
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
9
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The current OAuth token state, plus actions to refresh and revoke it.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
export interface UseOAuthTokensResult {
|
|
17
|
+
/** The stored OAuth tokens, or `null` when not logged in via OAuth */
|
|
18
|
+
tokens: OAuthTokens | null
|
|
19
|
+
/**
|
|
20
|
+
* Returns whether the access token has expired, comparing `expiresAt` against
|
|
21
|
+
* the current time at the moment it is called. Reading the clock does not
|
|
22
|
+
* trigger a re-render, so call this in an event handler or effect rather than
|
|
23
|
+
* during render. Returns `false` when there are no tokens.
|
|
24
|
+
*/
|
|
25
|
+
isExpired: () => boolean
|
|
26
|
+
/**
|
|
27
|
+
* Refresh via the OAuth `refresh_token` grant. When there is no refresh token,
|
|
28
|
+
* core clears the stored tokens, logs the user out, and this resolves `null`.
|
|
29
|
+
* Rejects on transient failures (network, 5xx, 408, 429), leaving tokens
|
|
30
|
+
* unchanged so the call can be retried. Also rejects when the server rejects
|
|
31
|
+
* the refresh token itself (other 4xx); core clears the tokens and logs out
|
|
32
|
+
* first, so check `tokens` before retrying.
|
|
33
|
+
*/
|
|
34
|
+
refresh: () => Promise<OAuthTokens | null>
|
|
35
|
+
/** Revoke the tokens at the OAuth server, clear them locally, and log out. */
|
|
36
|
+
revoke: () => Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const useOAuthTokensState = createStateSourceHook(getOAuthTokensState)
|
|
40
|
+
const useRefreshOAuthTokens = createCallbackHook(refreshOAuthTokens)
|
|
41
|
+
const useRevokeOAuthTokens = createCallbackHook(revokeOAuthTokens)
|
|
42
|
+
|
|
43
|
+
function isOAuthTokenExpired(tokens: OAuthTokens | null): boolean {
|
|
44
|
+
return tokens ? tokens.expiresAt.getTime() <= Date.now() : false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A React hook that exposes the stored OAuth token state along with `refresh`
|
|
49
|
+
* and `revoke` actions.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* The token view is a synchronous read over core's token state source, so the
|
|
53
|
+
* hook re-renders whenever tokens change — including changes made in another
|
|
54
|
+
* tab, which core propagates via `storage` events.
|
|
55
|
+
*
|
|
56
|
+
* @returns The current {@link UseOAuthTokensResult}
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* function TokenStatus() {
|
|
61
|
+
* const {tokens, isExpired, refresh, revoke} = useOAuthTokens()
|
|
62
|
+
*
|
|
63
|
+
* if (!tokens) return <div>Not signed in</div>
|
|
64
|
+
*
|
|
65
|
+
* const handleRefresh = async () => {
|
|
66
|
+
* if (!isExpired()) return
|
|
67
|
+
* try {
|
|
68
|
+
* await refresh()
|
|
69
|
+
* } catch {
|
|
70
|
+
* // Transient failure (tokens unchanged, retry later) or the refresh
|
|
71
|
+
* // token was rejected (tokens now null, user is logged out).
|
|
72
|
+
* }
|
|
73
|
+
* }
|
|
74
|
+
*
|
|
75
|
+
* return (
|
|
76
|
+
* <div>
|
|
77
|
+
* <p>Expires at {tokens.expiresAt.toLocaleTimeString()}</p>
|
|
78
|
+
* <button onClick={handleRefresh}>Refresh if expired</button>
|
|
79
|
+
* <button onClick={() => revoke()}>Revoke tokens</button>
|
|
80
|
+
* </div>
|
|
81
|
+
* )
|
|
82
|
+
* }
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @public
|
|
86
|
+
*/
|
|
87
|
+
export function useOAuthTokens(): UseOAuthTokensResult {
|
|
88
|
+
const tokens = useOAuthTokensState()
|
|
89
|
+
return {
|
|
90
|
+
tokens,
|
|
91
|
+
isExpired: () => isOAuthTokenExpired(tokens),
|
|
92
|
+
refresh: useRefreshOAuthTokens(),
|
|
93
|
+
revoke: useRevokeOAuthTokens(),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {renderHook} from '../../../test/test-utils'
|
|
6
|
+
import {useApplication} from './useApplication'
|
|
7
|
+
import {type DashboardApplication} from './useApplications'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
const application = {
|
|
14
|
+
id: 'application-1',
|
|
15
|
+
type: 'coreApp',
|
|
16
|
+
title: 'Inbox',
|
|
17
|
+
name: 'inbox',
|
|
18
|
+
reference: 'sanity/inbox',
|
|
19
|
+
icon: null,
|
|
20
|
+
isSingleton: true,
|
|
21
|
+
visibility: 'default',
|
|
22
|
+
slug: 'inbox',
|
|
23
|
+
externalUrl: null,
|
|
24
|
+
organizationId: 'organization-1',
|
|
25
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
26
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useApplication', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
32
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
33
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
34
|
+
host.connections.subscribe((client) =>
|
|
35
|
+
client.emit('applications.list', {
|
|
36
|
+
ok: true,
|
|
37
|
+
value: [application],
|
|
38
|
+
} as ValueOf<'applications.list'>),
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
resetMessageBus()
|
|
44
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
45
|
+
vi.unstubAllGlobals()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns an application by id or null', () => {
|
|
49
|
+
const {result, rerender} = renderHook(({applicationId}) => useApplication(applicationId), {
|
|
50
|
+
initialProps: {applicationId: application.id},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expectTypeOf(result.current).toEqualTypeOf<DashboardApplication | null>()
|
|
54
|
+
expect(result.current?.id).toBe(application.id)
|
|
55
|
+
|
|
56
|
+
rerender({applicationId: 'missing'})
|
|
57
|
+
expect(result.current).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {type DashboardApplication, useApplications} from './useApplications'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a dashboard application by id, or `null` when it is unavailable.
|
|
5
|
+
*
|
|
6
|
+
* Suspends until the dashboard publishes its application list.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function SelectedApplication({id}: {id: string}) {
|
|
11
|
+
* const application = useApplication(id)
|
|
12
|
+
* return application ? <h1>{application.title}</h1> : <p>Application unavailable</p>
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export function useApplication(
|
|
19
|
+
applicationId: DashboardApplication['id'],
|
|
20
|
+
): DashboardApplication | null {
|
|
21
|
+
return useApplications().find(({id}) => id === applicationId) ?? null
|
|
22
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type ApplicationConfig, 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, renderHook, screen} from '../../../test/test-utils'
|
|
7
|
+
import {type ApplicationConfigSelector, useApplicationConfig} from './useApplicationConfig'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
// The host writes state to each connection, so publish by emitting to every connection's client.
|
|
14
|
+
const emitConfigs = (value: ApplicationConfig[] | null) =>
|
|
15
|
+
host.connections.subscribe((client) => client.emit('applications.config', value))
|
|
16
|
+
|
|
17
|
+
const typeConfig: ApplicationConfig = {
|
|
18
|
+
appType: 'media-library',
|
|
19
|
+
entry: 'https://media-library-config.sanity.run',
|
|
20
|
+
moduleId: 'configs/installation_config',
|
|
21
|
+
version: '1',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const appConfig: ApplicationConfig = {
|
|
25
|
+
appId: 'application-1',
|
|
26
|
+
appType: 'media-library',
|
|
27
|
+
entry: 'https://application-config.sanity.run',
|
|
28
|
+
moduleId: 'configs/installation_config',
|
|
29
|
+
version: '2',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('useApplicationConfig', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
35
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetMessageBus()
|
|
40
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
41
|
+
vi.unstubAllGlobals()
|
|
42
|
+
vi.restoreAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('selects a config by application type or application id', () => {
|
|
46
|
+
emitConfigs([typeConfig, appConfig])
|
|
47
|
+
|
|
48
|
+
const {result, rerender} = renderHook<
|
|
49
|
+
{selector: ApplicationConfigSelector},
|
|
50
|
+
ApplicationConfig | null
|
|
51
|
+
>(({selector}) => useApplicationConfig(selector), {
|
|
52
|
+
initialProps: {selector: {appType: 'media-library'}},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expectTypeOf(result.current).toEqualTypeOf<ApplicationConfig | null>()
|
|
56
|
+
expect(result.current).toBe(typeConfig)
|
|
57
|
+
|
|
58
|
+
rerender({selector: {appId: 'application-1'}})
|
|
59
|
+
expect(result.current).toBe(appConfig)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('selects the type-level config for an appType query regardless of emit order', () => {
|
|
63
|
+
// App-scoped config first: a naive find() would return it for a type query.
|
|
64
|
+
emitConfigs([appConfig, typeConfig])
|
|
65
|
+
|
|
66
|
+
const {result} = renderHook(() => useApplicationConfig({appType: 'media-library'}))
|
|
67
|
+
|
|
68
|
+
expect(result.current).toBe(typeConfig)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns null when no config matches the selector', () => {
|
|
72
|
+
emitConfigs([typeConfig, appConfig])
|
|
73
|
+
|
|
74
|
+
const {result} = renderHook(() => useApplicationConfig({appId: 'missing'}))
|
|
75
|
+
|
|
76
|
+
expect(result.current).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('suspends until the dashboard publishes its configs', async () => {
|
|
80
|
+
function MediaLibrary() {
|
|
81
|
+
const config = useApplicationConfig({appType: 'media-library'})
|
|
82
|
+
return <span>{config?.moduleId ?? 'none'}</span>
|
|
83
|
+
}
|
|
84
|
+
render(
|
|
85
|
+
<Suspense fallback="Loading">
|
|
86
|
+
<MediaLibrary />
|
|
87
|
+
</Suspense>,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
emitConfigs([typeConfig])
|
|
94
|
+
})
|
|
95
|
+
expect(await screen.findByText('configs/installation_config')).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('follows topic updates', () => {
|
|
99
|
+
emitConfigs([typeConfig])
|
|
100
|
+
const {result} = renderHook(() => useApplicationConfig({appId: 'application-1'}))
|
|
101
|
+
expect(result.current).toBeNull()
|
|
102
|
+
|
|
103
|
+
act(() => emitConfigs([typeConfig, appConfig]))
|
|
104
|
+
expect(result.current).toBe(appConfig)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {type Application} from '@sanity/sdk'
|
|
2
|
+
import {type ApplicationConfig, type ApplicationConfigAppType} from '@sanity/sdk/dashboard'
|
|
3
|
+
|
|
4
|
+
import {useApplicationConfigs} from './useApplicationConfigs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Selects an application configuration by application id or application type.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export type ApplicationConfigSelector =
|
|
11
|
+
| {appId: Application['id']; appType?: never}
|
|
12
|
+
| {appId?: never; appType: ApplicationConfigAppType}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns an application configuration by application id or application type, or `null` when
|
|
16
|
+
* none matches.
|
|
17
|
+
*
|
|
18
|
+
* An `appType` query matches the type-level config only (the one without an `appId`), so it is
|
|
19
|
+
* independent of the order the dashboard published configs in. An `appId` query matches that
|
|
20
|
+
* application's config.
|
|
21
|
+
*
|
|
22
|
+
* Suspends until the dashboard publishes its application configs.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* function MediaLibraryConfig() {
|
|
27
|
+
* const config = useApplicationConfig({appType: 'media-library'})
|
|
28
|
+
* return config ? <span>{config.moduleId}</span> : <p>Not installed</p>
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
export function useApplicationConfig(
|
|
35
|
+
selector: ApplicationConfigSelector,
|
|
36
|
+
): ApplicationConfig | null {
|
|
37
|
+
const configs = useApplicationConfigs()
|
|
38
|
+
return (
|
|
39
|
+
configs.find((config) =>
|
|
40
|
+
selector.appId === undefined
|
|
41
|
+
? config.appType === selector.appType && config.appId === undefined
|
|
42
|
+
: config.appId === selector.appId,
|
|
43
|
+
) ?? null
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type ApplicationConfig, 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, renderHook, screen} from '../../../test/test-utils'
|
|
7
|
+
import {useApplicationConfigs} from './useApplicationConfigs'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
// The host writes state to each connection, so publish by emitting to every connection's client.
|
|
14
|
+
const emitConfigs = (value: ApplicationConfig[] | null) =>
|
|
15
|
+
host.connections.subscribe((client) => client.emit('applications.config', value))
|
|
16
|
+
|
|
17
|
+
const configs: ApplicationConfig[] = [
|
|
18
|
+
{
|
|
19
|
+
appType: 'media-library',
|
|
20
|
+
entry: 'https://media-library-config.sanity.run',
|
|
21
|
+
moduleId: 'configs/installation_config',
|
|
22
|
+
version: '1',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
appId: 'application-1',
|
|
26
|
+
appType: 'media-library',
|
|
27
|
+
entry: 'https://application-config.sanity.run',
|
|
28
|
+
moduleId: 'configs/installation_config',
|
|
29
|
+
version: '2',
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe('useApplicationConfigs', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
36
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
resetMessageBus()
|
|
41
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
42
|
+
vi.unstubAllGlobals()
|
|
43
|
+
vi.restoreAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns every published application config', () => {
|
|
47
|
+
emitConfigs(configs)
|
|
48
|
+
|
|
49
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
50
|
+
|
|
51
|
+
expectTypeOf(result.current).toEqualTypeOf<readonly ApplicationConfig[]>()
|
|
52
|
+
expect(result.current).toEqual(configs)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('suspends until the dashboard publishes its configs', async () => {
|
|
56
|
+
function Configs() {
|
|
57
|
+
return <span>{useApplicationConfigs().length} configs</span>
|
|
58
|
+
}
|
|
59
|
+
render(
|
|
60
|
+
<Suspense fallback="Loading">
|
|
61
|
+
<Configs />
|
|
62
|
+
</Suspense>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
66
|
+
|
|
67
|
+
await act(async () => {
|
|
68
|
+
emitConfigs(configs)
|
|
69
|
+
})
|
|
70
|
+
expect(await screen.findByText('2 configs')).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns an empty list when the dashboard clears its configs', () => {
|
|
74
|
+
emitConfigs(null)
|
|
75
|
+
|
|
76
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
77
|
+
|
|
78
|
+
expect(result.current).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('follows topic updates', () => {
|
|
82
|
+
emitConfigs(null)
|
|
83
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
84
|
+
expect(result.current).toEqual([])
|
|
85
|
+
|
|
86
|
+
act(() => emitConfigs(configs))
|
|
87
|
+
expect(result.current).toEqual(configs)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {type ApplicationConfig} from '@sanity/sdk/dashboard'
|
|
2
|
+
|
|
3
|
+
import {useTopic} from './useTopic'
|
|
4
|
+
|
|
5
|
+
// Stable, frozen reference for the cleared/absent case so the return value only changes with the
|
|
6
|
+
// topic and callers cannot mutate the shared empty list.
|
|
7
|
+
const NO_CONFIGS: readonly ApplicationConfig[] = Object.freeze([])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the application configuration modules available in the dashboard.
|
|
11
|
+
*
|
|
12
|
+
* Suspends until the dashboard publishes its application configs; a cleared list is empty.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* function Applications() {
|
|
17
|
+
* const configs = useApplicationConfigs()
|
|
18
|
+
* return configs.map((config) => <div key={config.moduleId}>{config.appType}</div>)
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export function useApplicationConfigs(): readonly ApplicationConfig[] {
|
|
25
|
+
return useTopic('applications.config') ?? NO_CONFIGS
|
|
26
|
+
}
|