sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
567 lines (510 loc) • 19.3 kB
text/typescript
/* eslint-disable max-statements */
import {type SanityDocument} from '@sanity/client'
import {isActionEnabled} from '@sanity/schema/_internal'
import {useTelemetry} from '@sanity/telemetry/react'
import {
type ObjectSchemaType,
type Path,
type SanityDocumentLike,
type ValidationMarker,
} from '@sanity/types'
import {useToast} from '@sanity/ui'
import {pathFor} from '@sanity/util/paths'
import {throttle} from 'lodash'
import {
type RefObject,
useCallback,
useEffect,
useInsertionEffect,
useMemo,
useRef,
useState,
} from 'react'
import deepEquals from 'react-fast-compare'
import {isSanityCreateLinkedDocument} from '../create/createUtils'
import {type ConnectionState, useConnectionState} from '../hooks/useConnectionState'
import {useDocumentOperation} from '../hooks/useDocumentOperation'
import {useEditState} from '../hooks/useEditState'
import {useSchema} from '../hooks/useSchema'
import {useValidationStatus} from '../hooks/useValidationStatus'
import {useTranslation} from '../i18n/hooks/useTranslation'
import {getSelectedPerspective} from '../perspective/getSelectedPerspective'
import {type ReleaseId} from '../perspective/types'
import {usePerspective} from '../perspective/usePerspective'
import {useDocumentVersions} from '../releases/hooks/useDocumentVersions'
import {useDocumentVersionTypeSortedList} from '../releases/hooks/useDocumentVersionTypeSortedList'
import {useOnlyHasVersions} from '../releases/hooks/useOnlyHasVersions'
import {isReleaseDocument} from '../releases/store/types'
import {useActiveReleases} from '../releases/store/useActiveReleases'
import {getReleaseIdFromReleaseDocumentId} from '../releases/util/getReleaseIdFromReleaseDocumentId'
import {isGoingToUnpublish} from '../releases/util/isGoingToUnpublish'
import {isPublishedPerspective, isReleaseScheduledOrScheduling} from '../releases/util/util'
import {
type DocumentPresence,
type EditStateFor,
type InitialValueState,
type PermissionCheckResult,
useDocumentValuePermissions,
usePresenceStore,
} from '../store'
import {EMPTY_ARRAY, getDraftId, getPublishedId, getVersionFromId, useUnique} from '../util'
import {
type FormState,
getExpandOperations,
type OnPathFocusPayload,
type PatchEvent,
setAtPath,
type StateTree,
toMutationPatches,
useFormState,
} from '.'
import {CreatedDraft} from './__telemetry__/form.telemetry'
interface DocumentFormOptions {
documentType: string
documentId: string
releaseId?: ReleaseId
initialValue?: InitialValueState
initialFocusPath?: Path
selectedPerspectiveName?: ReleaseId | 'published'
readOnly?: boolean | ((editState: EditStateFor) => boolean)
/**
* Usually the historical _rev value selected, if not defined, it will use the current document value
* so no comparison will be done.
*/
comparisonValue?:
| Partial<SanityDocument>
| ((editState: EditStateFor) => Partial<SanityDocument>)
| null
onFocusPath?: (path: Path) => void
changesOpen?: boolean
/**
* Callback that allows to transform the value before it's passed to the form
* used by the <DocumentPaneProvider > to display the history values.
*/
getFormDocumentValue?: (value: SanityDocumentLike) => SanityDocumentLike
}
interface DocumentFormValue {
editState: EditStateFor
connectionState: ConnectionState
collapsedFieldSets: StateTree<boolean> | undefined
collapsedPaths: StateTree<boolean> | undefined
openPath: Path
ready: boolean
value: SanityDocumentLike
formState: FormState
focusPath: Path
validation: ValidationMarker[]
permissions: PermissionCheckResult | undefined
isPermissionsLoading: boolean
onBlur: (blurredPath: Path) => void
onFocus: (_nextFocusPath: Path, payload?: OnPathFocusPayload) => void
onSetCollapsedPath: (path: Path, collapsed: boolean) => void
onSetActiveFieldGroup: (path: Path, groupName: string) => void
onSetCollapsedFieldSet: (path: Path, collapsed: boolean) => void
onChange: (event: PatchEvent) => void
onPathOpen: (path: Path) => void
onProgrammaticFocus: (nextPath: Path) => void
formStateRef: RefObject<FormState>
schemaType: ObjectSchemaType
}
/**
* @internal
* Hook for creating a form state and combine it with the <FormBuilder>.
* It will handle the connection state, edit state, validation, and presence.
*
* Use this as a base point to create your own form.
*/
// eslint-disable-next-line max-statements
export function useDocumentForm(options: DocumentFormOptions): DocumentFormValue {
const {
documentType,
getFormDocumentValue,
documentId,
initialValue,
changesOpen = false,
comparisonValue: comparisonValueRaw,
releaseId,
initialFocusPath,
selectedPerspectiveName,
readOnly: readOnlyProp,
onFocusPath,
} = options
const schema = useSchema()
const presenceStore = usePresenceStore()
const {data: releases} = useActiveReleases()
const {data: documentVersions} = useDocumentVersions({documentId})
const {selectedReleaseId} = usePerspective()
const schemaType = schema.get(documentType) as ObjectSchemaType | undefined
if (!schemaType) {
throw new Error(`Schema type for '${documentType}' not found`)
}
const liveEdit = Boolean(schemaType.liveEdit)
const telemetry = useTelemetry()
const {validation: validationRaw} = useValidationStatus(documentId, documentType, releaseId)
const validation = useUnique(validationRaw)
// if it only has versions then we need to make sure that whatever the first document that is allowed
// is a version document, but also that it has the right order
// this will make sure that then the right document appears and so does the right chip within the document header
const {sortedDocumentList} = useDocumentVersionTypeSortedList({documentId})
const onlyHasVersions = useOnlyHasVersions({documentId})
const firstVersion =
sortedDocumentList.length > 0
? documentVersions.find(
(id) =>
getVersionFromId(id) === getReleaseIdFromReleaseDocumentId(sortedDocumentList[0]._id),
)
: undefined
const activeDocumentReleaseId = useMemo(() => {
// if a document version exists with the selected release id, then it should use that
if (documentVersions.some((id) => getVersionFromId(id) === selectedReleaseId)) {
return selectedReleaseId
}
// check if the selected version is the only version, if it isn't and it doesn't exist in the release
// then it needs to use the documentVersions
if (selectedReleaseId && (!documentVersions || !onlyHasVersions)) {
return selectedReleaseId
}
return getVersionFromId(firstVersion ?? '')
}, [documentVersions, onlyHasVersions, selectedReleaseId, firstVersion])
const editState = useEditState(documentId, documentType, 'default', activeDocumentReleaseId)
const connectionState = useConnectionState(documentId, documentType, releaseId)
useConnectionToast(connectionState)
const [focusPath, setFocusPath] = useState<Path>(initialFocusPath || EMPTY_ARRAY)
const comparisonValue = useMemo(() => {
if (typeof comparisonValueRaw === 'function') {
return comparisonValueRaw(editState)
}
return comparisonValueRaw
}, [comparisonValueRaw, editState])
const value: SanityDocumentLike = useMemo(() => {
const baseValue = initialValue?.value || {_id: documentId, _type: documentType}
if (releaseId) {
return editState.version || editState.draft || editState.published || baseValue
}
if (selectedPerspectiveName && isPublishedPerspective(selectedPerspectiveName)) {
return (
editState.published ||
(liveEdit
? // If it's live edit and published perspective, add the initialValue
baseValue
: // If it's not live edit, the form needs to be empty in the draft state, don't show the initialValue
{_id: documentId, _type: documentType})
)
}
// if no version is selected, but there is only version, it should default to the version it finds
if (!selectedPerspectiveName && onlyHasVersions) {
return editState.version || editState.draft || editState.published || baseValue
}
return editState?.draft || editState?.published || baseValue
}, [
documentId,
documentType,
editState.draft,
editState.published,
editState.version,
initialValue,
liveEdit,
releaseId,
selectedPerspectiveName,
onlyHasVersions,
])
const [presence, setPresence] = useState<DocumentPresence[]>([])
useEffect(() => {
const subscription = presenceStore.documentPresence(value._id).subscribe((nextPresence) => {
setPresence(nextPresence)
})
return () => {
subscription.unsubscribe()
}
}, [presenceStore, value._id])
const [openPath, onSetOpenPath] = useState<Path>(EMPTY_ARRAY)
const [fieldGroupState, onSetFieldGroupState] = useState<StateTree<string>>()
const [collapsedPaths, onSetCollapsedPath] = useState<StateTree<boolean>>()
const [collapsedFieldSets, onSetCollapsedFieldSets] = useState<StateTree<boolean>>()
const handleOnSetCollapsedPath = useCallback((path: Path, collapsed: boolean) => {
onSetCollapsedPath((prevState) => setAtPath(prevState, path, collapsed))
}, [])
const handleOnSetCollapsedFieldSet = useCallback((path: Path, collapsed: boolean) => {
onSetCollapsedFieldSets((prevState) => setAtPath(prevState, path, collapsed))
}, [])
const handleSetActiveFieldGroup = useCallback(
(path: Path, groupName: string) =>
onSetFieldGroupState((prevState) => setAtPath(prevState, path, groupName)),
[],
)
const requiredPermission = value._createdAt ? 'update' : 'create'
const docPermissionsInput = useMemo(() => {
return {
...value,
_id: liveEdit ? getPublishedId(documentId) : getDraftId(documentId),
}
}, [liveEdit, value, documentId])
const [permissions, isPermissionsLoading] = useDocumentValuePermissions({
document: docPermissionsInput,
permission: requiredPermission,
})
const isNonExistent = !value?._id
const isCreateLinked = isSanityCreateLinkedDocument(value)
const ready = connectionState === 'connected' && editState.ready && !initialValue?.loading
const selectedPerspective = useMemo(() => {
return getSelectedPerspective(selectedPerspectiveName, releases)
}, [selectedPerspectiveName, releases])
const isReleaseLocked = useMemo(
() =>
isReleaseDocument(selectedPerspective)
? isReleaseScheduledOrScheduling(selectedPerspective)
: false,
[selectedPerspective],
)
const readOnly = useMemo(() => {
const hasNoPermission = !isPermissionsLoading && !permissions?.granted
const updateActionDisabled = !isActionEnabled(schemaType!, 'update')
const createActionDisabled = isNonExistent && !isActionEnabled(schemaType!, 'create')
const reconnecting = connectionState === 'reconnecting'
const isLocked = editState.transactionSyncLock?.enabled
const willBeUnpublished = value ? isGoingToUnpublish(value) : false
// in cases where the document has no draft or published, but has a version,
// and that version doesn't match current pinned version
// we disable editing
if (
editState.version &&
!editState.draft &&
!editState.published &&
onlyHasVersions &&
selectedPerspectiveName !== getVersionFromId(editState.version._id)
) {
return true
}
// in cases where the document has drafts but the schema is live edit, there is a risk of data loss, so we disable editing in this case
if (liveEdit && editState.draft?._id) {
return true
}
if (!liveEdit && selectedPerspectiveName === 'published') {
return true
}
// If a release is selected, validate that the document id matches the selected release id
if (releaseId && getVersionFromId(value._id) !== releaseId) {
return true
}
const isReadOnly =
!ready ||
hasNoPermission ||
updateActionDisabled ||
createActionDisabled ||
reconnecting ||
isLocked ||
isCreateLinked ||
willBeUnpublished ||
isReleaseLocked
if (isReadOnly) return true
if (typeof readOnlyProp === 'function') return readOnlyProp(editState)
return Boolean(readOnlyProp)
}, [
isPermissionsLoading,
permissions?.granted,
schemaType,
isNonExistent,
connectionState,
editState,
value,
onlyHasVersions,
selectedPerspectiveName,
liveEdit,
releaseId,
ready,
isCreateLinked,
isReleaseLocked,
readOnlyProp,
])
const {patch} = useDocumentOperation(documentId, documentType, releaseId)
const patchRef = useRef<(event: PatchEvent) => void>(() => {
throw new Error(
'Attempted to patch the Sanity document during initial render or in an `useInsertionEffect`. Input components should only call `onChange()` in a useEffect or an event handler.',
)
})
const handleChange = useCallback((event: PatchEvent) => patchRef.current(event), [])
useInsertionEffect(() => {
// Create-linked documents enter a read-only state in Studio. However, unlinking a Create-linked
// document necessitates patching it. This renders it impossible to unlink a Create-linked
// document.
//
// Excluding Create-linked documents from this check is a simple way to ensure they can be
// unlinked.
//
// This does mean `handleChange` can be used to patch any part of a Create-linked document,
// which would otherwise be read-only.
if (readOnly && !isCreateLinked) {
patchRef.current = () => {
throw new Error('Attempted to patch a read-only document')
}
} else {
// note: this needs to happen in an insertion effect to make sure we're ready to receive patches from child components when they run their effects initially
// in case they do e.g. `useEffect(() => props.onChange(set("foo")), [])`
// Note: although we discourage patch-on-mount, we still support it.
patchRef.current = (event: PatchEvent) => {
// when creating a new draft
if (!editState.draft && !editState.published) {
telemetry.log(CreatedDraft)
}
patch.execute(toMutationPatches(event.patches), initialValue?.value)
}
}
}, [
editState.draft,
editState.published,
initialValue,
patch,
telemetry,
readOnly,
isCreateLinked,
])
const formDocumentValue = useMemo(() => {
if (getFormDocumentValue) return getFormDocumentValue(value)
return value
}, [getFormDocumentValue, value])
const formState = useFormState({
schemaType,
documentValue: formDocumentValue,
readOnly,
comparisonValue: comparisonValue || value,
focusPath,
openPath,
collapsedPaths,
presence,
validation,
collapsedFieldSets,
fieldGroupState,
changesOpen,
})!
const formStateRef = useRef(formState)
useEffect(() => {
formStateRef.current = formState
}, [formState])
const handleSetOpenPath = useCallback(
(path: Path) => {
const ops = getExpandOperations(formStateRef.current!, path)
ops.forEach((op) => {
if (op.type === 'expandPath') {
onSetCollapsedPath((prevState) => setAtPath(prevState, op.path, false))
}
if (op.type === 'expandFieldSet') {
onSetCollapsedFieldSets((prevState) => setAtPath(prevState, op.path, false))
}
if (op.type === 'setSelectedGroup') {
onSetFieldGroupState((prevState) => setAtPath(prevState, op.path, op.groupName))
}
})
onSetOpenPath(path)
},
[formStateRef],
)
const updatePresence = useCallback(
(nextFocusPath: Path, payload?: OnPathFocusPayload) => {
presenceStore.setLocation([
{
type: 'document',
documentId: value._id,
path: nextFocusPath,
lastActiveAt: new Date().toISOString(),
selection: payload?.selection,
},
])
},
[presenceStore, value._id],
)
const updatePresenceThrottled = useMemo(
() => throttle(updatePresence, 1000, {leading: true, trailing: true}),
[updatePresence],
)
const focusPathRef = useRef<Path>([])
const handleFocus = useCallback(
(_nextFocusPath: Path, payload?: OnPathFocusPayload) => {
const nextFocusPath = pathFor(_nextFocusPath)
if (nextFocusPath !== focusPathRef.current) {
setFocusPath(pathFor(nextFocusPath))
handleSetOpenPath(pathFor(nextFocusPath.slice(0, -1)))
focusPathRef.current = nextFocusPath
onFocusPath?.(nextFocusPath)
}
updatePresenceThrottled(nextFocusPath, payload)
},
[onFocusPath, setFocusPath, handleSetOpenPath, updatePresenceThrottled],
)
const disableBlurRef = useRef(false)
const handleBlur = useCallback(
(_blurredPath: Path) => {
if (disableBlurRef.current) {
return
}
setFocusPath(EMPTY_ARRAY)
if (focusPathRef.current !== EMPTY_ARRAY) {
focusPathRef.current = EMPTY_ARRAY
onFocusPath?.(EMPTY_ARRAY)
}
// note: we're deliberately not syncing presence here since it would make the user avatar disappear when a
// user clicks outside a field without focusing another one
},
[onFocusPath, setFocusPath],
)
const handleProgrammaticFocus = useCallback(
(nextPath: Path) => {
// Supports changing the focus path not by a user interaction, but by a programmatic change, e.g. the url path changes.
// to avoid the blur event to be triggered, we set a flag to disable it for a short period of time.
disableBlurRef.current = true
if (!deepEquals(focusPathRef.current, nextPath)) {
setFocusPath(nextPath)
handleSetOpenPath(nextPath)
onFocusPath?.(nextPath)
focusPathRef.current = nextPath
}
const timeout = setTimeout(() => {
disableBlurRef.current = false
}, 0)
return () => clearTimeout(timeout)
},
[onFocusPath, handleSetOpenPath],
)
return {
editState,
connectionState,
focusPath,
validation,
ready,
value,
formState,
permissions,
isPermissionsLoading,
formStateRef,
collapsedFieldSets,
collapsedPaths,
openPath,
schemaType,
onChange: handleChange,
onPathOpen: handleSetOpenPath,
onProgrammaticFocus: handleProgrammaticFocus,
onBlur: handleBlur,
onFocus: handleFocus,
onSetActiveFieldGroup: handleSetActiveFieldGroup,
onSetCollapsedPath: handleOnSetCollapsedPath,
onSetCollapsedFieldSet: handleOnSetCollapsedFieldSet,
}
}
const useConnectionToast = (connectionState: ConnectionState) => {
const {push: pushToast} = useToast()
const {t} = useTranslation('studio')
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>
if (connectionState === 'reconnecting') {
timeout = setTimeout(() => {
pushToast({
id: 'sanity/reconnecting',
status: 'warning',
title: t('form.reconnecting.toast.title'),
})
}, 2000) // 2 seconds, we can iterate on the value
}
return () => {
if (timeout) clearTimeout(timeout)
}
}, [connectionState, pushToast, t])
}