From 112df249d80839bfcce4185033227b7a25ac7e39 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Mon, 9 Sep 2024 12:12:01 +0100 Subject: [PATCH] chore(core, structure): validation on references (#7431) --- .../src/core/hooks/useValidationStatus.ts | 3 +- .../document/document-pair/validation.ts | 151 +--------------- .../store/_legacy/document/document-store.ts | 3 +- packages/sanity/src/core/validation/index.ts | 4 + .../validateDocumentWithReferences.ts | 171 ++++++++++++++++++ 5 files changed, 185 insertions(+), 147 deletions(-) create mode 100644 packages/sanity/src/core/validation/validateDocumentWithReferences.ts diff --git a/packages/sanity/src/core/hooks/useValidationStatus.ts b/packages/sanity/src/core/hooks/useValidationStatus.ts index e1ad1e92d74..165e059d932 100644 --- a/packages/sanity/src/core/hooks/useValidationStatus.ts +++ b/packages/sanity/src/core/hooks/useValidationStatus.ts @@ -1,7 +1,8 @@ import {useMemo} from 'react' import {useObservable} from 'react-rx' -import {useDocumentStore, type ValidationStatus} from '../store' +import {useDocumentStore} from '../store' +import {type ValidationStatus} from '../validation' const INITIAL: ValidationStatus = {validation: [], isValidating: false} diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts index 4ad8d322267..8cf895fe80a 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts @@ -1,87 +1,22 @@ import {type SanityClient} from '@sanity/client' -import {isReference, type Schema, type ValidationMarker} from '@sanity/types' -import {reduce as reduceJSON} from 'json-reduce' +import {type Schema} from '@sanity/types' import {omit} from 'lodash' -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 {asyncScheduler, type Observable} from 'rxjs' +import {distinctUntilChanged, map, shareReplay, throttleTime} from 'rxjs/operators' import shallowEquals from 'shallow-equals' import {type SourceClientOptions} from '../../../../config' import {type LocaleSource} from '../../../../i18n' import {type DraftsModelDocumentAvailability} from '../../../../preview' -import {validateDocumentObservable, type ValidationContext} from '../../../../validation' +import {validateDocumentWithReferences, type ValidationStatus} from '../../../../validation' import {type IdPair} from '../types' import {memoize} from '../utils/createMemoizer' import {editState} from './editState' import {memoizeKeyGen} from './memoizeKeyGen' -/** - * @hidden - * @beta */ -export interface ValidationStatus { - isValidating: boolean - validation: ValidationMarker[] - revision?: string -} - -const INITIAL_VALIDATION_STATUS: ValidationStatus = { - isValidating: true, - validation: [], -} - -function findReferenceIds(obj: any): Set { - return reduceJSON( - obj, - (acc, node) => { - if (isReference(node)) { - acc.add(node._ref) - } - return acc - }, - new Set(), - ) -} - -const EMPTY_VALIDATION: ValidationMarker[] = [] - -type GetDocumentExists = NonNullable - -type ObserveDocumentPairAvailability = (id: string) => Observable - -const listenDocumentExists = ( - observeDocumentAvailability: ObserveDocumentPairAvailability, - id: string, -): Observable => - observeDocumentAvailability(id).pipe(map(({published}) => published.available)) - // throttle delay for document updates (i.e. time between responding to changes in the current document) const DOC_UPDATE_DELAY = 200 -// throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents) -const REF_UPDATE_DELAY = 1000 - function shareLatestWithRefCount() { return shareReplay({bufferSize: 1, refCount: true}) } @@ -92,7 +27,7 @@ export const validation = memoize( ctx: { client: SanityClient getClient: (options: SourceClientOptions) => SanityClient - observeDocumentPairAvailability: ObserveDocumentPairAvailability + observeDocumentPairAvailability: (id: string) => Observable schema: Schema i18n: LocaleSource serverActionsEnabled: Observable @@ -114,81 +49,7 @@ export const validation = memoize( shareLatestWithRefCount(), ) - 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, [id, result]): Record => { - 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(), - ) + return validateDocumentWithReferences(ctx, document$) }, (ctx, idPair, typeName) => { return memoizeKeyGen(ctx.client, idPair, typeName) diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 64ed02d48cc..d40fba36ce4 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -9,6 +9,7 @@ import {type DocumentPreviewStore} from '../../../preview' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' import {type Template} from '../../../templates' import {getDraftId, isDraftId} from '../../../util' +import {type ValidationStatus} from '../../../validation' import {type HistoryStore} from '../history' import {checkoutPair, type DocumentVersionEvent, type Pair} from './document-pair/checkoutPair' import {consistencyStatus} from './document-pair/consistencyStatus' @@ -21,7 +22,7 @@ import { type OperationSuccess, } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' -import {validation, type ValidationStatus} from './document-pair/validation' +import {validation} from './document-pair/validation' import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue' import {listenQuery, type ListenQueryOptions} from './listenQuery' import {resolveTypeForDocument} from './resolveTypeForDocument' diff --git a/packages/sanity/src/core/validation/index.ts b/packages/sanity/src/core/validation/index.ts index c9fef95af7e..4125f1f6bcc 100644 --- a/packages/sanity/src/core/validation/index.ts +++ b/packages/sanity/src/core/validation/index.ts @@ -4,3 +4,7 @@ export {Rule} from './Rule' export type {ValidationContext} from './types' export {validateDocument, type ValidateDocumentOptions} from './validateDocument' export {validateDocumentObservable} from './validateDocument' +export { + validateDocumentWithReferences, + type ValidationStatus, +} from './validateDocumentWithReferences' diff --git a/packages/sanity/src/core/validation/validateDocumentWithReferences.ts b/packages/sanity/src/core/validation/validateDocumentWithReferences.ts new file mode 100644 index 00000000000..2b52c2ab5e8 --- /dev/null +++ b/packages/sanity/src/core/validation/validateDocumentWithReferences.ts @@ -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 { + return reduceJSON( + obj, + (acc, node) => { + if (isReference(node)) { + acc.add(node._ref) + } + return acc + }, + new Set(), + ) +} + +const EMPTY_VALIDATION: ValidationMarker[] = [] + +type GetDocumentExists = NonNullable + +const listenDocumentExists = ( + observeDocumentAvailability: DocumentPreviewStore['unstable_observeDocumentPairAvailability'], + id: string, +): Observable => + 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() { + return shareReplay({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, +): Observable { + 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, [id, result]): Record => { + 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(), + ) +}