-
Notifications
You must be signed in to change notification settings - Fork 435
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(core, structure): validation on references (#7431)
- Loading branch information
Showing
5 changed files
with
185 additions
and
147 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
packages/sanity/src/core/validation/validateDocumentWithReferences.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
import {type SanityClient} from '@sanity/client' | ||
import { | ||
isReference, | ||
type SanityDocument, | ||
type Schema, | ||
type ValidationContext, | ||
type ValidationMarker, | ||
} from '@sanity/types' | ||
import {reduce as reduceJSON} from 'json-reduce' | ||
import { | ||
asyncScheduler, | ||
combineLatest, | ||
concat, | ||
defer, | ||
from, | ||
lastValueFrom, | ||
type Observable, | ||
of, | ||
timer, | ||
} from 'rxjs' | ||
import { | ||
distinct, | ||
distinctUntilChanged, | ||
first, | ||
groupBy, | ||
map, | ||
mergeMap, | ||
scan, | ||
shareReplay, | ||
skip, | ||
throttleTime, | ||
} from 'rxjs/operators' | ||
import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing' | ||
import shallowEquals from 'shallow-equals' | ||
|
||
import {type DocumentPreviewStore, type LocaleSource, type SourceClientOptions} from '..' | ||
import {validateDocumentObservable} from './validateDocument' | ||
|
||
/** | ||
* @hidden | ||
* @beta */ | ||
export interface ValidationStatus { | ||
isValidating: boolean | ||
validation: ValidationMarker[] | ||
revision?: string | ||
} | ||
|
||
const INITIAL_VALIDATION_STATUS: ValidationStatus = { | ||
isValidating: true, | ||
validation: [], | ||
} | ||
|
||
function findReferenceIds(obj: any): Set<string> { | ||
return reduceJSON( | ||
obj, | ||
(acc, node) => { | ||
if (isReference(node)) { | ||
acc.add(node._ref) | ||
} | ||
return acc | ||
}, | ||
new Set<string>(), | ||
) | ||
} | ||
|
||
const EMPTY_VALIDATION: ValidationMarker[] = [] | ||
|
||
type GetDocumentExists = NonNullable<ValidationContext['getDocumentExists']> | ||
|
||
const listenDocumentExists = ( | ||
observeDocumentAvailability: DocumentPreviewStore['unstable_observeDocumentPairAvailability'], | ||
id: string, | ||
): Observable<boolean> => | ||
observeDocumentAvailability(id).pipe(map(({published}) => published.available)) | ||
|
||
// throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents) | ||
const REF_UPDATE_DELAY = 1000 | ||
|
||
function shareLatestWithRefCount<T>() { | ||
return shareReplay<T>({bufferSize: 1, refCount: true}) | ||
} | ||
|
||
/** | ||
* @internal | ||
* Takes an observable of a document and validates it, including any references in the document. | ||
* */ | ||
export function validateDocumentWithReferences( | ||
ctx: { | ||
getClient: (options: SourceClientOptions) => SanityClient | ||
observeDocumentPairAvailability: DocumentPreviewStore['unstable_observeDocumentPairAvailability'] | ||
schema: Schema | ||
i18n: LocaleSource | ||
}, | ||
document$: Observable<SanityDocument | null | undefined>, | ||
): Observable<ValidationStatus> { | ||
const referenceIds$ = document$.pipe( | ||
map((document) => findReferenceIds(document)), | ||
mergeMap((ids) => from(ids)), | ||
) | ||
|
||
// Note: we only use this to trigger a re-run of validation when a referenced document is published/unpublished | ||
const referenceExistence$ = referenceIds$.pipe( | ||
groupBy((id) => id, {duration: () => timer(1000 * 60 * 30)}), | ||
mergeMap((id$) => | ||
id$.pipe( | ||
distinct(), | ||
mergeMap((id) => | ||
listenDocumentExists(ctx.observeDocumentPairAvailability, id).pipe( | ||
map( | ||
// eslint-disable-next-line max-nested-callbacks | ||
(result) => [id, result] as const, | ||
), | ||
), | ||
), | ||
), | ||
), | ||
scan((acc: Record<string, boolean>, [id, result]): Record<string, boolean> => { | ||
if (acc[id] === result) { | ||
return acc | ||
} | ||
return {...acc, [id]: result} | ||
}, {}), | ||
distinctUntilChanged(shallowEquals), | ||
shareLatestWithRefCount(), | ||
) | ||
|
||
// Provided to individual validation functions to support using existence of a weakly referenced document | ||
// as part of the validation rule (used by references in place) | ||
const getDocumentExists: GetDocumentExists = ({id}) => | ||
lastValueFrom( | ||
referenceExistence$.pipe( | ||
// If the id is not present as key in the `referenceExistence` map it means it's existence status | ||
// isn't yet loaded, so we want to wait until it is | ||
first((referenceExistence) => id in referenceExistence), | ||
map((referenceExistence) => referenceExistence[id]), | ||
), | ||
) | ||
|
||
const referenceDocumentUpdates$ = referenceExistence$.pipe( | ||
// we'll skip the first emission since the document already gets an initial validation pass | ||
// we're only interested in updates in referenced documents after that | ||
skip(1), | ||
throttleTime(REF_UPDATE_DELAY, asyncScheduler, {leading: true, trailing: true}), | ||
) | ||
|
||
return combineLatest([document$, concat(of(null), referenceDocumentUpdates$)]).pipe( | ||
map(([document]) => document), | ||
exhaustMapWithTrailing((document) => { | ||
return defer(() => { | ||
if (!document?._type) { | ||
return of({validation: EMPTY_VALIDATION, isValidating: false}) | ||
} | ||
return concat( | ||
of({isValidating: true, revision: document._rev}), | ||
validateDocumentObservable({ | ||
document, | ||
getClient: ctx.getClient, | ||
getDocumentExists, | ||
i18n: ctx.i18n, | ||
schema: ctx.schema, | ||
environment: 'studio', | ||
}).pipe( | ||
map((validationMarkers) => ({validation: validationMarkers, isValidating: false})), | ||
), | ||
) | ||
}) | ||
}), | ||
scan((acc, next) => ({...acc, ...next}), INITIAL_VALIDATION_STATUS), | ||
shareLatestWithRefCount(), | ||
) | ||
} |