Skip to content

Commit

Permalink
chore(core, structure): validation on references (#7431)
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanl17 authored Sep 9, 2024
1 parent 3a18971 commit 112df24
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 147 deletions.
3 changes: 2 additions & 1 deletion packages/sanity/src/core/hooks/useValidationStatus.ts
Original file line number Diff line number Diff line change
@@ -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}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<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']>

type ObserveDocumentPairAvailability = (id: string) => Observable<DraftsModelDocumentAvailability>

const listenDocumentExists = (
observeDocumentAvailability: ObserveDocumentPairAvailability,
id: string,
): Observable<boolean> =>
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<T>() {
return shareReplay<T>({bufferSize: 1, refCount: true})
}
Expand All @@ -92,7 +27,7 @@ export const validation = memoize(
ctx: {
client: SanityClient
getClient: (options: SourceClientOptions) => SanityClient
observeDocumentPairAvailability: ObserveDocumentPairAvailability
observeDocumentPairAvailability: (id: string) => Observable<DraftsModelDocumentAvailability>
schema: Schema
i18n: LocaleSource
serverActionsEnabled: Observable<boolean>
Expand All @@ -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<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(),
)
return validateDocumentWithReferences(ctx, document$)
},
(ctx, idPair, typeName) => {
return memoizeKeyGen(ctx.client, idPair, typeName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions packages/sanity/src/core/validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
171 changes: 171 additions & 0 deletions packages/sanity/src/core/validation/validateDocumentWithReferences.ts
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(),
)
}

0 comments on commit 112df24

Please sign in to comment.