Skip to content

Commit 112df24

Browse files
authored
chore(core, structure): validation on references (#7431)
1 parent 3a18971 commit 112df24

File tree

5 files changed

+185
-147
lines changed

5 files changed

+185
-147
lines changed

packages/sanity/src/core/hooks/useValidationStatus.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {useMemo} from 'react'
22
import {useObservable} from 'react-rx'
33

4-
import {useDocumentStore, type ValidationStatus} from '../store'
4+
import {useDocumentStore} from '../store'
5+
import {type ValidationStatus} from '../validation'
56

67
const INITIAL: ValidationStatus = {validation: [], isValidating: false}
78

packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts

+6-145
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,22 @@
11
import {type SanityClient} from '@sanity/client'
2-
import {isReference, type Schema, type ValidationMarker} from '@sanity/types'
3-
import {reduce as reduceJSON} from 'json-reduce'
2+
import {type Schema} from '@sanity/types'
43
import {omit} from 'lodash'
5-
import {
6-
asyncScheduler,
7-
combineLatest,
8-
concat,
9-
defer,
10-
from,
11-
lastValueFrom,
12-
type Observable,
13-
of,
14-
timer,
15-
} from 'rxjs'
16-
import {
17-
distinct,
18-
distinctUntilChanged,
19-
first,
20-
groupBy,
21-
map,
22-
mergeMap,
23-
scan,
24-
shareReplay,
25-
skip,
26-
throttleTime,
27-
} from 'rxjs/operators'
28-
import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing'
4+
import {asyncScheduler, type Observable} from 'rxjs'
5+
import {distinctUntilChanged, map, shareReplay, throttleTime} from 'rxjs/operators'
296
import shallowEquals from 'shallow-equals'
307

318
import {type SourceClientOptions} from '../../../../config'
329
import {type LocaleSource} from '../../../../i18n'
3310
import {type DraftsModelDocumentAvailability} from '../../../../preview'
34-
import {validateDocumentObservable, type ValidationContext} from '../../../../validation'
11+
import {validateDocumentWithReferences, type ValidationStatus} from '../../../../validation'
3512
import {type IdPair} from '../types'
3613
import {memoize} from '../utils/createMemoizer'
3714
import {editState} from './editState'
3815
import {memoizeKeyGen} from './memoizeKeyGen'
3916

40-
/**
41-
* @hidden
42-
* @beta */
43-
export interface ValidationStatus {
44-
isValidating: boolean
45-
validation: ValidationMarker[]
46-
revision?: string
47-
}
48-
49-
const INITIAL_VALIDATION_STATUS: ValidationStatus = {
50-
isValidating: true,
51-
validation: [],
52-
}
53-
54-
function findReferenceIds(obj: any): Set<string> {
55-
return reduceJSON(
56-
obj,
57-
(acc, node) => {
58-
if (isReference(node)) {
59-
acc.add(node._ref)
60-
}
61-
return acc
62-
},
63-
new Set<string>(),
64-
)
65-
}
66-
67-
const EMPTY_VALIDATION: ValidationMarker[] = []
68-
69-
type GetDocumentExists = NonNullable<ValidationContext['getDocumentExists']>
70-
71-
type ObserveDocumentPairAvailability = (id: string) => Observable<DraftsModelDocumentAvailability>
72-
73-
const listenDocumentExists = (
74-
observeDocumentAvailability: ObserveDocumentPairAvailability,
75-
id: string,
76-
): Observable<boolean> =>
77-
observeDocumentAvailability(id).pipe(map(({published}) => published.available))
78-
7917
// throttle delay for document updates (i.e. time between responding to changes in the current document)
8018
const DOC_UPDATE_DELAY = 200
8119

82-
// throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents)
83-
const REF_UPDATE_DELAY = 1000
84-
8520
function shareLatestWithRefCount<T>() {
8621
return shareReplay<T>({bufferSize: 1, refCount: true})
8722
}
@@ -92,7 +27,7 @@ export const validation = memoize(
9227
ctx: {
9328
client: SanityClient
9429
getClient: (options: SourceClientOptions) => SanityClient
95-
observeDocumentPairAvailability: ObserveDocumentPairAvailability
30+
observeDocumentPairAvailability: (id: string) => Observable<DraftsModelDocumentAvailability>
9631
schema: Schema
9732
i18n: LocaleSource
9833
serverActionsEnabled: Observable<boolean>
@@ -114,81 +49,7 @@ export const validation = memoize(
11449
shareLatestWithRefCount(),
11550
)
11651

117-
const referenceIds$ = document$.pipe(
118-
map((document) => findReferenceIds(document)),
119-
mergeMap((ids) => from(ids)),
120-
)
121-
122-
// Note: we only use this to trigger a re-run of validation when a referenced document is published/unpublished
123-
const referenceExistence$ = referenceIds$.pipe(
124-
groupBy((id) => id, {duration: () => timer(1000 * 60 * 30)}),
125-
mergeMap((id$) =>
126-
id$.pipe(
127-
distinct(),
128-
mergeMap((id) =>
129-
listenDocumentExists(ctx.observeDocumentPairAvailability, id).pipe(
130-
map(
131-
// eslint-disable-next-line max-nested-callbacks
132-
(result) => [id, result] as const,
133-
),
134-
),
135-
),
136-
),
137-
),
138-
scan((acc: Record<string, boolean>, [id, result]): Record<string, boolean> => {
139-
if (acc[id] === result) {
140-
return acc
141-
}
142-
return {...acc, [id]: result}
143-
}, {}),
144-
distinctUntilChanged(shallowEquals),
145-
shareLatestWithRefCount(),
146-
)
147-
148-
// Provided to individual validation functions to support using existence of a weakly referenced document
149-
// as part of the validation rule (used by references in place)
150-
const getDocumentExists: GetDocumentExists = ({id}) =>
151-
lastValueFrom(
152-
referenceExistence$.pipe(
153-
// If the id is not present as key in the `referenceExistence` map it means it's existence status
154-
// isn't yet loaded, so we want to wait until it is
155-
first((referenceExistence) => id in referenceExistence),
156-
map((referenceExistence) => referenceExistence[id]),
157-
),
158-
)
159-
160-
const referenceDocumentUpdates$ = referenceExistence$.pipe(
161-
// we'll skip the first emission since the document already gets an initial validation pass
162-
// we're only interested in updates in referenced documents after that
163-
skip(1),
164-
throttleTime(REF_UPDATE_DELAY, asyncScheduler, {leading: true, trailing: true}),
165-
)
166-
167-
return combineLatest([document$, concat(of(null), referenceDocumentUpdates$)]).pipe(
168-
map(([document]) => document),
169-
exhaustMapWithTrailing((document) => {
170-
return defer(() => {
171-
if (!document?._type) {
172-
return of({validation: EMPTY_VALIDATION, isValidating: false})
173-
}
174-
return concat(
175-
of({isValidating: true, revision: document._rev}),
176-
validateDocumentObservable({
177-
document,
178-
getClient: ctx.getClient,
179-
getDocumentExists,
180-
i18n: ctx.i18n,
181-
schema: ctx.schema,
182-
environment: 'studio',
183-
}).pipe(
184-
map((validationMarkers) => ({validation: validationMarkers, isValidating: false})),
185-
),
186-
)
187-
})
188-
}),
189-
scan((acc, next) => ({...acc, ...next}), INITIAL_VALIDATION_STATUS),
190-
shareLatestWithRefCount(),
191-
)
52+
return validateDocumentWithReferences(ctx, document$)
19253
},
19354
(ctx, idPair, typeName) => {
19455
return memoizeKeyGen(ctx.client, idPair, typeName)

packages/sanity/src/core/store/_legacy/document/document-store.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {type DocumentPreviewStore} from '../../../preview'
99
import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient'
1010
import {type Template} from '../../../templates'
1111
import {getDraftId, isDraftId} from '../../../util'
12+
import {type ValidationStatus} from '../../../validation'
1213
import {type HistoryStore} from '../history'
1314
import {checkoutPair, type DocumentVersionEvent, type Pair} from './document-pair/checkoutPair'
1415
import {consistencyStatus} from './document-pair/consistencyStatus'
@@ -21,7 +22,7 @@ import {
2122
type OperationSuccess,
2223
} from './document-pair/operationEvents'
2324
import {type OperationsAPI} from './document-pair/operations'
24-
import {validation, type ValidationStatus} from './document-pair/validation'
25+
import {validation} from './document-pair/validation'
2526
import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue'
2627
import {listenQuery, type ListenQueryOptions} from './listenQuery'
2728
import {resolveTypeForDocument} from './resolveTypeForDocument'

packages/sanity/src/core/validation/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ export {Rule} from './Rule'
44
export type {ValidationContext} from './types'
55
export {validateDocument, type ValidateDocumentOptions} from './validateDocument'
66
export {validateDocumentObservable} from './validateDocument'
7+
export {
8+
validateDocumentWithReferences,
9+
type ValidationStatus,
10+
} from './validateDocumentWithReferences'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import {type SanityClient} from '@sanity/client'
2+
import {
3+
isReference,
4+
type SanityDocument,
5+
type Schema,
6+
type ValidationContext,
7+
type ValidationMarker,
8+
} from '@sanity/types'
9+
import {reduce as reduceJSON} from 'json-reduce'
10+
import {
11+
asyncScheduler,
12+
combineLatest,
13+
concat,
14+
defer,
15+
from,
16+
lastValueFrom,
17+
type Observable,
18+
of,
19+
timer,
20+
} from 'rxjs'
21+
import {
22+
distinct,
23+
distinctUntilChanged,
24+
first,
25+
groupBy,
26+
map,
27+
mergeMap,
28+
scan,
29+
shareReplay,
30+
skip,
31+
throttleTime,
32+
} from 'rxjs/operators'
33+
import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing'
34+
import shallowEquals from 'shallow-equals'
35+
36+
import {type DocumentPreviewStore, type LocaleSource, type SourceClientOptions} from '..'
37+
import {validateDocumentObservable} from './validateDocument'
38+
39+
/**
40+
* @hidden
41+
* @beta */
42+
export interface ValidationStatus {
43+
isValidating: boolean
44+
validation: ValidationMarker[]
45+
revision?: string
46+
}
47+
48+
const INITIAL_VALIDATION_STATUS: ValidationStatus = {
49+
isValidating: true,
50+
validation: [],
51+
}
52+
53+
function findReferenceIds(obj: any): Set<string> {
54+
return reduceJSON(
55+
obj,
56+
(acc, node) => {
57+
if (isReference(node)) {
58+
acc.add(node._ref)
59+
}
60+
return acc
61+
},
62+
new Set<string>(),
63+
)
64+
}
65+
66+
const EMPTY_VALIDATION: ValidationMarker[] = []
67+
68+
type GetDocumentExists = NonNullable<ValidationContext['getDocumentExists']>
69+
70+
const listenDocumentExists = (
71+
observeDocumentAvailability: DocumentPreviewStore['unstable_observeDocumentPairAvailability'],
72+
id: string,
73+
): Observable<boolean> =>
74+
observeDocumentAvailability(id).pipe(map(({published}) => published.available))
75+
76+
// throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents)
77+
const REF_UPDATE_DELAY = 1000
78+
79+
function shareLatestWithRefCount<T>() {
80+
return shareReplay<T>({bufferSize: 1, refCount: true})
81+
}
82+
83+
/**
84+
* @internal
85+
* Takes an observable of a document and validates it, including any references in the document.
86+
* */
87+
export function validateDocumentWithReferences(
88+
ctx: {
89+
getClient: (options: SourceClientOptions) => SanityClient
90+
observeDocumentPairAvailability: DocumentPreviewStore['unstable_observeDocumentPairAvailability']
91+
schema: Schema
92+
i18n: LocaleSource
93+
},
94+
document$: Observable<SanityDocument | null | undefined>,
95+
): Observable<ValidationStatus> {
96+
const referenceIds$ = document$.pipe(
97+
map((document) => findReferenceIds(document)),
98+
mergeMap((ids) => from(ids)),
99+
)
100+
101+
// Note: we only use this to trigger a re-run of validation when a referenced document is published/unpublished
102+
const referenceExistence$ = referenceIds$.pipe(
103+
groupBy((id) => id, {duration: () => timer(1000 * 60 * 30)}),
104+
mergeMap((id$) =>
105+
id$.pipe(
106+
distinct(),
107+
mergeMap((id) =>
108+
listenDocumentExists(ctx.observeDocumentPairAvailability, id).pipe(
109+
map(
110+
// eslint-disable-next-line max-nested-callbacks
111+
(result) => [id, result] as const,
112+
),
113+
),
114+
),
115+
),
116+
),
117+
scan((acc: Record<string, boolean>, [id, result]): Record<string, boolean> => {
118+
if (acc[id] === result) {
119+
return acc
120+
}
121+
return {...acc, [id]: result}
122+
}, {}),
123+
distinctUntilChanged(shallowEquals),
124+
shareLatestWithRefCount(),
125+
)
126+
127+
// Provided to individual validation functions to support using existence of a weakly referenced document
128+
// as part of the validation rule (used by references in place)
129+
const getDocumentExists: GetDocumentExists = ({id}) =>
130+
lastValueFrom(
131+
referenceExistence$.pipe(
132+
// If the id is not present as key in the `referenceExistence` map it means it's existence status
133+
// isn't yet loaded, so we want to wait until it is
134+
first((referenceExistence) => id in referenceExistence),
135+
map((referenceExistence) => referenceExistence[id]),
136+
),
137+
)
138+
139+
const referenceDocumentUpdates$ = referenceExistence$.pipe(
140+
// we'll skip the first emission since the document already gets an initial validation pass
141+
// we're only interested in updates in referenced documents after that
142+
skip(1),
143+
throttleTime(REF_UPDATE_DELAY, asyncScheduler, {leading: true, trailing: true}),
144+
)
145+
146+
return combineLatest([document$, concat(of(null), referenceDocumentUpdates$)]).pipe(
147+
map(([document]) => document),
148+
exhaustMapWithTrailing((document) => {
149+
return defer(() => {
150+
if (!document?._type) {
151+
return of({validation: EMPTY_VALIDATION, isValidating: false})
152+
}
153+
return concat(
154+
of({isValidating: true, revision: document._rev}),
155+
validateDocumentObservable({
156+
document,
157+
getClient: ctx.getClient,
158+
getDocumentExists,
159+
i18n: ctx.i18n,
160+
schema: ctx.schema,
161+
environment: 'studio',
162+
}).pipe(
163+
map((validationMarkers) => ({validation: validationMarkers, isValidating: false})),
164+
),
165+
)
166+
})
167+
}),
168+
scan((acc, next) => ({...acc, ...next}), INITIAL_VALIDATION_STATUS),
169+
shareLatestWithRefCount(),
170+
)
171+
}

0 commit comments

Comments
 (0)