@sanity/sdk 0.0.0-rc.6 → 0.0.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/README.md +7 -15
- package/dist/index.d.ts +562 -234
- package/dist/index.js +515 -256
- package/dist/index.js.map +1 -1
- package/package.json +12 -10
- package/src/_exports/index.ts +17 -2
- package/src/auth/dashboardUtils.test.ts +41 -0
- package/src/auth/dashboardUtils.ts +12 -0
- package/src/auth/getOrganizationVerificationState.test.ts +197 -0
- package/src/auth/getOrganizationVerificationState.ts +73 -0
- package/src/auth/handleAuthCallback.test.ts +2 -0
- package/src/auth/handleAuthCallback.ts +1 -0
- package/src/auth/logout.test.ts +1 -0
- package/src/auth/logout.ts +1 -0
- package/src/auth/refreshStampedToken.ts +1 -0
- package/src/auth/studioModeAuth.test.ts +1 -1
- package/src/auth/studioModeAuth.ts +1 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -0
- package/src/client/clientStore.ts +22 -18
- package/src/comlink/node/actions/releaseNode.ts +16 -14
- package/src/config/__tests__/handles.test.ts +30 -0
- package/src/config/handles.ts +67 -0
- package/src/config/sanityConfig.ts +44 -16
- package/src/document/actions.ts +188 -60
- package/src/document/applyDocumentActions.ts +12 -5
- package/src/document/documentStore.test.ts +70 -121
- package/src/document/documentStore.ts +57 -27
- package/src/document/patchOperations.test.ts +1 -1
- package/src/document/patchOperations.ts +39 -39
- package/src/document/sharedListener.ts +3 -1
- package/src/favorites/favorites.test.ts +237 -0
- package/src/favorites/favorites.ts +122 -0
- package/src/preview/resolvePreview.test.ts +3 -4
- package/src/preview/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/preview/subscribeToStateAndFetchBatches.ts +4 -2
- package/src/project/organizationVerification.test.ts +35 -0
- package/src/project/organizationVerification.ts +26 -0
- package/src/projection/getProjectionState.ts +36 -11
- package/src/projection/resolveProjection.test.ts +3 -4
- package/src/projection/resolveProjection.ts +35 -9
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +4 -2
- package/src/query/queryStore.test.ts +12 -12
- package/src/query/queryStore.ts +71 -42
- package/src/releases/getPerspectiveState.test.ts +192 -0
- package/src/releases/getPerspectiveState.ts +93 -0
- package/src/releases/releasesStore.test.ts +170 -0
- package/src/releases/releasesStore.ts +89 -0
- package/src/releases/utils/sortReleases.test.ts +336 -0
- package/src/releases/utils/sortReleases.ts +48 -0
- package/src/utils/listenQuery.test.ts +302 -0
- package/src/utils/listenQuery.ts +128 -0
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
type KeyedSegment,
|
|
8
8
|
type Path,
|
|
9
9
|
type PathSegment,
|
|
10
|
-
type SanityDocumentLike,
|
|
11
10
|
} from '@sanity/types'
|
|
12
11
|
|
|
13
12
|
type SingleValuePath = Exclude<PathSegment, IndexTuple>[]
|
|
@@ -17,7 +16,7 @@ type ToNumber<TInput extends string> = TInput extends `${infer TNumber extends n
|
|
|
17
16
|
: TInput
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
|
-
* Parse a single
|
|
19
|
+
* Parse a single "segment" that may include bracket parts.
|
|
21
20
|
*
|
|
22
21
|
* For example, the literal
|
|
23
22
|
*
|
|
@@ -42,7 +41,7 @@ type ParseSegment<TInput extends string> = TInput extends `${infer TProp}[${infe
|
|
|
42
41
|
/**
|
|
43
42
|
* Parse one or more bracketed parts from a segment.
|
|
44
43
|
*
|
|
45
|
-
* It recursively
|
|
44
|
+
* It recursively "peels off" a bracketed part and then continues.
|
|
46
45
|
*
|
|
47
46
|
* For example, given the string:
|
|
48
47
|
*
|
|
@@ -61,7 +60,7 @@ type ParseBracket<TInput extends string> = TInput extends `[${infer TPart}]${inf
|
|
|
61
60
|
: [] // no leading bracket → end of this segment
|
|
62
61
|
|
|
63
62
|
/**
|
|
64
|
-
* Split the entire path string on dots
|
|
63
|
+
* Split the entire path string on dots "outside" of any brackets.
|
|
65
64
|
*
|
|
66
65
|
* For example:
|
|
67
66
|
* ```
|
|
@@ -76,42 +75,43 @@ type ParseBracket<TInput extends string> = TInput extends `[${infer TPart}]${inf
|
|
|
76
75
|
*
|
|
77
76
|
* (We use a simple recursion that splits on the first dot.)
|
|
78
77
|
*/
|
|
79
|
-
type PathParts<TPath extends string> = TPath extends `${infer
|
|
80
|
-
? [
|
|
81
|
-
:
|
|
78
|
+
type PathParts<TPath extends string> = TPath extends `${infer Head}.${infer Tail}`
|
|
79
|
+
? [Head, ...PathParts<Tail>]
|
|
80
|
+
: TPath extends ''
|
|
81
|
+
? []
|
|
82
|
+
: [TPath]
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
|
-
* Given a type T and an array of
|
|
85
|
+
* Given a type T and an array of "access keys" Parts, recursively index into T.
|
|
85
86
|
*
|
|
86
87
|
* If a part is a key, it looks up that property.
|
|
87
|
-
* If T is an array and the part is a number, it
|
|
88
|
+
* If T is an array and the part is a number, it "indexes" into the element type.
|
|
88
89
|
*/
|
|
89
|
-
type DeepGet<
|
|
90
|
-
?
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
type DeepGet<TValue, TPath extends readonly (string | number)[]> = TPath extends []
|
|
91
|
+
? TValue
|
|
92
|
+
: TPath extends readonly [infer THead, ...infer TTail]
|
|
93
|
+
? // Handle traversing into optional properties
|
|
94
|
+
DeepGet<
|
|
95
|
+
TValue extends undefined | null
|
|
96
|
+
? undefined // Stop traversal if current value is null/undefined
|
|
97
|
+
: THead extends keyof TValue // Access property if key exists
|
|
98
|
+
? TValue[THead]
|
|
99
|
+
: // Handle array indexing
|
|
100
|
+
THead extends number
|
|
101
|
+
? TValue extends readonly (infer TElement)[]
|
|
102
|
+
? TElement | undefined // Array element or undefined if out of bounds
|
|
103
|
+
: undefined // Cannot index non-array with number
|
|
104
|
+
: undefined, // Key/index doesn't exist
|
|
105
|
+
TTail extends readonly (string | number)[] ? TTail : [] // Continue with the rest of the path
|
|
106
|
+
>
|
|
107
|
+
: never // Should be unreachable
|
|
98
108
|
|
|
99
109
|
/**
|
|
100
110
|
* Given a document type TDocument and a JSON Match path string TPath,
|
|
101
111
|
* compute the type found at that path.
|
|
102
112
|
* @beta
|
|
103
113
|
*/
|
|
104
|
-
export type JsonMatch<TDocument
|
|
105
|
-
TDocument,
|
|
106
|
-
PathParts<TPath>
|
|
107
|
-
>
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Computing the full possible paths may be possible but is hard to compute
|
|
111
|
-
* within the type system for complex document types so we use string.
|
|
112
|
-
* @beta
|
|
113
|
-
*/
|
|
114
|
-
export type JsonMatchPath<_TDocument extends SanityDocumentLike> = string
|
|
114
|
+
export type JsonMatch<TDocument, TPath extends string> = DeepGet<TDocument, PathParts<TPath>>
|
|
115
115
|
|
|
116
116
|
function parseBracketContent(content: string): PathSegment {
|
|
117
117
|
// 1) Range match: ^(\d*):(\d*)$
|
|
@@ -138,7 +138,7 @@ function parseBracketContent(content: string): PathSegment {
|
|
|
138
138
|
return index
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
throw new Error(`Invalid bracket content:
|
|
141
|
+
throw new Error(`Invalid bracket content: "[${content}]"`)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function parseSegment(segment: string): PathSegment[] {
|
|
@@ -271,10 +271,10 @@ type MatchEntry<T = unknown> = {
|
|
|
271
271
|
*
|
|
272
272
|
* @beta
|
|
273
273
|
*/
|
|
274
|
-
export function jsonMatch<
|
|
275
|
-
TDocument
|
|
276
|
-
TPath
|
|
277
|
-
|
|
274
|
+
export function jsonMatch<TDocument, TPath extends string>(
|
|
275
|
+
input: TDocument,
|
|
276
|
+
path: TPath,
|
|
277
|
+
): MatchEntry<JsonMatch<TDocument, TPath>>[]
|
|
278
278
|
/** @beta */
|
|
279
279
|
export function jsonMatch<TValue>(input: unknown, path: string): MatchEntry<TValue>[]
|
|
280
280
|
/** @beta */
|
|
@@ -324,7 +324,7 @@ function matchRecursive(value: unknown, path: Path, currentPath: SingleValuePath
|
|
|
324
324
|
const startIndex = start === '' ? 0 : start
|
|
325
325
|
const endIndex = end === '' ? value.length : end
|
|
326
326
|
|
|
327
|
-
// We
|
|
327
|
+
// We'll accumulate all matches from each index in the range
|
|
328
328
|
let results: MatchEntry[] = []
|
|
329
329
|
|
|
330
330
|
// Decide whether the range is exclusive or inclusive. The example in
|
|
@@ -587,15 +587,15 @@ export function insert(input: unknown, insertPatch: InsertPatch): unknown {
|
|
|
587
587
|
const pathExpression = (insertPatch as {[K in Operation]?: string} & {items: unknown})[operation]
|
|
588
588
|
if (typeof pathExpression !== 'string') return input
|
|
589
589
|
|
|
590
|
-
// Helper to normalize a matched index given the parent array
|
|
590
|
+
// Helper to normalize a matched index given the parent array's length.
|
|
591
591
|
function normalizeIndex(index: number, parentLength: number): number {
|
|
592
592
|
switch (operation) {
|
|
593
593
|
case 'before':
|
|
594
|
-
// A negative index means
|
|
594
|
+
// A negative index means "append" (i.e. insert before a hypothetical element
|
|
595
595
|
// beyond the end of the array).
|
|
596
596
|
return index < 0 ? parentLength : index
|
|
597
597
|
case 'after':
|
|
598
|
-
// For "after", if the matched index is negative, we treat it as
|
|
598
|
+
// For "after", if the matched index is negative, we treat it as "prepend":
|
|
599
599
|
// by convention, we convert it to -1 so that later adding 1 produces 0.
|
|
600
600
|
return index < 0 ? -1 : index
|
|
601
601
|
default: // default to 'replace'
|
|
@@ -916,7 +916,7 @@ export function setDeep(input: unknown, path: SingleValuePath, value: unknown):
|
|
|
916
916
|
]
|
|
917
917
|
}
|
|
918
918
|
|
|
919
|
-
// For keyed segments that aren
|
|
919
|
+
// For keyed segments that aren't arrays, do nothing.
|
|
920
920
|
if (typeof currentSegment === 'object') return input
|
|
921
921
|
|
|
922
922
|
// For plain objects, update an existing property if it exists…
|
|
@@ -66,7 +66,9 @@ export function createFetchDocument(instance: SanityInstance) {
|
|
|
66
66
|
return function (documentId: string): Observable<SanityDocument | null> {
|
|
67
67
|
return getClientState(instance, {apiVersion: API_VERSION}).observable.pipe(
|
|
68
68
|
switchMap((client) => {
|
|
69
|
-
|
|
69
|
+
// TODO: remove this once the client is updated to v7 the new type is available in @sanity/mutate/_unstable_store
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
const loadDocument = createDocumentLoaderFromClient(client as any)
|
|
70
72
|
return loadDocument(documentId)
|
|
71
73
|
}),
|
|
72
74
|
map((result) => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {type Node} from '@sanity/comlink'
|
|
2
|
+
import {firstValueFrom} from 'rxjs'
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
|
|
6
|
+
import {type FrameMessage, type WindowMessage} from '../comlink/types'
|
|
7
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
8
|
+
import {getFavoritesState, resolveFavoritesState} from './favorites'
|
|
9
|
+
|
|
10
|
+
vi.mock('../comlink/node/comlinkNodeStore')
|
|
11
|
+
|
|
12
|
+
let instance: SanityInstance | undefined
|
|
13
|
+
|
|
14
|
+
describe('favoritesStore', () => {
|
|
15
|
+
const mockContext = {
|
|
16
|
+
documentId: 'doc123',
|
|
17
|
+
documentType: 'movie',
|
|
18
|
+
resourceId: 'res456',
|
|
19
|
+
resourceType: 'studio' as const,
|
|
20
|
+
schemaName: 'movieSchema',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockContextNoSchema = {
|
|
24
|
+
documentId: 'doc123',
|
|
25
|
+
documentType: 'movie',
|
|
26
|
+
resourceId: 'res456',
|
|
27
|
+
resourceType: 'studio' as const,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createFavoriteKey', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.resetAllMocks()
|
|
33
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
instance?.dispose()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('creates different keys for different contexts with schema name', async () => {
|
|
41
|
+
const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
|
|
42
|
+
const mockNode = {fetch: mockFetch}
|
|
43
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
44
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Make two fetches with different document IDs
|
|
48
|
+
await resolveFavoritesState(instance!, mockContext)
|
|
49
|
+
await resolveFavoritesState(instance!, {
|
|
50
|
+
...mockContext,
|
|
51
|
+
documentId: 'different',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Verify that the fetch was called with different payloads
|
|
55
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
56
|
+
const call1 = mockFetch.mock.calls[0][1]
|
|
57
|
+
const call2 = mockFetch.mock.calls[1][1]
|
|
58
|
+
expect(call1.document.id).toBe('doc123')
|
|
59
|
+
expect(call2.document.id).toBe('different')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('creates different keys for contexts without schema name', async () => {
|
|
63
|
+
const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
|
|
64
|
+
const mockNode = {fetch: mockFetch}
|
|
65
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
66
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Make two fetches with different document IDs
|
|
70
|
+
await resolveFavoritesState(instance!, mockContextNoSchema)
|
|
71
|
+
await resolveFavoritesState(instance!, {
|
|
72
|
+
...mockContextNoSchema,
|
|
73
|
+
documentId: 'different',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Verify that the fetch was called with different payloads
|
|
77
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
78
|
+
const call1 = mockFetch.mock.calls[0][1]
|
|
79
|
+
const call2 = mockFetch.mock.calls[1][1]
|
|
80
|
+
expect(call1.document.id).toBe('doc123')
|
|
81
|
+
expect(call2.document.id).toBe('different')
|
|
82
|
+
expect(call1.document.resource.schemaName).toBeUndefined()
|
|
83
|
+
expect(call2.document.resource.schemaName).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('fetcher', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.resetAllMocks()
|
|
90
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
instance?.dispose()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('fetches favorite status and handles success', async () => {
|
|
98
|
+
const mockResponse = {isFavorited: true}
|
|
99
|
+
const mockFetch = vi.fn().mockResolvedValue(mockResponse)
|
|
100
|
+
const mockNode = {fetch: mockFetch}
|
|
101
|
+
|
|
102
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
103
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const result = await resolveFavoritesState(instance!, mockContext)
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual(mockResponse)
|
|
109
|
+
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
|
|
110
|
+
document: {
|
|
111
|
+
id: mockContext.documentId,
|
|
112
|
+
type: mockContext.documentType,
|
|
113
|
+
resource: {
|
|
114
|
+
id: mockContext.resourceId,
|
|
115
|
+
type: mockContext.resourceType,
|
|
116
|
+
schemaName: mockContext.schemaName,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('handles error and returns default response', async () => {
|
|
123
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
|
|
124
|
+
const mockNode = {fetch: mockFetch}
|
|
125
|
+
|
|
126
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
127
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const result = await resolveFavoritesState(instance!, mockContext)
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({isFavorited: false})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('shares observable between multiple subscribers and cleans up', async () => {
|
|
136
|
+
const mockResponse = {isFavorited: true}
|
|
137
|
+
const mockFetch = vi.fn().mockResolvedValue(mockResponse)
|
|
138
|
+
const mockNode = {fetch: mockFetch}
|
|
139
|
+
|
|
140
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
141
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const state = getFavoritesState(instance!, mockContext)
|
|
145
|
+
|
|
146
|
+
// First subscriber
|
|
147
|
+
const sub1 = state.subscribe()
|
|
148
|
+
await firstValueFrom(state.observable)
|
|
149
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
150
|
+
|
|
151
|
+
// Second subscriber should use cached response
|
|
152
|
+
const sub2 = state.subscribe()
|
|
153
|
+
await firstValueFrom(state.observable)
|
|
154
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
155
|
+
|
|
156
|
+
// Cleanup
|
|
157
|
+
sub1()
|
|
158
|
+
sub2()
|
|
159
|
+
|
|
160
|
+
// Wait for cleanup
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
162
|
+
|
|
163
|
+
expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('reuses active fetch via createFetcherStore/shareReplay when called again while pending', async () => {
|
|
167
|
+
vi.useFakeTimers()
|
|
168
|
+
|
|
169
|
+
let resolveFetch: (value: {isFavorited: boolean}) => void
|
|
170
|
+
const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
|
|
171
|
+
resolveFetch = resolve
|
|
172
|
+
})
|
|
173
|
+
const mockFetch = vi.fn().mockReturnValue(fetchPromise) // Mocks node.fetch
|
|
174
|
+
const mockNode = {fetch: mockFetch}
|
|
175
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
176
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Call 1: Triggers the actual fetch
|
|
180
|
+
const promise1 = resolveFavoritesState(instance!, mockContext)
|
|
181
|
+
// Allow fetcher to run and call node.fetch
|
|
182
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
183
|
+
expect(mockFetch).toHaveBeenCalledTimes(1) // node.fetch called once
|
|
184
|
+
|
|
185
|
+
// Call 2: Should reuse the pending fetch via createFetcherStore/shareReplay
|
|
186
|
+
const promise2 = resolveFavoritesState(instance!, mockContext)
|
|
187
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
188
|
+
|
|
189
|
+
// Verify node.fetch was NOT called again
|
|
190
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
191
|
+
|
|
192
|
+
// Resolve the underlying fetch
|
|
193
|
+
resolveFetch!({isFavorited: true})
|
|
194
|
+
await vi.advanceTimersByTimeAsync(1) // Allow promises to resolve
|
|
195
|
+
|
|
196
|
+
// Check results
|
|
197
|
+
const result1 = await promise1
|
|
198
|
+
const result2 = await promise2
|
|
199
|
+
expect(result1).toEqual({isFavorited: true})
|
|
200
|
+
expect(result2).toEqual({isFavorited: true})
|
|
201
|
+
|
|
202
|
+
// Allow cleanup timers
|
|
203
|
+
await vi.advanceTimersByTimeAsync(5001) // stateExpirationDelay
|
|
204
|
+
expect(vi.mocked(releaseNode)).toHaveBeenCalled()
|
|
205
|
+
|
|
206
|
+
vi.useRealTimers()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('handles timeout and returns default response', async () => {
|
|
210
|
+
vi.useFakeTimers()
|
|
211
|
+
|
|
212
|
+
const mockFetch = vi.fn().mockReturnValue(new Promise(() => {})) // Promise that never resolves
|
|
213
|
+
const mockNode = {fetch: mockFetch}
|
|
214
|
+
|
|
215
|
+
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
216
|
+
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const resultPromise = resolveFavoritesState(instance!, mockContext)
|
|
220
|
+
|
|
221
|
+
// Advance time past the timeout threshold (3000ms)
|
|
222
|
+
await vi.advanceTimersByTimeAsync(3001)
|
|
223
|
+
|
|
224
|
+
const result = await resultPromise
|
|
225
|
+
|
|
226
|
+
expect(result).toEqual({isFavorited: false})
|
|
227
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
228
|
+
|
|
229
|
+
// Ensure releaseNode is still called even on timeout/error path
|
|
230
|
+
// Need to wait for the catchError and cleanup logic
|
|
231
|
+
await vi.advanceTimersByTimeAsync(1) // Allow microtasks to run
|
|
232
|
+
expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
|
|
233
|
+
|
|
234
|
+
vi.useRealTimers()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanvasResource,
|
|
3
|
+
type MediaResource,
|
|
4
|
+
SDK_CHANNEL_NAME,
|
|
5
|
+
SDK_NODE_NAME,
|
|
6
|
+
type StudioResource,
|
|
7
|
+
} from '@sanity/message-protocol'
|
|
8
|
+
import {catchError, from, map, Observable, of, shareReplay, throwError, timeout} from 'rxjs'
|
|
9
|
+
|
|
10
|
+
import {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
|
|
11
|
+
import {type DocumentHandle} from '../config/sanityConfig'
|
|
12
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
13
|
+
import {createFetcherStore} from '../utils/createFetcherStore'
|
|
14
|
+
|
|
15
|
+
// Users may, in many situations, be developing
|
|
16
|
+
// without a connection to the Dashboard UI.
|
|
17
|
+
// This timeout allows us to return a fallback state
|
|
18
|
+
// instead of suspending.
|
|
19
|
+
const FAVORITES_FETCH_TIMEOUT = 3000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export interface FavoriteStatusResponse {
|
|
25
|
+
isFavorited: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
interface FavoriteDocumentContext extends DocumentHandle {
|
|
32
|
+
resourceId: string
|
|
33
|
+
resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
|
|
34
|
+
schemaName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper to create a stable key for the store
|
|
38
|
+
function createFavoriteKey(context: FavoriteDocumentContext): string {
|
|
39
|
+
return `${context.documentId}:${context.documentType}:${context.resourceId}:${context.resourceType}${
|
|
40
|
+
context.schemaName ? `:${context.schemaName}` : ''
|
|
41
|
+
}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusResponse>({
|
|
45
|
+
name: 'Favorites',
|
|
46
|
+
getKey: (_instance: SanityInstance, context: FavoriteDocumentContext) => {
|
|
47
|
+
return createFavoriteKey(context)
|
|
48
|
+
},
|
|
49
|
+
fetcher: (instance: SanityInstance) => {
|
|
50
|
+
return (context: FavoriteDocumentContext): Observable<FavoriteStatusResponse> => {
|
|
51
|
+
const node = getOrCreateNode(instance, {
|
|
52
|
+
name: SDK_NODE_NAME,
|
|
53
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const payload = {
|
|
57
|
+
document: {
|
|
58
|
+
id: context.documentId,
|
|
59
|
+
type: context.documentType,
|
|
60
|
+
resource: {
|
|
61
|
+
id: context.resourceId,
|
|
62
|
+
type: context.resourceType,
|
|
63
|
+
schemaName: context.schemaName,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dashboardFetch = from(
|
|
69
|
+
node.fetch(
|
|
70
|
+
// @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
|
|
71
|
+
'dashboard/v1/events/favorite/query',
|
|
72
|
+
payload,
|
|
73
|
+
) as Promise<FavoriteStatusResponse>,
|
|
74
|
+
).pipe(
|
|
75
|
+
timeout({
|
|
76
|
+
first: FAVORITES_FETCH_TIMEOUT,
|
|
77
|
+
with: () => throwError(() => new Error('Favorites service connection timeout')),
|
|
78
|
+
}),
|
|
79
|
+
map((response) => {
|
|
80
|
+
return {isFavorited: response.isFavorited}
|
|
81
|
+
}),
|
|
82
|
+
catchError((err) => {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.error('Favorites service connection error', err)
|
|
85
|
+
return of({isFavorited: false})
|
|
86
|
+
}),
|
|
87
|
+
// Share the same subscription between multiple subscribers
|
|
88
|
+
shareReplay(1),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Clean up when all subscribers are gone
|
|
92
|
+
return new Observable<FavoriteStatusResponse>((subscriber) => {
|
|
93
|
+
const subscription = dashboardFetch.subscribe(subscriber)
|
|
94
|
+
return () => {
|
|
95
|
+
subscription.unsubscribe()
|
|
96
|
+
// If this was the last subscriber, clean up
|
|
97
|
+
if (subscription.closed) {
|
|
98
|
+
releaseNode(instance, SDK_NODE_NAME)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Gets a StateSource for the favorite status of a document.
|
|
108
|
+
* @param instance - The Sanity instance.
|
|
109
|
+
* @param context - The document context including ID, type, and resource information.
|
|
110
|
+
* @returns A StateSource emitting `{ isFavorited: boolean }`.
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
export const getFavoritesState = favorites.getState
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves the favorite status for a document.
|
|
117
|
+
* @param instance - The Sanity instance.
|
|
118
|
+
* @param context - The document context including ID, type, and resource information.
|
|
119
|
+
* @returns A Promise resolving to `{ isFavorited: boolean }`.
|
|
120
|
+
* @public
|
|
121
|
+
*/
|
|
122
|
+
export const resolveFavoritesState = favorites.resolveState
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {type SanityDocumentLike} from '@sanity/types'
|
|
2
1
|
import {of} from 'rxjs'
|
|
3
2
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import {createDocumentHandle} from '../config/handles'
|
|
6
5
|
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
7
6
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
7
|
import {getPreviewState} from './getPreviewState'
|
|
@@ -32,10 +31,10 @@ describe('resolvePreview', () => {
|
|
|
32
31
|
})
|
|
33
32
|
|
|
34
33
|
it('resolves a preview and returns the first emitted value with results', async () => {
|
|
35
|
-
const docHandle
|
|
34
|
+
const docHandle = createDocumentHandle({
|
|
36
35
|
documentId: 'doc123',
|
|
37
36
|
documentType: 'movie',
|
|
38
|
-
}
|
|
37
|
+
})
|
|
39
38
|
|
|
40
39
|
const result = await resolvePreview(instance, docHandle)
|
|
41
40
|
|
|
@@ -53,13 +53,13 @@ describe('subscribeToStateAndFetchBatches', () => {
|
|
|
53
53
|
expect(getQueryState).toHaveBeenCalledTimes(1)
|
|
54
54
|
expect(getQueryState).toHaveBeenCalledWith(
|
|
55
55
|
instance,
|
|
56
|
-
expect.any(String),
|
|
57
56
|
expect.objectContaining({
|
|
58
57
|
params: expect.objectContaining({
|
|
59
58
|
__ids_71322c7a: ['doc1', 'drafts.doc1', 'doc2', 'drafts.doc2'],
|
|
60
59
|
}),
|
|
61
60
|
perspective: PREVIEW_PERSPECTIVE,
|
|
62
61
|
tag: PREVIEW_TAG,
|
|
62
|
+
query: expect.any(String),
|
|
63
63
|
}),
|
|
64
64
|
)
|
|
65
65
|
|
|
@@ -59,7 +59,8 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
59
59
|
const {query, params} = createPreviewQuery(ids)
|
|
60
60
|
const controller = new AbortController()
|
|
61
61
|
return new Observable<PreviewQueryResult[]>((observer) => {
|
|
62
|
-
const {getCurrent, observable} = getQueryState<PreviewQueryResult[]>(instance,
|
|
62
|
+
const {getCurrent, observable} = getQueryState<PreviewQueryResult[]>(instance, {
|
|
63
|
+
query,
|
|
63
64
|
params,
|
|
64
65
|
tag: PREVIEW_TAG,
|
|
65
66
|
perspective: PREVIEW_PERSPECTIVE,
|
|
@@ -67,7 +68,8 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
67
68
|
const source$ = defer(() => {
|
|
68
69
|
if (getCurrent() === undefined) {
|
|
69
70
|
return from(
|
|
70
|
-
resolveQuery<PreviewQueryResult[]>(instance,
|
|
71
|
+
resolveQuery<PreviewQueryResult[]>(instance, {
|
|
72
|
+
query,
|
|
71
73
|
params,
|
|
72
74
|
tag: PREVIEW_TAG,
|
|
73
75
|
perspective: PREVIEW_PERSPECTIVE,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {compareProjectOrganization, type OrgVerificationResult} from './organizationVerification'
|
|
4
|
+
|
|
5
|
+
describe('compareProjectOrganization', () => {
|
|
6
|
+
const projectId = 'proj-1'
|
|
7
|
+
const expectedOrgId = 'org-abc'
|
|
8
|
+
|
|
9
|
+
it('should return error null when project orgId matches expected orgId', () => {
|
|
10
|
+
const projectOrgId = 'org-abc'
|
|
11
|
+
const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
|
|
12
|
+
expect(result).toEqual<OrgVerificationResult>({error: null})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should return an error message when project orgId does not match expected orgId', () => {
|
|
16
|
+
const projectOrgId = 'org-xyz' // Mismatch
|
|
17
|
+
const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
|
|
18
|
+
expect(result.error).toContain('belongs to Organization org-xyz')
|
|
19
|
+
expect(result.error).toContain('Dashboard has Organization org-abc selected')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should return an error message when project orgId is null', () => {
|
|
23
|
+
const projectOrgId = null
|
|
24
|
+
const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
|
|
25
|
+
expect(result.error).toContain('belongs to Organization unknown')
|
|
26
|
+
expect(result.error).toContain('Dashboard has Organization org-abc selected')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should return an error message when project orgId is undefined', () => {
|
|
30
|
+
const projectOrgId = undefined
|
|
31
|
+
const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
|
|
32
|
+
expect(result.error).toContain('belongs to Organization unknown')
|
|
33
|
+
expect(result.error).toContain('Dashboard has Organization org-abc selected')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error message returned by the organization verification
|
|
3
|
+
* @public
|
|
4
|
+
*/
|
|
5
|
+
export interface OrgVerificationResult {
|
|
6
|
+
error: string | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compares a project's actual organization ID with the expected organization ID.
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export function compareProjectOrganization(
|
|
14
|
+
projectId: string,
|
|
15
|
+
projectOrganizationId: string | null | undefined,
|
|
16
|
+
currentDashboardOrgId: string,
|
|
17
|
+
): OrgVerificationResult {
|
|
18
|
+
if (projectOrganizationId !== currentDashboardOrgId) {
|
|
19
|
+
return {
|
|
20
|
+
error:
|
|
21
|
+
`Project ${projectId} belongs to Organization ${projectOrganizationId ?? 'unknown'}, ` +
|
|
22
|
+
`but the Dashboard has Organization ${currentDashboardOrgId} selected`,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {error: null}
|
|
26
|
+
}
|