From c5407b9e5d563a1395a14431ee3a7ce9d9112b47 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Tue, 26 Sep 2023 17:08:13 -0500 Subject: [PATCH] wip --- packages/sanity/.gitignore | 1 + packages/sanity/exports/document.ts | 1 + packages/sanity/package.json | 11 + packages/sanity/src/core/config/types.ts | 2 +- .../src/core/document/DocumentContextError.ts | 10 + .../core/document/DocumentIdAndTypeContext.ts | 24 + .../document/DocumentIdAndTypeProvider.tsx | 96 ++ .../src/core/document/DocumentProvider.tsx | 69 ++ .../document/formState/FormStateContext.ts | 83 ++ .../document/formState/FormStateProvider.tsx | 317 +++++++ .../src/core/document/formState/index.ts | 2 + .../core/document/formState/useFormState.ts | 14 + packages/sanity/src/core/document/index.ts | 7 + .../initialValue/InitialValueContext.ts | 4 + .../initialValue/InitialValueProvider.tsx | 73 ++ .../src/core/document/initialValue/index.ts | 2 + .../document/initialValue/useInitialValue.ts | 16 + .../ReferenceInoutOptionsContext.ts | 56 ++ .../ReferenceInputOptionsProvider.tsx | 61 ++ .../document/referenceInputOptions/index.ts | 8 + .../useReferenceInputOptions.ts | 13 + .../core/document/timeline/TimelineContext.ts | 9 + .../document/timeline/TimelineProvider.tsx | 65 ++ .../src/core/document/timeline/index.ts | 3 + .../src/core/document/timeline/useTimeline.ts | 11 + .../document/timeline/useTimelineSelector.ts | 27 + .../sanity/src/core/document/useDocumentId.ts | 14 + .../src/core/document/useDocumentType.ts | 15 + .../sanity/src/core/error/ErrorLogger.tsx | 6 +- .../ReferenceInput/useReferenceInput.tsx | 2 +- .../src/core/form/store/useFormState.ts | 5 +- .../studio/contexts/ReferenceInputOptions.tsx | 89 -- .../src/core/form/studio/contexts/index.ts | 1 - .../inputs/reference/StudioReferenceInput.tsx | 2 +- .../src/core/hooks/useConnectionState.ts | 4 +- .../document/document-pair/memoizeKeyGen.ts | 2 +- .../store/_legacy/document/document-store.ts | 4 +- .../store/_legacy/history/TimelineError.ts | 18 + .../_legacy/history/createHistoryStore.ts | 12 +- .../src/core/store/_legacy/history/index.ts | 1 + .../_legacy/history/useTimelineSelector.ts | 1 + .../store/_legacy/history/useTimelineStore.ts | 52 +- packages/sanity/src/core/util/index.ts | 2 + .../src/core/util/useShallowMemoizedObject.ts | 25 + .../sanity/src/core/util/useStableCallback.ts | 51 + .../src/desk/documentActions/DeleteAction.tsx | 20 +- .../desk/documentActions/PublishAction.tsx | 5 +- packages/sanity/src/desk/index.ts | 1 - .../document/DocumentOperationResults.tsx | 7 +- .../src/desk/panes/document/DocumentPane.tsx | 236 ++--- .../panes/document/DocumentPaneContext.ts | 48 +- .../panes/document/DocumentPaneProvider.tsx | 891 ++++++------------ .../documentPanel/DeletedDocumentBanner.tsx | 4 +- .../document/documentPanel/DocumentPanel.tsx | 30 +- .../documentPanel/documentViews/FormView.tsx | 47 +- .../header/DocumentHeaderTabs.tsx | 3 +- .../header/DocumentHeaderTitle.tsx | 4 +- .../header/DocumentPanelHeader.tsx | 9 +- .../document/getInitialValueTemplateOpts.ts | 45 - .../inspectors/changes/ChangesInspector.tsx | 18 +- .../validation/ValidationInspector.tsx | 10 +- .../DocumentActionShortcuts.tsx | 13 +- .../document/statusBar/DocumentStatusBar.tsx | 6 +- .../statusBar/DocumentStatusBarActions.tsx | 10 +- .../statusBar/sparkline/DocumentBadges.tsx | 4 +- .../statusBar/sparkline/DocumentSparkline.tsx | 19 +- .../panes/document/timeline/TimelineError.tsx | 22 - .../document/timeline/TimelineErrorPane.tsx | 32 + .../src/desk/panes/document/timeline/index.ts | 1 + .../panes/document/timeline/timelineMenu.tsx | 47 +- .../sanity/src/desk/panes/document/types.ts | 9 - packages/sanity/tsconfig.json | 1 + packages/sanity/tsconfig.lib.json | 1 + packages/sanity/turbo.json | 2 +- 74 files changed, 1736 insertions(+), 1100 deletions(-) create mode 100644 packages/sanity/exports/document.ts create mode 100644 packages/sanity/src/core/document/DocumentContextError.ts create mode 100644 packages/sanity/src/core/document/DocumentIdAndTypeContext.ts create mode 100644 packages/sanity/src/core/document/DocumentIdAndTypeProvider.tsx create mode 100644 packages/sanity/src/core/document/DocumentProvider.tsx create mode 100644 packages/sanity/src/core/document/formState/FormStateContext.ts create mode 100644 packages/sanity/src/core/document/formState/FormStateProvider.tsx create mode 100644 packages/sanity/src/core/document/formState/index.ts create mode 100644 packages/sanity/src/core/document/formState/useFormState.ts create mode 100644 packages/sanity/src/core/document/index.ts create mode 100644 packages/sanity/src/core/document/initialValue/InitialValueContext.ts create mode 100644 packages/sanity/src/core/document/initialValue/InitialValueProvider.tsx create mode 100644 packages/sanity/src/core/document/initialValue/index.ts create mode 100644 packages/sanity/src/core/document/initialValue/useInitialValue.ts create mode 100644 packages/sanity/src/core/document/referenceInputOptions/ReferenceInoutOptionsContext.ts create mode 100644 packages/sanity/src/core/document/referenceInputOptions/ReferenceInputOptionsProvider.tsx create mode 100644 packages/sanity/src/core/document/referenceInputOptions/index.ts create mode 100644 packages/sanity/src/core/document/referenceInputOptions/useReferenceInputOptions.ts create mode 100644 packages/sanity/src/core/document/timeline/TimelineContext.ts create mode 100644 packages/sanity/src/core/document/timeline/TimelineProvider.tsx create mode 100644 packages/sanity/src/core/document/timeline/index.ts create mode 100644 packages/sanity/src/core/document/timeline/useTimeline.ts create mode 100644 packages/sanity/src/core/document/timeline/useTimelineSelector.ts create mode 100644 packages/sanity/src/core/document/useDocumentId.ts create mode 100644 packages/sanity/src/core/document/useDocumentType.ts delete mode 100644 packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx create mode 100644 packages/sanity/src/core/store/_legacy/history/TimelineError.ts create mode 100644 packages/sanity/src/core/util/useShallowMemoizedObject.ts create mode 100644 packages/sanity/src/core/util/useStableCallback.ts delete mode 100644 packages/sanity/src/desk/panes/document/getInitialValueTemplateOpts.ts delete mode 100644 packages/sanity/src/desk/panes/document/timeline/TimelineError.tsx create mode 100644 packages/sanity/src/desk/panes/document/timeline/TimelineErrorPane.tsx delete mode 100644 packages/sanity/src/desk/panes/document/types.ts diff --git a/packages/sanity/.gitignore b/packages/sanity/.gitignore index 93e97561c114..b323d91d36fb 100644 --- a/packages/sanity/.gitignore +++ b/packages/sanity/.gitignore @@ -16,6 +16,7 @@ /cli.js /desk.js /router.js +/document.js # Playwright-ct artifacts /playwright-ct/report diff --git a/packages/sanity/exports/document.ts b/packages/sanity/exports/document.ts new file mode 100644 index 000000000000..db11ce47ea58 --- /dev/null +++ b/packages/sanity/exports/document.ts @@ -0,0 +1 @@ +export * from '../src/core/document' diff --git a/packages/sanity/package.json b/packages/sanity/package.json index c39848dffe69..a0564cac01a6 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -33,6 +33,17 @@ "import": "./lib/index.esm.js", "default": "./lib/index.esm.js" }, + "./document": { + "types": "./lib/exports/document.d.ts", + "source": "./exports/document.ts", + "require": "./lib/document.js", + "node": { + "import": "./lib/document.cjs.mjs", + "require": "./lib/document.js" + }, + "import": "./lib/document.esm.js", + "default": "./lib/document.esm.js" + }, "./_internal": { "types": "./lib/exports/_internal.d.ts", "source": "./exports/_internal.ts", diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 9d2c6b64f35f..08bc8ea3ec35 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -132,7 +132,7 @@ export interface Tool { options?: Options /** - * The router for the tool. See {@link router.Router} + * The router for the tool. See {@link Router} */ router?: Router diff --git a/packages/sanity/src/core/document/DocumentContextError.ts b/packages/sanity/src/core/document/DocumentContextError.ts new file mode 100644 index 000000000000..7cbdcdb2a698 --- /dev/null +++ b/packages/sanity/src/core/document/DocumentContextError.ts @@ -0,0 +1,10 @@ +/** + * Represents an error thrown when the document context value is missing or not + * found.This error typically indicates that a component is not wrapped in a + * ``. + */ +export class DocumentContextError extends Error { + constructor() { + super('Could not find context value. Did you wrap this component in a ?') + } +} diff --git a/packages/sanity/src/core/document/DocumentIdAndTypeContext.ts b/packages/sanity/src/core/document/DocumentIdAndTypeContext.ts new file mode 100644 index 000000000000..c61ac79a5420 --- /dev/null +++ b/packages/sanity/src/core/document/DocumentIdAndTypeContext.ts @@ -0,0 +1,24 @@ +import {createContext} from 'react' + +/** + * The context value type associated with the `DocumentIdAndTypeProvider`. + * @internal + */ +export interface DocumentIdAndTypeContextValue { + /** + * The published ID of the document. + */ + documentId: string + /** + * The resolved document type. If a document type was not given it will be + * resolved async using the `resolveTypeForDocument` method from the document + * store. + */ + documentType: string +} + +/** + * The react context associated with the `DocumentIdAndTypeProvider`. + * @internal + */ +export const DocumentIdAndTypeContext = createContext(null) diff --git a/packages/sanity/src/core/document/DocumentIdAndTypeProvider.tsx b/packages/sanity/src/core/document/DocumentIdAndTypeProvider.tsx new file mode 100644 index 000000000000..5778612b1ab9 --- /dev/null +++ b/packages/sanity/src/core/document/DocumentIdAndTypeProvider.tsx @@ -0,0 +1,96 @@ +import React, {useEffect, useMemo, useState} from 'react' +import {first} from 'rxjs' +import {getPublishedId} from '../util' +import {useTemplates} from '../hooks' +import {useDocumentStore} from '../store' +import {Template} from '../templates' +import {DocumentIdAndTypeContext, DocumentIdAndTypeContextValue} from './DocumentIdAndTypeContext' + +export interface DocumentIdAndTypeProviderProps { + documentId: string + documentType: string | undefined + templateName: string | undefined + children: React.ReactNode + fallback: React.ReactNode +} + +export function DocumentIdAndTypeProvider({ + fallback, + children, + documentType: typeFromProps, + documentId: idFromProps, + templateName, +}: DocumentIdAndTypeProviderProps) { + const documentId = getPublishedId(idFromProps) + const templates = useTemplates() + const documentStore = useDocumentStore() + + const [error, setError] = useState(null) + if (error) throw error + + // generate a lookup object for templates by their IDs + const templatesById = useMemo(() => { + return templates.reduce>((acc, t) => { + acc[t.id] = t + return acc + }, {}) + }, [templates]) + + // determines the initial `documentType` based on the following priority: + // 1. the value provided in `typeFromProps`, unless it's a legacy value. + // 2. the schema type associated with the template name in `templatesById`. + // 3. defaults to `null` if neither source provides a valid value. + + // mote: The legacy value `' * '` was used historically to denote an unspecified + // document type. We want to ensure it's not used to initialize `documentType`. + const [documentType, setDocumentType] = useState(() => { + // check for a valid `typeFromProps` (excluding the legacy value). + if (typeFromProps && typeFromProps !== '*') return typeFromProps + + // determine the document type from the template's schema type. + const typeFromTemplates = templateName && templatesById[templateName]?.schemaType + if (typeFromTemplates) return typeFromTemplates + + // default to `null` if no valid type is determined. + return null + }) + + useEffect(() => { + // exit early if document type is already determined. + if (documentType) return undefined + + // fetch and set the document type from the document store + const subscription = documentStore + .resolveTypeForDocument(documentId) + // note: this operation is only done once to maintain consistency with + // other non-reactive code paths. + .pipe(first()) + .subscribe({ + next: (documentTypeFromContentLake) => { + if (documentTypeFromContentLake) { + setDocumentType(documentTypeFromContentLake) + } else { + setError( + new Error(`Could not resolve document type for document with ID ${documentId}`), + ) + } + }, + error: setError, + }) + + return () => subscription.unsubscribe() + }, [documentId, documentStore, documentType]) + + if (!documentType) return <>{fallback} + + const contextValue: DocumentIdAndTypeContextValue = { + documentId, + documentType, + } + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/core/document/DocumentProvider.tsx b/packages/sanity/src/core/document/DocumentProvider.tsx new file mode 100644 index 000000000000..27892350c3bc --- /dev/null +++ b/packages/sanity/src/core/document/DocumentProvider.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {noop} from 'lodash' +import {InitialValueProvider, InitialValueProviderProps} from './initialValue' +import { + DocumentIdAndTypeProvider, + DocumentIdAndTypeProviderProps, +} from './DocumentIdAndTypeProvider' +import {TimelineProvider, TimelineProviderProps} from './timeline' +import {FormStateProvider, FormStateProviderProps} from './formState' +import { + ReferenceInputOptionsProvider, + ReferenceInputOptionsProviderProps, +} from './referenceInputOptions' + +/** @internal */ +export interface DocumentProviderProps + extends DocumentIdAndTypeProviderProps, + TimelineProviderProps, + InitialValueProviderProps, + FormStateProviderProps, + ReferenceInputOptionsProviderProps {} + +/** @internal */ +export function DocumentProvider({ + documentId, + documentType, + templateName, + templateParams, + timelineRange = {}, + onTimelineRangeChange = noop, + initialFocusPath, + isHistoryInspectorOpen, + fallback, + children, + EditReferenceLinkComponent, + onEditReference, + activePath, +}: DocumentProviderProps) { + return ( + + + + + + {children} + + + + + + ) +} diff --git a/packages/sanity/src/core/document/formState/FormStateContext.ts b/packages/sanity/src/core/document/formState/FormStateContext.ts new file mode 100644 index 000000000000..34c196f6f250 --- /dev/null +++ b/packages/sanity/src/core/document/formState/FormStateContext.ts @@ -0,0 +1,83 @@ +import {SanityDocumentLike, ObjectSchemaType, ValidationMarker, Path} from '@sanity/types' +import {createContext} from 'react' +import {PatchEvent, DocumentFormNode, StateTree} from '../../form' +import {ConnectionState} from '../../hooks' +import {EditStateFor, PermissionCheckResult} from '../../store' + +export interface FormStateContextValue { + documentId: string + documentType: string + + /** + * the current value of the form. note that this supersedes both the `value` + * and the `displayed` prop from the previous `DocumentPaneContextValue` + * interface + */ + value: TDocument + + editState: EditStateFor + + /** + * if the value originates from the current value from context lake, then this + * will be `current-value`. if the value is from a historical revision then + * it will be `historical-value`, if the value is from an initial value + * template then it will be `initial-value` + */ + valueOrigin: 'draft-value' | 'published-value' | 'initial-value' | 'historical-value' | undefined + + schemaType: ObjectSchemaType + + /** + * The value that is used to compare changes since a particular time. This is + * used to show change indicators. For example, this value is typically the + * published version of the document so that while you're editing the draft, + * the published version can be compared to the current draft version. + */ + compareValue: TDocument | null + + /** + * Signals when the document is ready for editing. Considers the connection + * state, edit states, and whether or not the timeline is ready. + */ + ready: boolean + + /** + * Propagates changes described by a patch event message to the form value. + */ + patchValue: (event: PatchEvent) => void + + /** + * Contains the prepared root form node state. This is the result of + * `prepareFormState`. + */ + formState: DocumentFormNode | null + + focusPath: Path + setFocusPath: (path: Path) => void + + openPath: Path + setOpenPath: (path: Path) => void + + collapsedFieldsets: StateTree + setFieldsetCollapsed: (path: Path, collapsed: boolean) => void + + collapsedPaths: StateTree + setPathCollapsed: (path: Path, collapsed: boolean) => void + + activeFieldGroups: StateTree + setActiveFieldGroup: (path: Path, groupName: string) => void + + validation: ValidationMarker[] + permissions: PermissionCheckResult | undefined + isPermissionsLoading: boolean + + connectionState: ConnectionState + + delete: () => void + isDeleting: boolean + isDeleted: boolean +} + +export const FormStateContext = createContext | null>( + null, +) diff --git a/packages/sanity/src/core/document/formState/FormStateProvider.tsx b/packages/sanity/src/core/document/formState/FormStateProvider.tsx new file mode 100644 index 000000000000..4505957c26f1 --- /dev/null +++ b/packages/sanity/src/core/document/formState/FormStateProvider.tsx @@ -0,0 +1,317 @@ +import {ObjectSchemaType, Path, SanityDocumentLike} from '@sanity/types' +import React, {useCallback, useMemo, useState, useEffect} from 'react' +import {resolveKeyedPath} from '@sanity/util/paths' +import {isActionEnabled} from '@sanity/schema/_internal' +import { + StateTree, + PatchEvent, + setAtPath, + getExpandOperations, + toMutationPatches, + useFormState as useFormStateStandalone, +} from '../../form' +import { + useSchema, + useDocumentOperation, + useConnectionState, + useEditState, + useValidationStatus, +} from '../../hooks' +import {usePresenceStore, DocumentPresence, useDocumentValuePermissions} from '../../store' +import {getPublishedId, getDraftId} from '../../util' +import {useStableCallback} from '../../util/useStableCallback' +import {useInitialValue} from '../initialValue/useInitialValue' +import {useDocumentId} from '../useDocumentId' +import {useDocumentType} from '../useDocumentType' +import {useTimelineSelector} from '../timeline' +import {FormStateContext, FormStateContextValue} from './FormStateContext' + +const EMPTY_STATE_TREE: StateTree = {value: undefined} +const EMPTY_PATH: Path = [] + +/** @internal */ +export interface FormStateProviderProps { + isHistoryInspectorOpen?: boolean + initialFocusPath?: Path + children: React.ReactNode +} + +/** @internal */ +export function FormStateProvider({ + isHistoryInspectorOpen = false, + initialFocusPath, + children, +}: FormStateProviderProps) { + const documentId = useDocumentId() + const documentType = useDocumentType() + const {patch, delete: deleteOp} = useDocumentOperation(documentId, documentType) + const [isDeleting, setIsDeleting] = useState(false) + const initialValue = useInitialValue() // useProvidedInitialValue ?? or useInitialValueFromContext + const connectionState = useConnectionState(documentId, documentType) + const editState = useEditState(documentId, documentType) + const presenceStore = usePresenceStore() + const [error, setError] = useState(null) + if (error) throw error + + const [presence, setPresence] = useState([]) + useEffect(() => { + const subscription = presenceStore.documentPresence(documentId).subscribe({ + next: setPresence, + error: setError, + }) + + return () => subscription.unsubscribe() + }, [documentId, presenceStore]) + + const { + onOlderRevision, + timelineDisplayed, + hasRevTime, + sinceAttributes, + timelineReady, + isPristine, + } = useTimelineSelector( + useCallback( + (state) => ({ + onOlderRevision: state.onOlderRevision, + timelineDisplayed: state.timelineDisplayed, + hasRevTime: state.revTime !== null, + timelineReady: state.timelineReady, + sinceAttributes: state.sinceAttributes, + isPristine: state.isPristine, + }), + [], + ), + ) + + const {value, valueOrigin} = ((): Pick< + FormStateContextValue, + 'valueOrigin' | 'value' + > => { + if (onOlderRevision) { + return { + valueOrigin: 'historical-value', + value: (timelineDisplayed || {_id: documentId, _type: documentType}) as TDocument, + } + } + + if (editState.draft) { + return { + valueOrigin: 'draft-value', + value: editState.draft as TDocument, + } + } + + if (editState.published) { + return { + valueOrigin: 'published-value', + value: editState.published as TDocument, + } + } + + return { + valueOrigin: 'initial-value', + value: initialValue as TDocument, + } + })() + + const compareValue: TDocument | null = isHistoryInspectorOpen + ? (sinceAttributes as TDocument) + : (editState?.published as TDocument) || null + + const schema = useSchema() + const schemaType = schema.get(documentType) as ObjectSchemaType | undefined + + if (!schemaType) { + throw new Error(`Could not find schema type for document type \`${documentType}\``) + } + + const [focusPath, _setFocusPath] = useState(() => + initialFocusPath ? resolveKeyedPath(value, initialFocusPath) : EMPTY_PATH, + ) + const [openPath, _setOpenPath] = useState( + () => + // set the openPath to the initial focus path + focusPath || EMPTY_PATH, + ) + const [fieldGroupState, _setFieldGroupState] = useState>(EMPTY_STATE_TREE) + const [collapsedPaths, _setCollapsedPaths] = useState>(EMPTY_STATE_TREE) + const [collapsedFieldsets, _setCollapsedFieldsets] = + useState>(EMPTY_STATE_TREE) + + const setPathCollapsed = useCallback((path: Path, collapsed: boolean) => { + _setCollapsedPaths((prevState) => setAtPath(prevState, path, collapsed)) + }, []) + + const setFieldsetCollapsed = useCallback((path: Path, collapsed: boolean) => { + _setCollapsedFieldsets((prevState) => setAtPath(prevState, path, collapsed)) + }, []) + + const setActiveFieldGroup = useCallback((path: Path, groupName: string) => { + _setFieldGroupState((prevState) => setAtPath(prevState, path, groupName)) + }, []) + + const [permissions, isPermissionsLoading] = useDocumentValuePermissions({ + document: useMemo(() => { + return { + ...value, + _id: editState.liveEdit ? getPublishedId(documentId) : getDraftId(documentId), + } + }, [value, editState.liveEdit, documentId]), + permission: value._createdAt ? 'update' : 'create', + }) + + /** + * Note that in addition to connection and edit state, we also wait for a valid document timeline + * range to be loaded. This means if we're loading an older revision, the full transaction range must + * be loaded in full prior to the document being displayed. + * + * Previously, visiting studio URLs with timeline params would display the 'current' document and then + * 'snap' in the older revision, which was disorienting and could happen mid-edit. + * + * In the event that the timeline cannot be loaded due to TimelineController errors or blocked requests, + * we skip this readiness check to ensure that users aren't locked out of editing. Trying to select + * a timeline revision in this instance will display an error localized to the popover itself. + */ + const ready = connectionState === 'connected' && editState.ready && timelineReady + + const readOnly = (() => { + const hasNoPermission = !isPermissionsLoading && !permissions?.granted + const updateActionDisabled = !isActionEnabled(schemaType, 'update') + const isNonExistent = !value?._id + const createActionDisabled = isNonExistent && !isActionEnabled(schemaType, 'create') + const reconnecting = connectionState === 'reconnecting' + const isLocked = editState.transactionSyncLock?.enabled + + return ( + !ready || + hasRevTime || + hasNoPermission || + updateActionDisabled || + createActionDisabled || + reconnecting || + isLocked + ) + })() + + const {validation} = useValidationStatus(documentId, documentType) + + const formState = useFormStateStandalone(schemaType, { + fieldGroupState, + collapsedFieldSets: collapsedFieldsets, + collapsedPaths, + value, + comparisonValue: compareValue, + focusPath, + openPath, + readOnly, + presence, + validation, + changesOpen: isHistoryInspectorOpen, + }) + + const setOpenPath = useStableCallback((path: Path) => { + if (!formState) return + + const ops = getExpandOperations(formState, path) + + for (const op of ops) { + switch (op.type) { + case 'expandPath': { + setPathCollapsed(op.path, false) + break + } + case 'expandFieldSet': { + setFieldsetCollapsed(op.path, false) + break + } + case 'setSelectedGroup': { + setActiveFieldGroup(op.path, op.groupName) + break + } + default: { + throw new Error( + `Could not handle operation \`${ + // @ts-expect-error `op` should be of type never here + op.type + }\``, + ) + } + } + } + + _setOpenPath(path) + }) + + const patchValue = useStableCallback((event: PatchEvent) => { + patch.execute(toMutationPatches(event.patches), initialValue) + }) + + const setFocusPath = useStableCallback((nextFocusPath: Path) => { + _setFocusPath(nextFocusPath) + presenceStore.setLocation([ + { + type: 'document', + documentId, + path: nextFocusPath, + lastActiveAt: new Date().toISOString(), + }, + ]) + }) + + const deleteFn = useStableCallback(() => { + setIsDeleting(true) + deleteOp.execute() + }) + + /** + * Determine if the current document is deleted. + * + * When the timeline is available – we check for the absence of an editable document pair + * (both draft + published versions) as well as a non 'pristine' timeline (i.e. a timeline that consists + * of at least one chunk). + * + * In the _very rare_ case where the timeline cannot be loaded – we skip this check and always assume + * the document is NOT deleted. Since we can't accurately determine document deleted status without history, + * skipping this check means that in these cases, users will at least be able to create new documents + * without them being incorrectly marked as deleted. + */ + const isDeleted = useMemo(() => { + if (!timelineReady) { + return false + } + return Boolean(!editState?.draft && !editState?.published) && !isPristine + }, [editState?.draft, editState?.published, isPristine, timelineReady]) + + const contextValue: FormStateContextValue = { + documentId, + documentType, + editState, + value, + valueOrigin, + formState, + compareValue, + activeFieldGroups: fieldGroupState, + collapsedFieldsets, + collapsedPaths, + focusPath, + isPermissionsLoading, + openPath, + permissions, + ready, + validation, + patchValue, + setActiveFieldGroup, + setFieldsetCollapsed, + setFocusPath, + setOpenPath, + setPathCollapsed, + schemaType, + connectionState, + delete: deleteFn, + isDeleted, + isDeleting, + } + + return {children} +} diff --git a/packages/sanity/src/core/document/formState/index.ts b/packages/sanity/src/core/document/formState/index.ts new file mode 100644 index 000000000000..3b59e3870543 --- /dev/null +++ b/packages/sanity/src/core/document/formState/index.ts @@ -0,0 +1,2 @@ +export * from './useFormState' +export * from './FormStateProvider' diff --git a/packages/sanity/src/core/document/formState/useFormState.ts b/packages/sanity/src/core/document/formState/useFormState.ts new file mode 100644 index 000000000000..a1439bc2325e --- /dev/null +++ b/packages/sanity/src/core/document/formState/useFormState.ts @@ -0,0 +1,14 @@ +import {SanityDocumentLike} from '@sanity/types' +import {useContext} from 'react' +import {DocumentContextError} from '../DocumentContextError' +import {FormStateContext, FormStateContextValue} from './FormStateContext' + +/** @internal */ +export function useFormState< + TDocument extends SanityDocumentLike = SanityDocumentLike, +>(): FormStateContextValue { + const context = useContext(FormStateContext) + if (!context) throw new DocumentContextError() + + return context as FormStateContextValue +} diff --git a/packages/sanity/src/core/document/index.ts b/packages/sanity/src/core/document/index.ts new file mode 100644 index 000000000000..f6db4425d55c --- /dev/null +++ b/packages/sanity/src/core/document/index.ts @@ -0,0 +1,7 @@ +export * from './formState' +export * from './initialValue' +export * from './referenceInputOptions' +export * from './timeline' +export * from './useDocumentId' +export * from './useDocumentType' +export * from './DocumentProvider' diff --git a/packages/sanity/src/core/document/initialValue/InitialValueContext.ts b/packages/sanity/src/core/document/initialValue/InitialValueContext.ts new file mode 100644 index 000000000000..e04d4fd0effa --- /dev/null +++ b/packages/sanity/src/core/document/initialValue/InitialValueContext.ts @@ -0,0 +1,4 @@ +import {SanityDocumentLike} from '@sanity/types' +import {createContext} from 'react' + +export const InitialValueContext = createContext(null) diff --git a/packages/sanity/src/core/document/initialValue/InitialValueProvider.tsx b/packages/sanity/src/core/document/initialValue/InitialValueProvider.tsx new file mode 100644 index 000000000000..3b529bdc504a --- /dev/null +++ b/packages/sanity/src/core/document/initialValue/InitialValueProvider.tsx @@ -0,0 +1,73 @@ +import {SanityDocumentLike} from '@sanity/types' +import React, {useEffect, useState} from 'react' +import {first, map} from 'rxjs' +import {useDocumentStore, useInitialValueResolverContext} from '../../store' +import {useDocumentId} from '../useDocumentId' +import {useDocumentType} from '../useDocumentType' +import {InitialValueContext} from './InitialValueContext' + +/** @internal */ +export interface InitialValueProviderProps { + templateName: string | undefined + templateParams: Record | undefined + children: React.ReactNode + fallback: React.ReactNode +} + +/** @internal */ +export function InitialValueProvider({ + children, + fallback, + templateParams, + templateName: template, +}: InitialValueProviderProps) { + const documentId = useDocumentId() + const documentType = useDocumentType() + const [initialValue, setInitialValue] = useState(null) + const [error, setError] = useState(null) + if (error) throw error + + const documentStore = useDocumentStore() + const initialValueResolverContext = useInitialValueResolverContext() + + useEffect(() => { + const subscription = documentStore + .initialValue( + { + documentId, + documentType, + templateName: template, + templateParams, + }, + initialValueResolverContext, + ) + .pipe( + map((message) => { + if (message.type === 'error') throw message.error + return message + }), + first((message): message is Extract => { + return message.type === 'success' + }), + ) + .subscribe({ + error: setError, + next: (message) => setInitialValue(message.value || {_id: documentId, _type: documentType}), + }) + + return () => subscription.unsubscribe() + }, [ + initialValueResolverContext, + documentId, + documentStore, + documentType, + templateParams, + template, + ]) + + if (!initialValue) return <>{fallback} + + return ( + {children} + ) +} diff --git a/packages/sanity/src/core/document/initialValue/index.ts b/packages/sanity/src/core/document/initialValue/index.ts new file mode 100644 index 000000000000..09a69e7ec833 --- /dev/null +++ b/packages/sanity/src/core/document/initialValue/index.ts @@ -0,0 +1,2 @@ +export * from './InitialValueProvider' +export * from './useInitialValue' diff --git a/packages/sanity/src/core/document/initialValue/useInitialValue.ts b/packages/sanity/src/core/document/initialValue/useInitialValue.ts new file mode 100644 index 000000000000..74b9d64bd8ec --- /dev/null +++ b/packages/sanity/src/core/document/initialValue/useInitialValue.ts @@ -0,0 +1,16 @@ +import {SanityDocumentLike} from '@sanity/types' +import {useContext} from 'react' +import {DocumentContextError} from '../DocumentContextError' +import {InitialValueContext} from './InitialValueContext' + +/** + * @internal + */ +export function useInitialValue< + TDocument extends SanityDocumentLike = SanityDocumentLike, +>(): TDocument { + const initialValue = useContext(InitialValueContext) + if (!initialValue) throw new DocumentContextError() + + return initialValue as TDocument +} diff --git a/packages/sanity/src/core/document/referenceInputOptions/ReferenceInoutOptionsContext.ts b/packages/sanity/src/core/document/referenceInputOptions/ReferenceInoutOptionsContext.ts new file mode 100644 index 000000000000..6e25f9351700 --- /dev/null +++ b/packages/sanity/src/core/document/referenceInputOptions/ReferenceInoutOptionsContext.ts @@ -0,0 +1,56 @@ +import {Path} from '@sanity/types' +import {createContext} from 'react' +import {TemplatePermissionsResult} from '../../store' + +/** @internal */ +export interface TemplateOptions { + id: string + params?: Record +} + +/** + * @internal + */ +export interface EditReferenceOptions { + id: string + type: string + parentRefPath: Path + template: TemplateOptions +} + +/** @internal */ +export interface EditReferenceLinkComponentProps extends Omit, 'children'> { + documentId: string + documentType: string + parentRefPath: Path + template?: TemplateOptions + children: React.ReactNode +} + +/** + * @internal + */ +export interface ReferenceInputOptions { + /** + * Represents the highlighted path if ths current document has a related open + * child (e.g. reference in place). + */ + activePath?: {path: Path; state: 'selected' | 'pressed' | 'none'} + /** + * A specialized `EditReferenceLinkComponent` component that takes in the needed props to open a + * referenced document to the right + */ + EditReferenceLinkComponent?: React.ComponentType + + initialValueTemplateItems?: TemplatePermissionsResult[] + + /** + * Similar to `EditReferenceChildLink` expect without the wrapping component + */ + onEditReference?: (options: EditReferenceOptions) => void +} + +// a simple alias for conventions sake +export type ReferenceInputOptionsContextValue = ReferenceInputOptions + +export const ReferenceInputOptionsContext = createContext({}) diff --git a/packages/sanity/src/core/document/referenceInputOptions/ReferenceInputOptionsProvider.tsx b/packages/sanity/src/core/document/referenceInputOptions/ReferenceInputOptionsProvider.tsx new file mode 100644 index 000000000000..861f98c63fc5 --- /dev/null +++ b/packages/sanity/src/core/document/referenceInputOptions/ReferenceInputOptionsProvider.tsx @@ -0,0 +1,61 @@ +import React, {useMemo} from 'react' +import {useShallowMemoizedObject} from '../../util' +import {useSource} from '../../studio' +import {useDocumentId} from '../useDocumentId' +import {useDocumentType} from '../useDocumentType' +import {useTemplatePermissions} from '../../store' +import { + ReferenceInputOptionsContext, + ReferenceInputOptionsContextValue, +} from './ReferenceInoutOptionsContext' + +/** @internal */ +export interface ReferenceInputOptionsProviderProps + extends Omit { + children: React.ReactNode + fallback: React.ReactNode +} + +/** + * @internal + */ +export function ReferenceInputOptionsProvider({ + children, + fallback, + activePath, + EditReferenceLinkComponent, + onEditReference, +}: ReferenceInputOptionsProviderProps) { + const documentId = useDocumentId() + const documentType = useDocumentType() + const {resolveNewDocumentOptions} = useSource().document + + // The templates that should be creatable from inside this document pane. + // For example, from the "Create new" menu in reference inputs. + const templateItems = useMemo(() => { + return resolveNewDocumentOptions({ + type: 'document', + documentId, + schemaType: documentType, + }) + }, [documentId, documentType, resolveNewDocumentOptions]) + + const [templatePermissions, isTemplatePermissionsLoading] = useTemplatePermissions({ + templateItems, + }) + + const context = useShallowMemoizedObject({ + activePath, + EditReferenceLinkComponent, + onEditReference, + initialValueTemplateItems: templatePermissions, + }) + + if (isTemplatePermissionsLoading) return <>{fallback} + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/core/document/referenceInputOptions/index.ts b/packages/sanity/src/core/document/referenceInputOptions/index.ts new file mode 100644 index 000000000000..4dac3db6be58 --- /dev/null +++ b/packages/sanity/src/core/document/referenceInputOptions/index.ts @@ -0,0 +1,8 @@ +export * from './ReferenceInputOptionsProvider' +export * from './useReferenceInputOptions' +export type { + ReferenceInputOptions, + EditReferenceLinkComponentProps, + EditReferenceOptions, + TemplateOptions, +} from './ReferenceInoutOptionsContext' diff --git a/packages/sanity/src/core/document/referenceInputOptions/useReferenceInputOptions.ts b/packages/sanity/src/core/document/referenceInputOptions/useReferenceInputOptions.ts new file mode 100644 index 000000000000..74d74ad48734 --- /dev/null +++ b/packages/sanity/src/core/document/referenceInputOptions/useReferenceInputOptions.ts @@ -0,0 +1,13 @@ +import {useContext} from 'react' +import {DocumentContextError} from '../DocumentContextError' +import { + ReferenceInputOptionsContext, + ReferenceInputOptionsContextValue, +} from './ReferenceInoutOptionsContext' + +/** @internal */ +export function useReferenceInputOptions(): ReferenceInputOptionsContextValue { + const context = useContext(ReferenceInputOptionsContext) + if (!context) throw new DocumentContextError() + return context +} diff --git a/packages/sanity/src/core/document/timeline/TimelineContext.ts b/packages/sanity/src/core/document/timeline/TimelineContext.ts new file mode 100644 index 000000000000..0cb3391527a5 --- /dev/null +++ b/packages/sanity/src/core/document/timeline/TimelineContext.ts @@ -0,0 +1,9 @@ +import {createContext} from 'react' +import {TimelineStore} from './TimelineProvider' + +export interface TimelineContextValue { + timelineStore: TimelineStore + setRange: (params: {rev?: string | null; since?: string | null}) => void +} + +export const TimelineContext = createContext(null) diff --git a/packages/sanity/src/core/document/timeline/TimelineProvider.tsx b/packages/sanity/src/core/document/timeline/TimelineProvider.tsx new file mode 100644 index 000000000000..2c0fe0e52d6f --- /dev/null +++ b/packages/sanity/src/core/document/timeline/TimelineProvider.tsx @@ -0,0 +1,65 @@ +import React, {useCallback, useState} from 'react' +import { + TimelineController, + TimelineState, + useTimelineStore as useStandaloneTimelineStore, +} from '../../store' +import {useDocumentId} from '../useDocumentId' +import {useDocumentType} from '../useDocumentType' +import {useShallowMemoizedObject} from '../../util' +import {TimelineContext} from './TimelineContext' + +/** @internal */ +export interface TimelineStore { + findRangeForRev: TimelineController['findRangeForNewRev'] + findRangeForSince: TimelineController['findRangeForNewSince'] + loadMore: () => void + getSnapshot: () => TimelineState + subscribe: (callback: () => void) => () => void +} + +/** @internal */ +export interface TimelineProviderProps { + children: React.ReactNode + timelineRange: {rev?: string; since?: string} + onTimelineRangeChange: (range: {rev?: string; since?: string}) => void +} + +/** @internal */ +export function TimelineProvider({ + children, + timelineRange: {rev, since}, + onTimelineRangeChange: onRangeChange, +}: TimelineProviderProps) { + const documentId = useDocumentId() + const documentType = useDocumentType() + + const timelineStore = useStandaloneTimelineStore({ + documentId, + documentType, + rev, + since, + }) + + // this handler exists to replace `null`s with `undefined`s + const handleSetRange = useCallback( + (range: {rev?: string | null; since?: string | null}) => { + onRangeChange({ + rev: range.rev || undefined, + since: range.since || undefined, + }) + }, + [onRangeChange], + ) + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/core/document/timeline/index.ts b/packages/sanity/src/core/document/timeline/index.ts new file mode 100644 index 000000000000..94939ba25e48 --- /dev/null +++ b/packages/sanity/src/core/document/timeline/index.ts @@ -0,0 +1,3 @@ +export * from './TimelineProvider' +export * from './useTimeline' +export * from './useTimelineSelector' diff --git a/packages/sanity/src/core/document/timeline/useTimeline.ts b/packages/sanity/src/core/document/timeline/useTimeline.ts new file mode 100644 index 000000000000..d78d104afdb4 --- /dev/null +++ b/packages/sanity/src/core/document/timeline/useTimeline.ts @@ -0,0 +1,11 @@ +import {useContext} from 'react' +import {DocumentContextError} from '../DocumentContextError' +import {TimelineContext, TimelineContextValue} from './TimelineContext' + +/** @internal */ +export function useTimeline(): TimelineContextValue { + const context = useContext(TimelineContext) + if (!context) throw new DocumentContextError() + + return context +} diff --git a/packages/sanity/src/core/document/timeline/useTimelineSelector.ts b/packages/sanity/src/core/document/timeline/useTimelineSelector.ts new file mode 100644 index 000000000000..6a6f512e218c --- /dev/null +++ b/packages/sanity/src/core/document/timeline/useTimelineSelector.ts @@ -0,0 +1,27 @@ +import {useContext} from 'react' +import {identity} from 'rxjs' +import shallowEquals from 'shallow-equals' +import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector' +import {TimelineState} from '../../store' +import {DocumentContextError} from '../DocumentContextError' +import {TimelineContext} from './TimelineContext' + +/** @internal */ +export function useTimelineSelector(): TimelineState +/** @internal */ +export function useTimelineSelector(selector: (state: TimelineState) => TReturn): TReturn +/** @internal */ +export function useTimelineSelector( + selector: (state: TimelineState) => unknown = identity, +): unknown { + const context = useContext(TimelineContext) + if (!context) throw new DocumentContextError() + + return useSyncExternalStoreWithSelector( + context.timelineStore.subscribe, + context.timelineStore.getSnapshot, + null, + selector, + shallowEquals, + ) +} diff --git a/packages/sanity/src/core/document/useDocumentId.ts b/packages/sanity/src/core/document/useDocumentId.ts new file mode 100644 index 000000000000..68780a8b665e --- /dev/null +++ b/packages/sanity/src/core/document/useDocumentId.ts @@ -0,0 +1,14 @@ +import {useContext} from 'react' +import {DocumentIdAndTypeContext} from './DocumentIdAndTypeContext' +import {DocumentContextError} from './DocumentContextError' + +/** + * Returns the ID of the document without a `drafts.` prefix. + * @internal + */ +export function useDocumentId(): string { + const context = useContext(DocumentIdAndTypeContext) + if (!context) throw new DocumentContextError() + + return context.documentId +} diff --git a/packages/sanity/src/core/document/useDocumentType.ts b/packages/sanity/src/core/document/useDocumentType.ts new file mode 100644 index 000000000000..7ceac0814e6e --- /dev/null +++ b/packages/sanity/src/core/document/useDocumentType.ts @@ -0,0 +1,15 @@ +import {useContext} from 'react' +import {DocumentIdAndTypeContext} from './DocumentIdAndTypeContext' +import {DocumentContextError} from './DocumentContextError' + +/** + * Returns the resolved document type derived from the structure or pulled from + * the document's `_type`. + * @internal + */ +export function useDocumentType(): string { + const context = useContext(DocumentIdAndTypeContext) + if (!context) throw new DocumentContextError() + + return context.documentType +} diff --git a/packages/sanity/src/core/error/ErrorLogger.tsx b/packages/sanity/src/core/error/ErrorLogger.tsx index 07772716a5b0..16e584a90b9a 100644 --- a/packages/sanity/src/core/error/ErrorLogger.tsx +++ b/packages/sanity/src/core/error/ErrorLogger.tsx @@ -1,7 +1,7 @@ import {useToast} from '@sanity/ui' import {useEffect} from 'react' import {ConfigResolutionError, SchemaError} from '../config' -import {CorsOriginError} from '../store' +import {CorsOriginError, TimelineError} from '../store' import {globalScope} from '../util' const errorChannel = globalScope.__sanityErrorChannel @@ -63,5 +63,9 @@ function isKnownError(err: Error): boolean { return true } + if (err instanceof TimelineError) { + return true + } + return false } diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx index 576b457ce5ac..214469ab4207 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -13,13 +13,13 @@ import { import {useSchema} from '../../../hooks' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' import {useDocumentPreviewStore} from '../../../store' -import {useReferenceInputOptions} from '../../studio' import {useSource} from '../../../studio' import {Source} from '../../../config' import {useFormValue} from '../../useFormValue' import {FIXME} from '../../../FIXME' import * as adapter from '../../studio/inputs/client-adapters/reference' import {isNonNullable} from '../../../util' +import {useReferenceInputOptions} from '../../../document' import {EditReferenceEvent} from './types' function useValueRef(value: T): {current: T} { diff --git a/packages/sanity/src/core/form/store/useFormState.ts b/packages/sanity/src/core/form/store/useFormState.ts index 51a5b740dd0a..3f5e46cbc02a 100644 --- a/packages/sanity/src/core/form/store/useFormState.ts +++ b/packages/sanity/src/core/form/store/useFormState.ts @@ -16,7 +16,10 @@ export type FormState< S extends ObjectSchemaType = ObjectSchemaType, > = ObjectFormNode -/** @internal */ +/** + * @internal + * @deprecated use `useFormState` from `sanity/document` + */ export function useFormState< T extends {[key in string]: unknown} = {[key in string]: unknown}, S extends ObjectSchemaType = ObjectSchemaType, diff --git a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx b/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx deleted file mode 100644 index a2018fa4c039..000000000000 --- a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, {ComponentType, createContext, HTMLProps, useContext, useMemo} from 'react' -import {Path} from '@sanity/types' -import {TemplatePermissionsResult} from '../../../store' - -const Context = createContext({}) - -/** @internal */ -export interface TemplateOption { - id: string - params?: Record -} - -/** - * @internal - */ -export interface EditReferenceOptions { - id: string - type: string - parentRefPath: Path - template: TemplateOption -} - -/** @internal */ -export interface EditReferenceLinkComponentProps { - documentId: string - documentType: string - parentRefPath: Path - template?: TemplateOption - children: React.ReactNode -} - -/** - * @internal - */ -export interface ReferenceInputOptions { - /** - * Represents the highlighted path if ths current document has a related open - * child (e.g. reference in place). - */ - activePath?: {path: Path; state: 'selected' | 'pressed' | 'none'} - /** - * A specialized `EditReferenceLinkComponent` component that takes in the needed props to open a - * referenced document to the right - */ - EditReferenceLinkComponent?: ComponentType< - Omit, 'children'> & EditReferenceLinkComponentProps - > - - initialValueTemplateItems?: TemplatePermissionsResult[] - - /** - * Similar to `EditReferenceChildLink` expect without the wrapping component - */ - onEditReference?: (options: EditReferenceOptions) => void -} - -/** - * @internal - */ -export function useReferenceInputOptions() { - return useContext(Context) -} - -/** - * @internal - */ -export function ReferenceInputOptionsProvider( - props: ReferenceInputOptions & {children: React.ReactNode}, -) { - const { - children, - activePath, - EditReferenceLinkComponent, - onEditReference, - initialValueTemplateItems, - } = props - - const contextValue = useMemo( - () => ({ - activePath, - EditReferenceLinkComponent, - onEditReference, - initialValueTemplateItems, - }), - [activePath, EditReferenceLinkComponent, onEditReference, initialValueTemplateItems], - ) - - return {children} -} diff --git a/packages/sanity/src/core/form/studio/contexts/index.ts b/packages/sanity/src/core/form/studio/contexts/index.ts index 4bcba15d44eb..faf7dd8dc4f1 100644 --- a/packages/sanity/src/core/form/studio/contexts/index.ts +++ b/packages/sanity/src/core/form/studio/contexts/index.ts @@ -1,3 +1,2 @@ export * from './FormCallbacks' -export * from './ReferenceInputOptions' export * from './reviewChanges' diff --git a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx index 932b4eb0a42a..04a77ea9c747 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -14,7 +14,7 @@ import {catchError, mergeMap} from 'rxjs/operators' import * as adapter from '../client-adapters/reference' import {ReferenceInput} from '../../../inputs/ReferenceInput/ReferenceInput' import {CreateReferenceOption, EditReferenceEvent} from '../../../inputs/ReferenceInput/types' -import {useReferenceInputOptions} from '../../contexts' +import {useReferenceInputOptions} from '../../../../document' import {ObjectInputProps} from '../../../types' import {Source} from '../../../../config' import {useSource} from '../../../../studio' diff --git a/packages/sanity/src/core/hooks/useConnectionState.ts b/packages/sanity/src/core/hooks/useConnectionState.ts index 199d2b3285fc..c4f5c457a6e3 100644 --- a/packages/sanity/src/core/hooks/useConnectionState.ts +++ b/packages/sanity/src/core/hooks/useConnectionState.ts @@ -9,13 +9,13 @@ export type ConnectionState = 'connecting' | 'reconnecting' | 'connected' const INITIAL: ConnectionState = 'connecting' /** @internal */ -export function useConnectionState(publishedDocId: string, docTypeName: string): ConnectionState { +export function useConnectionState(publishedDocId: string, docTypeName?: string): ConnectionState { const documentStore = useDocumentStore() return useMemoObservable( () => documentStore.pair.documentEvents(publishedDocId, docTypeName).pipe( - map((ev: {type: string}) => ev.type), + map((ev) => ev.type), map((eventType) => eventType !== 'reconnect'), switchMap((isConnected) => isConnected ? of('connected') : timer(200).pipe(mapTo('reconnecting')), diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/memoizeKeyGen.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/memoizeKeyGen.ts index 1b7ac0e1b3c9..490f7576ba8a 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/memoizeKeyGen.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/memoizeKeyGen.ts @@ -1,7 +1,7 @@ import {SanityClient} from '@sanity/client' import {IdPair} from '../types' -export function memoizeKeyGen(client: SanityClient, idPair: IdPair, typeName: string) { +export function memoizeKeyGen(client: SanityClient, idPair: IdPair, typeName = '*'): string { const config = client.config() return `${config.dataset ?? ''}-${config.projectId ?? ''}-${idPair.publishedId}-${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 eed0f55d04a8..fcacd034a504 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -54,7 +54,7 @@ export interface DocumentStore { pair: { consistencyStatus: (publishedId: string, type: string) => Observable /** @internal */ - documentEvents: (publishedId: string, type: string) => Observable + documentEvents: (publishedId: string, type?: string) => Observable /** @internal */ editOperations: (publishedId: string, type: string) => Observable editState: (publishedId: string, type: string) => Observable @@ -116,7 +116,7 @@ export function createDocumentStore({ consistencyStatus(publishedId, type) { return consistencyStatus(ctx.client, getIdPairFromPublished(publishedId), type) }, - documentEvents(publishedId, type) { + documentEvents(publishedId, type = '*') { return documentEvents(ctx.client, getIdPairFromPublished(publishedId), type) }, editOperations(publishedId, type) { diff --git a/packages/sanity/src/core/store/_legacy/history/TimelineError.ts b/packages/sanity/src/core/store/_legacy/history/TimelineError.ts new file mode 100644 index 000000000000..1d757b6e17be --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/history/TimelineError.ts @@ -0,0 +1,18 @@ +/** + * Represents an error specific to timeline operations or contexts. The error + * attempts to extract a meaningful message from its cause, defaulting to + * "Unknown error" if one isn't found. + * + * The `cause` property holds the original error + * @internal + */ +export class TimelineError extends Error { + constructor(public cause: unknown) { + const messageFromCause = + typeof cause === 'object' && cause && 'message' in cause && typeof cause.message === 'string' + ? cause.message + : 'Unknown error' + + super(`TimelineError: ${messageFromCause}`) + } +} diff --git a/packages/sanity/src/core/store/_legacy/history/createHistoryStore.ts b/packages/sanity/src/core/store/_legacy/history/createHistoryStore.ts index 1d2f14630a24..87b687c82e8d 100644 --- a/packages/sanity/src/core/store/_legacy/history/createHistoryStore.ts +++ b/packages/sanity/src/core/store/_legacy/history/createHistoryStore.ts @@ -30,7 +30,10 @@ export interface HistoryStore { restore: (id: string, targetId: string, rev: string) => Observable - /** @internal */ + /** + * @internal + * @deprecated import the `createTimelineController` function instead + */ getTimelineController: (options: { client: SanityClient documentId: string @@ -94,7 +97,10 @@ const getDocumentAtRevision = ( return entry } -const getTimelineController = ({ +/** + * @internal + */ +export const createTimelineController = ({ client, documentId, documentType, @@ -219,6 +225,6 @@ export function createHistoryStore({client}: HistoryStoreOptions): HistoryStore restore: (id, targetId, rev) => restore(client, id, targetId, rev), - getTimelineController, + getTimelineController: createTimelineController, } } diff --git a/packages/sanity/src/core/store/_legacy/history/index.ts b/packages/sanity/src/core/store/_legacy/history/index.ts index 974a230b5e1d..8dc891fcc2e3 100644 --- a/packages/sanity/src/core/store/_legacy/history/index.ts +++ b/packages/sanity/src/core/store/_legacy/history/index.ts @@ -2,3 +2,4 @@ export * from './createHistoryStore' export * from './history' export * from './useTimelineStore' export * from './useTimelineSelector' +export * from './TimelineError' diff --git a/packages/sanity/src/core/store/_legacy/history/useTimelineSelector.ts b/packages/sanity/src/core/store/_legacy/history/useTimelineSelector.ts index 51bb9dce0230..bbcb4662f50a 100644 --- a/packages/sanity/src/core/store/_legacy/history/useTimelineSelector.ts +++ b/packages/sanity/src/core/store/_legacy/history/useTimelineSelector.ts @@ -6,6 +6,7 @@ import {TimelineState, TimelineStore} from './useTimelineStore' * Accepts a selector function which can be used to opt-in to specific timelineStore updates. * * @internal + * @deprecated use `useTimelineSelector` from `sanity/document` instead */ export function useTimelineSelector( timelineStore: TimelineStore, diff --git a/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts b/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts index e41be7efffab..c7b98efe09ca 100644 --- a/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts +++ b/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts @@ -1,16 +1,31 @@ import {ObjectDiff} from '@sanity/diff' -import {useEffect, useMemo, useRef} from 'react' +import {useEffect, useMemo, useRef, useState} from 'react' import deepEquals from 'react-fast-compare' -import {BehaviorSubject, catchError, distinctUntilChanged, map, of, Subscription, tap} from 'rxjs' -import {Annotation, Chunk, SelectionState, TimelineController, useHistoryStore} from '../../..' +import { + BehaviorSubject, + catchError, + distinctUntilChanged, + map, + of, + Subscription, + tap, + throwError, +} from 'rxjs' +import { + Annotation, + Chunk, + SelectionState, + TimelineController, + createTimelineController, +} from '../../..' import {useClient} from '../../../hooks' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' import {remoteSnapshots, RemoteSnapshotVersionEvent} from '../document' +import {TimelineError} from './TimelineError' interface UseTimelineControllerOpts { documentId: string documentType: string - onError?: (err: Error) => void rev?: string since?: string } @@ -73,30 +88,32 @@ export interface TimelineStore { * `useSyncExternalStore` to subscribe to selected state changes. * * @internal + * + * @deprecated use `useTimeline` from `sanity/document` instead. * */ export function useTimelineStore({ documentId, documentType, - onError, rev, since, }: UseTimelineControllerOpts): TimelineStore { - const historyStore = useHistoryStore() const snapshotsSubscriptionRef = useRef(null) const timelineStateRef = useRef(INITIAL_TIMELINE_STATE) const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const [error, setError] = useState(null) + if (error) throw error /** * The mutable TimelineController, used internally */ const controller = useMemo( () => - historyStore.getTimelineController({ + createTimelineController({ client, documentId, documentType, }), - [client, documentId, documentType, historyStore], + [client, documentId, documentType], ) /** @@ -204,25 +221,22 @@ export function useTimelineStore({ }), // Only emit (and in turn, re-render) when values have changed distinctUntilChanged(deepEquals), - // Emit initial timeline state whenever we encounter an error in TimelineController's `handler` callback. - // A little ham-fisted, but also reflects how we handle timeline errors in the UI - // (i.e. no timeline state or diffs are rendered and we revert to the current editable document) - catchError((err) => { - onError?.(err) - return of(INITIAL_TIMELINE_STATE) - }), tap((timelineState) => { timelineStateRef.current = timelineState }), - // Trigger callback function required by `useSyncExternalStore` to denote when to re-render - tap(callback), ) - .subscribe() + .subscribe({ + // Trigger callback function required by `useSyncExternalStore` to denote when to re-render + next: () => callback(), + // propagate errors wrapped in `TimelineError`s so they can be + // re-thrown in the component stack and caught in error boundaries + error: (err) => setError(new TimelineError(err)), + }) return () => subscription.unsubscribe() }, } - }, [controller, onError, timelineController$]) + }, [controller, timelineController$]) return timelineStore } diff --git a/packages/sanity/src/core/util/index.ts b/packages/sanity/src/core/util/index.ts index 88b2cd5f2c3a..23f1d9e23d80 100644 --- a/packages/sanity/src/core/util/index.ts +++ b/packages/sanity/src/core/util/index.ts @@ -17,3 +17,5 @@ export * from './useLoadable' export * from './useThrottledCallback' export * from './useUnique' export * from './userHasRole' +export * from './useShallowMemoizedObject' +export * from './useStableCallback' diff --git a/packages/sanity/src/core/util/useShallowMemoizedObject.ts b/packages/sanity/src/core/util/useShallowMemoizedObject.ts new file mode 100644 index 000000000000..1a7a19958d61 --- /dev/null +++ b/packages/sanity/src/core/util/useShallowMemoizedObject.ts @@ -0,0 +1,25 @@ +import {useRef, useMemo} from 'react' +import shallowEquals from 'shallow-equals' + +/** + * Performs a shallow equality check between the given object and the object + * from the previous render cycle. Returns the previous object if they are + * shallowly equal; otherwise, returns the new object. + * + * @param obj - The object to be memoized. + * @returns A memoized version of the provided object, based on a shallow + * equality check. + * @internal + */ +export function useShallowMemoizedObject(obj: TObject): TObject { + const prev = useRef(null) + + const memoizedObj = useMemo(() => { + if (prev.current && shallowEquals(prev.current, obj)) return prev.current + + prev.current = obj + return obj + }, [obj]) + + return memoizedObj +} diff --git a/packages/sanity/src/core/util/useStableCallback.ts b/packages/sanity/src/core/util/useStableCallback.ts new file mode 100644 index 000000000000..2b41c26ca449 --- /dev/null +++ b/packages/sanity/src/core/util/useStableCallback.ts @@ -0,0 +1,51 @@ +import {useRef, useEffect, useCallback, useLayoutEffect} from 'react' + +// Removes the `useLayoutEffect` warning on the server +// https://github.com/reduxjs/react-redux/blob/0f1ab0960c38ac61b4fe69285a5b401f9f6e6177/src/utils/useIsomorphicLayoutEffect.js +const useUniversalLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect + +/** + * Wraps incoming values in a stable getter function that returns the latest + * value. Useful tool for signifying a value should not be considered as a + * reactive dependency. + * + * When this getter is invoked, it pulls the latest value from a hidden ref. + * This ref is synced with the current inside of a `useLayoutEffect` so that it + * runs before other `useEffect`s. + * @internal + */ +export function useStableGetter(value: T): () => T { + const ref = useRef(value) + + useUniversalLayoutEffect(() => { + ref.current = value + }, [value]) + + const getValue = useCallback(() => { + return ref.current + }, []) + + return getValue +} + +/** + * Returns a stable callback that does not change between re-renders. + * + * The implementation uses `useStableGetter` to get latest version of the + * callback (and the values closed within it) so values are not stale between + * different invocations. + * @internal + */ +export function useStableCallback( + callback: (...args: TArgs) => TReturn, +): (...args: TArgs) => TReturn { + const getCallback = useStableGetter(callback) + + return useCallback( + (...args) => { + const cb = getCallback() + return cb(...args) + }, + [getCallback], + ) +} diff --git a/packages/sanity/src/desk/documentActions/DeleteAction.tsx b/packages/sanity/src/desk/documentActions/DeleteAction.tsx index 7f1051ecac12..36c7f83aafae 100644 --- a/packages/sanity/src/desk/documentActions/DeleteAction.tsx +++ b/packages/sanity/src/desk/documentActions/DeleteAction.tsx @@ -2,6 +2,7 @@ import {TrashIcon} from '@sanity/icons' import React, {useCallback, useState} from 'react' +import {useFormState} from 'sanity/document' import {ConfirmDeleteDialog} from '../components' import { DocumentActionComponent, @@ -10,18 +11,18 @@ import { useDocumentOperation, useDocumentPairPermissions, } from 'sanity' -import {useDocumentPane} from '../panes/document/useDocumentPane' const DISABLED_REASON_TITLE = { NOTHING_TO_DELETE: 'This document doesn’t yet exist or is already deleted', + NOT_READY: '', // purposefully empty } /** @internal */ export const DeleteAction: DocumentActionComponent = ({id, type, draft, onComplete}) => { - const {setIsDeleting: paneSetIsDeleting} = useDocumentPane() + const {delete: deleteFn, isDeleting} = useFormState() const {delete: deleteOp} = useDocumentOperation(id, type) - const [isDeleting, setIsDeleting] = useState(false) const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false) + const deleteDisabledReason = deleteOp.disabled const handleCancel = useCallback(() => { setConfirmDialogOpen(false) @@ -29,12 +30,10 @@ export const DeleteAction: DocumentActionComponent = ({id, type, draft, onComple }, [onComplete]) const handleConfirm = useCallback(() => { - setIsDeleting(true) setConfirmDialogOpen(false) - paneSetIsDeleting(true) - deleteOp.execute() + deleteFn() onComplete() - }, [deleteOp, onComplete, paneSetIsDeleting]) + }, [deleteFn, onComplete]) const handle = useCallback(() => { setConfirmDialogOpen(true) @@ -66,11 +65,8 @@ export const DeleteAction: DocumentActionComponent = ({id, type, draft, onComple return { tone: 'critical', icon: TrashIcon, - disabled: isDeleting || Boolean(deleteOp.disabled) || isPermissionsLoading, - title: - (deleteOp.disabled && - DISABLED_REASON_TITLE[deleteOp.disabled as keyof typeof DISABLED_REASON_TITLE]) || - '', + disabled: isDeleting || Boolean(deleteDisabledReason) || isPermissionsLoading, + title: (deleteDisabledReason && DISABLED_REASON_TITLE[deleteDisabledReason]) || '', label: isDeleting ? 'Deleting…' : 'Delete', shortcut: 'Ctrl+Alt+D', onHandle: handle, diff --git a/packages/sanity/src/desk/documentActions/PublishAction.tsx b/packages/sanity/src/desk/documentActions/PublishAction.tsx index 8a07bd574c43..7cc30954b9f2 100644 --- a/packages/sanity/src/desk/documentActions/PublishAction.tsx +++ b/packages/sanity/src/desk/documentActions/PublishAction.tsx @@ -1,6 +1,7 @@ import {CheckmarkIcon, PublishIcon} from '@sanity/icons' import {isValidationErrorMarker} from '@sanity/types' import React, {useCallback, useEffect, useState} from 'react' +import {useDocumentId, useDocumentType} from 'sanity/document' import {TimeAgo} from '../components' import {useDocumentPane} from '../panes/document/useDocumentPane' import { @@ -45,7 +46,9 @@ export const PublishAction: DocumentActionComponent = (props) => { const {publish} = useDocumentOperation(id, type) const validationStatus = useValidationStatus(id, type) const syncState = useSyncState(id, type) - const {changesOpen, onHistoryOpen, documentId, documentType} = useDocumentPane() + const documentId = useDocumentId() + const documentType = useDocumentType() + const {changesOpen, onHistoryOpen} = useDocumentPane() const editState = useEditState(documentId, documentType) const revision = (editState?.draft || editState?.published || {})._rev diff --git a/packages/sanity/src/desk/index.ts b/packages/sanity/src/desk/index.ts index cdec939e5d22..3440f123add4 100644 --- a/packages/sanity/src/desk/index.ts +++ b/packages/sanity/src/desk/index.ts @@ -3,7 +3,6 @@ export * from './deskTool' export {DocumentInspectorHeader} from './panes/document/documentInspector' // Export `DocumentPaneProvider` -export {type DocumentPaneProviderProps} from './panes/document/types' export * from './panes/document/DocumentPaneProvider' export * from './panes/document/useDocumentPane' diff --git a/packages/sanity/src/desk/panes/document/DocumentOperationResults.tsx b/packages/sanity/src/desk/panes/document/DocumentOperationResults.tsx index 089c173a5d94..5911accda9d1 100644 --- a/packages/sanity/src/desk/panes/document/DocumentOperationResults.tsx +++ b/packages/sanity/src/desk/panes/document/DocumentOperationResults.tsx @@ -1,8 +1,8 @@ import {useToast} from '@sanity/ui' import React, {memo, useEffect, useRef} from 'react' -import {useDocumentPane} from './useDocumentPane' -import {useDocumentOperationEvent} from 'sanity' +import {useDocumentId, useDocumentType} from 'sanity/document' import {usePaneRouter} from '../../components' +import {useDocumentOperationEvent} from 'sanity' function getOpErrorTitle(op: string): string { if (op === 'delete') { @@ -36,7 +36,8 @@ const IGNORE_OPS = ['patch', 'commit'] export const DocumentOperationResults = memo(function DocumentOperationResults() { const {push: pushToast} = useToast() - const {documentId, documentType} = useDocumentPane() + const documentId = useDocumentId() + const documentType = useDocumentType() const event: any = useDocumentOperationEvent(documentId, documentType) const prevEvent = useRef(event) const paneRouter = usePaneRouter() diff --git a/packages/sanity/src/desk/panes/document/DocumentPane.tsx b/packages/sanity/src/desk/panes/document/DocumentPane.tsx index 86727ede079b..9e292f0164ce 100644 --- a/packages/sanity/src/desk/panes/document/DocumentPane.tsx +++ b/packages/sanity/src/desk/panes/document/DocumentPane.tsx @@ -3,47 +3,50 @@ import { Code, DialogProvider, DialogProviderProps, + ErrorBoundary, + ErrorBoundaryProps, Flex, PortalProvider, Stack, Text, useElementRect, + useToast, } from '@sanity/ui' -import React, {memo, useCallback, useMemo, useState} from 'react' +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react' import styled from 'styled-components' import {fromString as pathFromString} from '@sanity/util/paths' import {Path} from '@sanity/types' -import {DocumentPaneNode} from '../../types' import {Pane, PaneFooter, usePaneRouter} from '../../components' import {usePaneLayout} from '../../components/pane/usePaneLayout' import {ErrorPane} from '../error' import {LoadingPane} from '../loading' import {DOCUMENT_PANEL_PORTAL_ELEMENT} from '../../constants' import {DocumentOperationResults} from './DocumentOperationResults' -import {DocumentPaneProvider} from './DocumentPaneProvider' +import {DocumentPaneProvider, DocumentPaneProviderProps} from './DocumentPaneProvider' import {DocumentPanel} from './documentPanel' import {DocumentActionShortcuts} from './keyboardShortcuts' import {DocumentStatusBar} from './statusBar' -import {DocumentPaneProviderProps} from './types' import {useDocumentPane} from './useDocumentPane' import { DOCUMENT_INSPECTOR_MIN_WIDTH, DOCUMENT_PANEL_INITIAL_MIN_WIDTH, DOCUMENT_PANEL_MIN_WIDTH, + HISTORY_INSPECTOR_NAME, } from './constants' +import {TimelineErrorPane} from './timeline' +import {DocumentProvider, useDocumentType, useFormState} from 'sanity/document' import { ChangeConnectorRoot, - ReferenceInputOptionsProvider, SourceProvider, + TimelineError, + getPublishedId, isDev, - useDocumentType, + useConnectionState, useSource, - useTemplatePermissions, - useTemplates, useZIndex, } from 'sanity' -type DocumentPaneOptions = DocumentPaneNode['options'] +type ErrorParams = Parameters[0] const DIALOG_PROVIDER_POSITION: DialogProviderProps['position'] = [ // We use the `position: fixed` for dialogs on narrower screens (first two media breakpoints). @@ -73,31 +76,8 @@ export const DocumentPane = memo(function DocumentPane(props: DocumentPaneProvid function DocumentPaneInner(props: DocumentPaneProviderProps) { const {pane, paneKey} = props - const {resolveNewDocumentOptions} = useSource().document const paneRouter = usePaneRouter() - const options = usePaneOptions(pane.options, paneRouter.params) - const {documentType, isLoaded: isDocumentLoaded} = useDocumentType(options.id, options.type) - - // The templates that should be creatable from inside this document pane. - // For example, from the "Create new" menu in reference inputs. - const templateItems = useMemo(() => { - return resolveNewDocumentOptions({ - type: 'document', - documentId: options.id, - schemaType: options.type, - }) - }, [options.id, options.type, resolveNewDocumentOptions]) - - const [templatePermissions, isTemplatePermissionsLoading] = useTemplatePermissions({ - templateItems, - }) - const isLoaded = isDocumentLoaded && !isTemplatePermissionsLoading - - const providerProps = useMemo(() => { - return isLoaded && documentType && options.type !== documentType - ? mergeDocumentType(props, options, documentType) - : props - }, [props, documentType, isLoaded, options]) + const [errorParams, setErrorParams] = useState(null) const {ReferenceChildLink, handleEditReference, groupIndex, routerPanesState} = paneRouter const childParams = routerPanesState[groupIndex + 1]?.[0].params || {} @@ -119,106 +99,119 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) { : {path: [], state: 'none'} }, [parentRefPath, groupIndex, routerPanesStateLength]) - if (options.type === '*' && !isLoaded) { - return + const templateNameFromUrl = paneRouter.params?.template + const templateNameFromStructure = pane.options.template + + if ( + templateNameFromUrl && + templateNameFromStructure && + templateNameFromUrl !== templateNameFromStructure + ) { + // eslint-disable-next-line no-console + console.warn( + `Conflicting templates: URL says "${templateNameFromUrl}", structure node says "${templateNameFromStructure}". Using "${templateNameFromStructure}".`, + ) + } + const templateName = templateNameFromStructure || templateNameFromUrl + + const templateParams = { + ...pane.options.templateParameters, + ...(typeof paneRouter.payload === 'object' ? paneRouter.payload || {} : {}), } - if (!documentType) { + const handleTimelineRangeChange = useCallback( + (range: {since?: string; rev?: string}) => { + paneRouter.setParams({ + ...paneRouter.params, + since: range.since, + rev: range.rev, + }) + }, + [paneRouter], + ) + + const connectionState = useConnectionState(getPublishedId(pane.options.id)) + const toast = useToast() + + // reset the error if the ID changes + useEffect(() => { + setErrorParams(null) + }, [pane.options.id]) + + useEffect(() => { + if (connectionState === 'reconnecting') { + toast.push({ + id: 'sanity/desk/reconnecting', + status: 'warning', + title: <>Connection lost. Reconnecting…, + }) + } + }, [connectionState, toast]) + + if (errorParams?.error instanceof TimelineError) { + // not using a `useCallback` here because this handler won't always be needed + const handleRetry = () => { + const nextParams = {...paneRouter.params} + delete nextParams.since + delete nextParams.rev + paneRouter.setParams(nextParams) + + setErrorParams(null) + } + return ( - The document was not found} - > - - - The document type is not defined, and a document with the {options.id}{' '} - identifier could not be found. - - - + // eslint-disable-next-line react/jsx-no-bind + onRetry={handleRetry} + /> ) } return ( - - {/* NOTE: this is a temporary location for this provider until we */} - {/* stabilize the reference input options formally in the form builder */} - {/* eslint-disable-next-line react/jsx-pascal-case */} - + + } > - - - + + + + + ) } -function usePaneOptions( - options: DocumentPaneOptions, - params: Record = {}, -): DocumentPaneOptions { - const templates = useTemplates() - - return useMemo(() => { - // The document type is provided, so return - if (options.type && options.type !== '*') { - return options - } - - // Attempt to derive document type from the template configuration - const templateName = options.template || params.template - const template = templateName ? templates.find((t) => t.id === templateName) : undefined - const documentType = template?.schemaType - - // No document type was found in a template - if (!documentType) { - return options - } - - // The template provided the document type, so modify the pane’s `options` property - return {...options, type: documentType} - }, [options, params.template, templates]) -} - -function mergeDocumentType( - props: DocumentPaneProviderProps, - options: DocumentPaneOptions, - documentType: string, -): DocumentPaneProviderProps { - return { - ...props, - pane: { - ...props.pane, - options: {...options, type: documentType}, - }, - } -} - function InnerDocumentPane() { - const { - changesOpen, - documentType, - inspector, - inspectOpen, - onFocus, - onPathOpen, - onHistoryOpen, - onKeyUp, - paneKey, - schemaType, - value, - } = useDocumentPane() + const documentType = useDocumentType() + const {changesOpen, inspector, inspectOpen, onHistoryOpen, onKeyUp, paneKey} = useDocumentPane() + const {setOpenPath, setFocusPath, schemaType, value, ready} = useFormState() const {collapsed: layoutCollapsed} = usePaneLayout() + const paneRouter = usePaneRouter() const zOffsets = useZIndex() const [rootElement, setRootElement] = useState(null) const [footerElement, setFooterElement] = useState(null) @@ -229,12 +222,21 @@ function InnerDocumentPane() { const footerRect = useElementRect(footerElement) const footerH = footerRect?.height + // Reset `focusPath` when `documentId` or `params.path` changes + useEffect(() => { + if (ready && paneRouter.params?.path) { + const nextParams = {...paneRouter.params} + delete nextParams.path + paneRouter.setParams(nextParams) + } + }, [paneRouter, ready]) + const onConnectorSetFocus = useCallback( (path: Path) => { - onPathOpen(path) - onFocus(path) + setOpenPath(path) + setFocusPath(path) }, - [onPathOpen, onFocus], + [setFocusPath, setOpenPath], ) const currentMinWidth = diff --git a/packages/sanity/src/desk/panes/document/DocumentPaneContext.ts b/packages/sanity/src/desk/panes/document/DocumentPaneContext.ts index 29312ad169b5..6667ea944ee2 100644 --- a/packages/sanity/src/desk/panes/document/DocumentPaneContext.ts +++ b/packages/sanity/src/desk/panes/document/DocumentPaneContext.ts @@ -1,27 +1,13 @@ -import { - ValidationMarker, - Path, - SanityDocument, - ObjectSchemaType, - SanityDocumentLike, -} from '@sanity/types' +import {Path} from '@sanity/types' import {createContext} from 'react' import {View} from '../../structureBuilder' import {PaneMenuItem, PaneMenuItemGroup} from '../../types' -import {TimelineMode} from './types' import { DocumentActionComponent, DocumentBadgeComponent, DocumentFieldAction, - DocumentFormNode, DocumentInspector, DocumentLanguageFilterComponent, - DocumentPermission, - EditStateFor, - PatchEvent, - PermissionCheckResult, - StateTree, - TimelineStore, } from 'sanity' /** @internal */ @@ -31,26 +17,13 @@ export interface DocumentPaneContextValue { badges: DocumentBadgeComponent[] | null changesOpen: boolean closeInspector: (inspectorName?: string) => void - collapsedFieldSets: StateTree | undefined - collapsedPaths: StateTree | undefined - compareValue: Partial | null - connectionState: 'connecting' | 'reconnecting' | 'connected' - displayed: Partial | null - documentId: string - documentIdRaw: string - documentType: string - editState: EditStateFor | null fieldActions: DocumentFieldAction[] - focusPath: Path index: number inspectOpen: boolean inspector: DocumentInspector | null inspectors: DocumentInspector[] menuItemGroups: PaneMenuItemGroup[] menuItems: PaneMenuItem[] - onBlur: (blurredPath: Path) => void - onChange: (event: PatchEvent) => void - onFocus: (pathOrEvent: Path) => void onHistoryClose: () => void onHistoryOpen: () => void onInspectClose: () => void @@ -58,31 +31,12 @@ export interface DocumentPaneContextValue { onMenuAction: (item: PaneMenuItem) => void onPaneClose: () => void onPaneSplit?: () => void - onPathOpen: (path: Path) => void - onSetActiveFieldGroup: (path: Path, groupName: string) => void - onSetCollapsedPath: (path: Path, expanded: boolean) => void - onSetCollapsedFieldSet: (path: Path, expanded: boolean) => void openInspector: (inspectorName: string, paneParams?: Record) => void paneKey: string previewUrl?: string | null - ready: boolean - schemaType: ObjectSchemaType - setTimelineMode: (mode: TimelineMode) => void - setTimelineRange(since: string | null, rev: string | null): void - setIsDeleting: (state: boolean) => void source?: string - timelineError: Error | null - timelineMode: TimelineMode - timelineStore: TimelineStore title: string | null - validation: ValidationMarker[] - value: SanityDocumentLike views: View[] - formState: DocumentFormNode | null - permissions?: PermissionCheckResult | null - isDeleting: boolean - isDeleted: boolean - isPermissionsLoading: boolean unstable_languageFilter: DocumentLanguageFilterComponent[] } diff --git a/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx index afc3804d08bd..e9931cd5742b 100644 --- a/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx @@ -1,18 +1,12 @@ import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {ObjectSchemaType, Path, SanityDocument, SanityDocumentLike} from '@sanity/types' -import {omit, set} from 'lodash' -import {useToast} from '@sanity/ui' -import {fromString as pathFromString, resolveKeyedPath} from '@sanity/util/paths' +import {omit} from 'lodash' import isHotkey from 'is-hotkey' -import {isActionEnabled} from '@sanity/schema/_internal' import {usePaneRouter} from '../../components' import {PaneMenuItem} from '../../types' import {useDeskTool} from '../../useDeskTool' +import {BaseDeskToolPaneProps} from '../types' import {DocumentPaneContext, DocumentPaneContextValue} from './DocumentPaneContext' import {getMenuItems} from './menuItems' -import {DocumentPaneProviderProps} from './types' -import {usePreviewUrl} from './usePreviewUrl' -import {getInitialValueTemplateOpts} from './getInitialValueTemplateOpts' import { DEFAULT_MENU_ITEM_GROUPS, EMPTY_PARAMS, @@ -22,28 +16,8 @@ import { import {DocumentInspectorMenuItemsResolver} from './DocumentInspectorMenuItemsResolver' import { DocumentInspector, - DocumentPresence, - PatchEvent, - StateTree, - toMutationPatches, - getExpandOperations, - getPublishedId, - setAtPath, - useConnectionState, - useDocumentOperation, - useEditState, - useFormState, - useInitialValue, - usePresenceStore, - useSchema, useSource, - useTemplates, useUnique, - useValidationStatus, - getDraftId, - useDocumentValuePermissions, - useTimelineStore, - useTimelineSelector, DocumentFieldAction, DocumentInspectorMenuItem, FieldActionsResolver, @@ -51,642 +25,345 @@ import { DocumentFieldActionNode, FieldActionsProvider, } from 'sanity' +import {useDocumentId, useDocumentType, useFormState} from 'sanity/document' + +/** @internal */ +export type DocumentPaneProviderProps = { + children?: React.ReactNode +} & BaseDeskToolPaneProps<'document'> /** * @internal */ -// eslint-disable-next-line complexity, max-statements -export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { - const {children, index, pane, paneKey} = props - const schema = useSchema() - const templates = useTemplates() - const { - actions: documentActions, - badges: documentBadges, - unstable_fieldActions: fieldActionsResolver, - unstable_languageFilter: languageFilterResolver, - inspectors: inspectorsResolver, - } = useSource().document - const presenceStore = usePresenceStore() - const paneRouter = usePaneRouter() - const setPaneParams = paneRouter.setParams - const {features} = useDeskTool() - const {push: pushToast} = useToast() - const { - options, - menuItemGroups = DEFAULT_MENU_ITEM_GROUPS, - title = null, - views: viewsProp = [], - } = pane - const paneOptions = useUnique(options) - const documentIdRaw = paneOptions.id - const documentId = getPublishedId(documentIdRaw) - const documentType = options.type - const params = useUnique(paneRouter.params) || EMPTY_PARAMS - const panePayload = useUnique(paneRouter.payload) - const {templateName, templateParams} = useMemo( - () => - getInitialValueTemplateOpts(templates, { - documentType, - templateName: paneOptions.template, - templateParams: paneOptions.templateParameters, - panePayload, - urlTemplate: params.template, +export const DocumentPaneProvider = memo( + ({children, index, pane: _pane, paneKey}: DocumentPaneProviderProps) => { + const documentId = useDocumentId() + const documentType = useDocumentType() + + const pane = useMemo( + () => ({ + ..._pane, + options: { + ..._pane.options, + type: documentType, + }, }), - [documentType, paneOptions, params, panePayload, templates], - ) - const initialValueRaw = useInitialValue({ - documentId, - documentType, - templateName, - templateParams, - }) - const initialValue = useUnique(initialValueRaw) - const {patch} = useDocumentOperation(documentId, documentType) - const editState = useEditState(documentId, documentType) - const {validation: validationRaw} = useValidationStatus(documentId, documentType) - const connectionState = useConnectionState(documentId, documentType) - const schemaType = schema.get(documentType) as ObjectSchemaType | undefined - const value: SanityDocumentLike = editState?.draft || editState?.published || initialValue.value - const [isDeleting, setIsDeleting] = useState(false) - - const [inspectorMenuItems, setInspectorMenuItems] = useState([]) - - // Resolve document actions - const actions = useMemo( - () => documentActions({schemaType: documentType, documentId}), - [documentActions, documentId, documentType], - ) - - // Resolve document badges - const badges = useMemo( - () => documentBadges({schemaType: documentType, documentId}), - [documentBadges, documentId, documentType], - ) - - // Resolve document language filter - const languageFilter = useMemo( - () => languageFilterResolver({schemaType: documentType, documentId}), - [documentId, documentType, languageFilterResolver], - ) - - const validation = useUnique(validationRaw) - const views = useUnique(viewsProp) - - const [focusPath, setFocusPath] = useState(() => - params.path ? pathFromString(params.path) : [], - ) - const activeViewId = params.view || (views[0] && views[0].id) || null - const [timelineMode, setTimelineMode] = useState<'since' | 'rev' | 'closed'>('closed') - - const [timelineError, setTimelineError] = useState(null) - /** - * Create an intermediate store which handles document Timeline + TimelineController - * creation, and also fetches pre-requsite document snapshots. Compatible with `useSyncExternalStore` - * and made available to child components via DocumentPaneContext. - */ - const timelineStore = useTimelineStore({ - documentId, - documentType, - onError: setTimelineError, - rev: params.rev, - since: params.since, - }) - - // Subscribe to external timeline state changes - const onOlderRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) - const revTime = useTimelineSelector(timelineStore, (state) => state.revTime) - const sinceAttributes = useTimelineSelector(timelineStore, (state) => state.sinceAttributes) - const timelineDisplayed = useTimelineSelector(timelineStore, (state) => state.timelineDisplayed) - const timelineReady = useTimelineSelector(timelineStore, (state) => state.timelineReady) - const isPristine = useTimelineSelector(timelineStore, (state) => state.isPristine) - - /** - * Determine if the current document is deleted. - * - * When the timeline is available – we check for the absence of an editable document pair - * (both draft + published versions) as well as a non 'pristine' timeline (i.e. a timeline that consists - * of at least one chunk). - * - * In the _very rare_ case where the timeline cannot be loaded – we skip this check and always assume - * the document is NOT deleted. Since we can't accurately determine document deleted status without history, - * skipping this check means that in these cases, users will at least be able to create new documents - * without them being incorrectly marked as deleted. - */ - const isDeleted = useMemo(() => { - if (!timelineReady) { - return false - } - return Boolean(!editState?.draft && !editState?.published) && !isPristine - }, [editState?.draft, editState?.published, isPristine, timelineReady]) - - // TODO: this may cause a lot of churn. May be a good idea to prevent these - // requests unless the menu is open somehow - const previewUrl = usePreviewUrl(value) - - const [presence, setPresence] = useState([]) - useEffect(() => { - const subscription = presenceStore.documentPresence(documentId).subscribe((nextPresence) => { - setPresence(nextPresence) - }) - return () => { - subscription.unsubscribe() - } - }, [documentId, presenceStore]) + [documentType, _pane], + ) - const inspectors: DocumentInspector[] = useMemo( - () => inspectorsResolver({documentId, documentType}), - [documentId, documentType, inspectorsResolver], - ) + const { + actions: documentActions, + badges: documentBadges, + unstable_fieldActions: fieldActionsResolver, + unstable_languageFilter: languageFilterResolver, + inspectors: inspectorsResolver, + } = useSource().document + const paneRouter = usePaneRouter() + const setPaneParams = paneRouter.setParams + const {features} = useDeskTool() + const {menuItemGroups = DEFAULT_MENU_ITEM_GROUPS, title = null, views: viewsProp = []} = pane + const params = useUnique(paneRouter.params) || EMPTY_PARAMS + const {schemaType} = useFormState() + + const [inspectorMenuItems, setInspectorMenuItems] = useState([]) + + // Resolve document actions + const actions = useMemo( + () => documentActions({schemaType: documentType, documentId}), + [documentActions, documentId, documentType], + ) - const [inspectorName, setInspectorName] = useState(() => params.inspect || null) + // Resolve document badges + const badges = useMemo( + () => documentBadges({schemaType: documentType, documentId}), + [documentBadges, documentId, documentType], + ) - // Handle inspector name changes from URL - const inspectParamRef = useRef(params.inspect) - useEffect(() => { - if (inspectParamRef.current !== params.inspect) { - inspectParamRef.current = params.inspect - setInspectorName(params.inspect || null) - } - }, [params.inspect]) + // Resolve document language filter + const languageFilter = useMemo( + () => languageFilterResolver({schemaType: documentType, documentId}), + [documentId, documentType, languageFilterResolver], + ) + + const views = useUnique(viewsProp) + + const activeViewId = params.view || (views[0] && views[0].id) || null - const currentInspector = inspectors?.find((i) => i.name === inspectorName) - const resolvedChangesInspector = inspectors.find((i) => i.name === HISTORY_INSPECTOR_NAME) + // TODO: this may cause a lot of churn. May be a good idea to prevent these + // requests unless the menu is open somehow + // const previewUrl = usePreviewUrl(value) - const changesOpen = currentInspector?.name === HISTORY_INSPECTOR_NAME + const inspectors: DocumentInspector[] = useMemo( + () => inspectorsResolver({documentId, documentType}), + [documentId, documentType, inspectorsResolver], + ) + + const [inspectorName, setInspectorName] = useState(() => params.inspect || null) + + // Handle inspector name changes from URL + const inspectParamRef = useRef(params.inspect) + useEffect(() => { + if (inspectParamRef.current !== params.inspect) { + inspectParamRef.current = params.inspect + setInspectorName(params.inspect || null) + } + }, [params.inspect]) + + const currentInspector = inspectors?.find((i) => i.name === inspectorName) + const resolvedChangesInspector = inspectors.find((i) => i.name === HISTORY_INSPECTOR_NAME) + + const changesOpen = currentInspector?.name === HISTORY_INSPECTOR_NAME - const hasValue = Boolean(value) - const menuItems = useMemo( - () => - getMenuItems({ + // TODO: fix this + const previewUrl = '' + + // const hasValue = Boolean(value) + const menuItems = (() => { + return getMenuItems({ currentInspector, features, - hasValue, + hasValue: true, inspectorMenuItems, inspectors, previewUrl, - }), - [currentInspector, features, hasValue, inspectorMenuItems, inspectors, previewUrl], - ) - const inspectOpen = params.inspect === 'on' - const compareValue: Partial | null = changesOpen - ? sinceAttributes - : editState?.published || null - - const fieldActions: DocumentFieldAction[] = useMemo( - () => (schemaType ? fieldActionsResolver({documentId, documentType, schemaType}) : []), - [documentId, documentType, fieldActionsResolver, schemaType], - ) - - /** - * Note that in addition to connection and edit state, we also wait for a valid document timeline - * range to be loaded. This means if we're loading an older revision, the full transaction range must - * be loaded in full prior to the document being displayed. - * - * Previously, visiting studio URLs with timeline params would display the 'current' document and then - * 'snap' in the older revision, which was disorienting and could happen mid-edit. - * - * In the event that the timeline cannot be loaded due to TimelineController errors or blocked requests, - * we skip this readiness check to ensure that users aren't locked out of editing. Trying to select - * a timeline revision in this instance will display an error localized to the popover itself. - */ - const ready = - connectionState === 'connected' && editState.ready && (timelineReady || !!timelineError) - - const displayed: Partial | undefined = useMemo( - () => (onOlderRevision ? timelineDisplayed || {_id: value._id, _type: value._type} : value), - [onOlderRevision, timelineDisplayed, value], - ) - - const setTimelineRange = useCallback( - (newSince: string, newRev: string | null) => { - setPaneParams({ - ...params, - since: newSince, - rev: newRev || undefined, }) - }, - [params, setPaneParams], - ) - - const handleFocus = useCallback( - (nextFocusPath: Path) => { - setFocusPath(nextFocusPath) - presenceStore.setLocation([ - { - type: 'document', - documentId, - path: nextFocusPath, - lastActiveAt: new Date().toISOString(), - }, - ]) - }, - [documentId, presenceStore, setFocusPath], - ) - - const handleBlur = useCallback( - (blurredPath: Path) => { - setFocusPath([]) - // 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 - }, - [setFocusPath], - ) - - const patchRef = useRef<(event: PatchEvent) => void>(() => { - throw new Error('Nope') - }) - - patchRef.current = (event: PatchEvent) => { - patch.execute(toMutationPatches(event.patches), initialValue.value) - } - - const handleChange = useCallback((event: PatchEvent) => patchRef.current(event), []) - - const closeInspector = useCallback( - (closeInspectorName?: string) => { - // inspector?: DocumentInspector - const inspector = closeInspectorName && inspectors.find((i) => i.name === closeInspectorName) - - if (closeInspectorName && !inspector) { - console.warn(`No inspector named "${closeInspectorName}"`) - return - } + })() + const inspectOpen = params.inspect === 'on' - if (!currentInspector) { - return - } - - if (inspector) { - const result = inspector.onClose?.({params}) ?? {params} - - setInspectorName(null) - inspectParamRef.current = undefined + const fieldActions: DocumentFieldAction[] = useMemo( + () => (schemaType ? fieldActionsResolver({documentId, documentType, schemaType}) : []), + [documentId, documentType, fieldActionsResolver, schemaType], + ) - setPaneParams({...result.params, inspect: undefined}) + const closeInspector = useCallback( + (closeInspectorName?: string) => { + // inspector?: DocumentInspector + const inspector = + closeInspectorName && inspectors.find((i) => i.name === closeInspectorName) - return - } + if (closeInspectorName && !inspector) { + console.warn(`No inspector named "${closeInspectorName}"`) + return + } - if (currentInspector) { - const result = currentInspector.onClose?.({params}) ?? {params} + if (!currentInspector) { + return + } - setInspectorName(null) - inspectParamRef.current = undefined + if (inspector) { + const result = inspector.onClose?.({params}) ?? {params} - setPaneParams({...result.params, inspect: undefined}) - } - }, - [currentInspector, inspectors, params, setPaneParams], - ) + setInspectorName(null) + inspectParamRef.current = undefined - const openInspector = useCallback( - (nextInspectorName: string, paneParams?: Record) => { - const nextInspector = inspectors.find((i) => i.name === nextInspectorName) + setPaneParams({...result.params, inspect: undefined}) - if (!nextInspector) { - console.warn(`No inspector named "${nextInspectorName}"`) - return - } + return + } - // if the inspector is already open, only update params - if (currentInspector?.name === nextInspector.name) { - setPaneParams({...params, ...paneParams, inspect: nextInspector.name}) - return - } + if (currentInspector) { + const result = currentInspector.onClose?.({params}) ?? {params} - let currentParams = params + setInspectorName(null) + inspectParamRef.current = undefined - if (currentInspector) { - const closeResult = nextInspector.onClose?.({params: currentParams}) ?? { - params: currentParams, + setPaneParams({...result.params, inspect: undefined}) } + }, + [currentInspector, inspectors, params, setPaneParams], + ) - currentParams = closeResult.params - } + const openInspector = useCallback( + (nextInspectorName: string, paneParams?: Record) => { + const nextInspector = inspectors.find((i) => i.name === nextInspectorName) - const result = nextInspector.onOpen?.({params: currentParams}) ?? {params: currentParams} + if (!nextInspector) { + console.warn(`No inspector named "${nextInspectorName}"`) + return + } - setInspectorName(nextInspector.name) - inspectParamRef.current = nextInspector.name + // if the inspector is already open, only update params + if (currentInspector?.name === nextInspector.name) { + setPaneParams({...params, ...paneParams, inspect: nextInspector.name}) + return + } - setPaneParams({...result.params, ...paneParams, inspect: nextInspector.name}) - }, - [currentInspector, inspectors, params, setPaneParams], - ) + let currentParams = params - const handleHistoryClose = useCallback(() => { - if (resolvedChangesInspector) { - closeInspector(resolvedChangesInspector.name) - } - }, [closeInspector, resolvedChangesInspector]) + if (currentInspector) { + const closeResult = nextInspector.onClose?.({params: currentParams}) ?? { + params: currentParams, + } - const handleHistoryOpen = useCallback(() => { - if (!features.reviewChanges) { - return - } + currentParams = closeResult.params + } - if (resolvedChangesInspector) { - openInspector(resolvedChangesInspector.name) - } - }, [features.reviewChanges, openInspector, resolvedChangesInspector]) + const result = nextInspector.onOpen?.({params: currentParams}) ?? {params: currentParams} - const handlePaneClose = useCallback(() => paneRouter.closeCurrent(), [paneRouter]) + setInspectorName(nextInspector.name) + inspectParamRef.current = nextInspector.name - const handlePaneSplit = useCallback(() => paneRouter.duplicateCurrent(), [paneRouter]) + setPaneParams({...result.params, ...paneParams, inspect: nextInspector.name}) + }, + [currentInspector, inspectors, params, setPaneParams], + ) - const toggleLegacyInspect = useCallback( - (toggle = !inspectOpen) => { - if (toggle) { - setPaneParams({...params, inspect: 'on'}) - } else { - setPaneParams(omit(params, 'inspect')) - } - }, - [inspectOpen, params, setPaneParams], - ) - - const handleMenuAction = useCallback( - (item: PaneMenuItem) => { - if (item.action === 'production-preview' && previewUrl) { - window.open(previewUrl) - return true + const handleHistoryClose = useCallback(() => { + if (resolvedChangesInspector) { + closeInspector(resolvedChangesInspector.name) } + }, [closeInspector, resolvedChangesInspector]) - if (item.action === 'inspect') { - toggleLegacyInspect(true) - return true + const handleHistoryOpen = useCallback(() => { + if (!features.reviewChanges) { + return } - if (item.action === 'reviewChanges') { - handleHistoryOpen() - return true + if (resolvedChangesInspector) { + openInspector(resolvedChangesInspector.name) } + }, [features.reviewChanges, openInspector, resolvedChangesInspector]) - if (typeof item.action === 'string' && item.action.startsWith(INSPECT_ACTION_PREFIX)) { - const nextInspectorName = item.action.slice(INSPECT_ACTION_PREFIX.length) - const nextInspector = inspectors.find((i) => i.name === nextInspectorName) + const handlePaneClose = useCallback(() => paneRouter.closeCurrent(), [paneRouter]) - if (nextInspector) { - if (nextInspector.name === inspectorName) { - closeInspector(nextInspector.name) - } else { - openInspector(nextInspector.name) - } + const handlePaneSplit = useCallback(() => paneRouter.duplicateCurrent(), [paneRouter]) + + const toggleLegacyInspect = useCallback( + (toggle = !inspectOpen) => { + if (toggle) { + setPaneParams({...params, inspect: 'on'}) + } else { + setPaneParams(omit(params, 'inspect')) + } + }, + [inspectOpen, params, setPaneParams], + ) + + const handleMenuAction = useCallback( + (item: PaneMenuItem) => { + if (item.action === 'production-preview' && previewUrl) { + window.open(previewUrl) return true } - } - return false - }, - [ + if (item.action === 'inspect') { + toggleLegacyInspect(true) + return true + } + + if (item.action === 'reviewChanges') { + handleHistoryOpen() + return true + } + + if (typeof item.action === 'string' && item.action.startsWith(INSPECT_ACTION_PREFIX)) { + const nextInspectorName = item.action.slice(INSPECT_ACTION_PREFIX.length) + const nextInspector = inspectors.find((i) => i.name === nextInspectorName) + + if (nextInspector) { + if (nextInspector.name === inspectorName) { + closeInspector(nextInspector.name) + } else { + openInspector(nextInspector.name) + } + return true + } + } + + return false + }, + [ + closeInspector, + handleHistoryOpen, + inspectorName, + inspectors, + openInspector, + toggleLegacyInspect, + ], + ) + + const handleKeyUp = useCallback( + (event: React.KeyboardEvent) => { + for (const item of menuItems) { + if (item.shortcut) { + if (isHotkey(item.shortcut, event)) { + event.preventDefault() + event.stopPropagation() + handleMenuAction(item) + return + } + } + } + }, + [handleMenuAction, menuItems], + ) + + const handleLegacyInspectClose = useCallback( + () => toggleLegacyInspect(false), + [toggleLegacyInspect], + ) + + // const docId = value._id ? value._id : 'dummy-id' + + const documentPane: DocumentPaneContextValue = { + actions, + activeViewId, + badges, + changesOpen, closeInspector, - handleHistoryOpen, - inspectorName, + fieldActions, + inspector: currentInspector || null, inspectors, + menuItems, + onHistoryClose: handleHistoryClose, + onHistoryOpen: handleHistoryOpen, + onInspectClose: handleLegacyInspectClose, + onKeyUp: handleKeyUp, + onMenuAction: handleMenuAction, + onPaneClose: handlePaneClose, + onPaneSplit: handlePaneSplit, openInspector, + index, + inspectOpen, + menuItemGroups: menuItemGroups || [], + paneKey, previewUrl, - toggleLegacyInspect, - ], - ) - - const handleKeyUp = useCallback( - (event: React.KeyboardEvent) => { - for (const item of menuItems) { - if (item.shortcut) { - if (isHotkey(item.shortcut, event)) { - event.preventDefault() - event.stopPropagation() - handleMenuAction(item) - return - } - } - } - }, - [handleMenuAction, menuItems], - ) - - const handleLegacyInspectClose = useCallback( - () => toggleLegacyInspect(false), - [toggleLegacyInspect], - ) - - const [openPath, onSetOpenPath] = useState([]) - const [fieldGroupState, onSetFieldGroupState] = useState>() - const [collapsedPaths, onSetCollapsedPath] = useState>() - const [collapsedFieldSets, onSetCollapsedFieldSets] = useState>() - - 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 liveEdit = Boolean(schemaType?.liveEdit) - const docId = value._id ? value._id : 'dummy-id' - const docPermissionsInput = useMemo(() => { - return { - ...value, - _id: liveEdit ? getPublishedId(docId) : getDraftId(docId), + title, + views, + unstable_languageFilter: languageFilter, } - }, [liveEdit, value, docId]) - - const [permissions, isPermissionsLoading] = useDocumentValuePermissions({ - document: docPermissionsInput, - permission: requiredPermission, - }) - const isNonExistent = !value?._id - - 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 [rootFieldActionNodes, setRootFieldActionNodes] = useState([]) return ( - !ready || - revTime !== null || - hasNoPermission || - updateActionDisabled || - createActionDisabled || - reconnecting || - isLocked || - isDeleting || - isDeleted + + {inspectors.length > 0 && ( + + )} + + {/* Resolve root-level field actions */} + {fieldActions.length > 0 && schemaType && ( + + )} + + + {children} + + ) - }, [ - connectionState, - editState.transactionSyncLock, - isNonExistent, - isDeleted, - isDeleting, - isPermissionsLoading, - permissions?.granted, - ready, - revTime, - schemaType, - ]) - - const formState = useFormState(schemaType!, { - value: displayed, - readOnly, - comparisonValue: compareValue, - focusPath, - openPath, - collapsedPaths, - presence, - validation, - collapsedFieldSets, - fieldGroupState, - changesOpen, - }) - - const formStateRef = useRef(formState) - formStateRef.current = formState - - const setOpenPath = 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 documentPane: DocumentPaneContextValue = { - actions, - activeViewId, - badges, - changesOpen, - closeInspector, - collapsedFieldSets, - collapsedPaths, - compareValue, - connectionState, - displayed, - documentId, - documentIdRaw, - documentType, - editState, - fieldActions, - focusPath, - inspector: currentInspector || null, - inspectors, - menuItems, - onBlur: handleBlur, - onChange: handleChange, - onFocus: handleFocus, - onPathOpen: setOpenPath, - onHistoryClose: handleHistoryClose, - onHistoryOpen: handleHistoryOpen, - onInspectClose: handleLegacyInspectClose, - onKeyUp: handleKeyUp, - onMenuAction: handleMenuAction, - onPaneClose: handlePaneClose, - onPaneSplit: handlePaneSplit, - onSetActiveFieldGroup: handleSetActiveFieldGroup, - onSetCollapsedPath: handleOnSetCollapsedPath, - onSetCollapsedFieldSet: handleOnSetCollapsedFieldSet, - openInspector, - index, - inspectOpen, - validation, - menuItemGroups: menuItemGroups || [], - paneKey, - previewUrl, - ready, - schemaType: schemaType!, - isPermissionsLoading, - permissions, - setTimelineMode, - setTimelineRange, - setIsDeleting, - isDeleting, - isDeleted, - timelineError, - timelineMode, - timelineStore, - title, - value, - views, - formState, - unstable_languageFilter: languageFilter, - } - - useEffect(() => { - if (connectionState === 'reconnecting') { - pushToast({ - id: 'sanity/desk/reconnecting', - status: 'warning', - title: <>Connection lost. Reconnecting…, - }) - } - }, [connectionState, pushToast]) - - // Reset `focusPath` when `documentId` or `params.path` changes - useEffect(() => { - if (ready && params.path) { - const {path, ...restParams} = params - const pathFromUrl = resolveKeyedPath(formStateRef.current?.value, pathFromString(path)) - // Reset focus path when url params path changes - setFocusPath(pathFromUrl) - setOpenPath(pathFromUrl) - // remove the `path`-param from url after we have consumed it as the initial focus path - paneRouter.setParams(restParams) - } - }, [params, documentId, setOpenPath, ready, paneRouter]) - - const [rootFieldActionNodes, setRootFieldActionNodes] = useState([]) - - return ( - - {inspectors.length > 0 && ( - - )} - - {/* Resolve root-level field actions */} - {fieldActions.length > 0 && schemaType && ( - - )} - - - {children} - - - ) -}) + }, +) DocumentPaneProvider.displayName = 'DocumentPaneProvider' diff --git a/packages/sanity/src/desk/panes/document/documentPanel/DeletedDocumentBanner.tsx b/packages/sanity/src/desk/panes/document/documentPanel/DeletedDocumentBanner.tsx index f4bca949be4a..883061d2e0cc 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/DeletedDocumentBanner.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/DeletedDocumentBanner.tsx @@ -2,7 +2,7 @@ import React, {useCallback} from 'react' import {Button, Card, Container, Flex, Text} from '@sanity/ui' import {ReadOnlyIcon} from '@sanity/icons' import styled from 'styled-components' -import {useDocumentPane} from '../useDocumentPane' +import {useFormState} from 'sanity/document' import {useDocumentOperation} from 'sanity' import {useRouter} from 'sanity/router' @@ -16,7 +16,7 @@ interface DeletedDocumentBannerProps { } export function DeletedDocumentBanner({revisionId}: DeletedDocumentBannerProps) { - const {documentId, documentType} = useDocumentPane() + const {documentId, documentType} = useFormState() const {restore} = useDocumentOperation(documentId, documentType) const {navigateIntent} = useRouter() const handleRestore = useCallback(() => { diff --git a/packages/sanity/src/desk/panes/document/documentPanel/DocumentPanel.tsx b/packages/sanity/src/desk/panes/document/documentPanel/DocumentPanel.tsx index 9a9bcc3cccac..84eaaf8e694c 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/DocumentPanel.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/DocumentPanel.tsx @@ -18,7 +18,8 @@ import {ReferenceChangedBanner} from './ReferenceChangedBanner' import {PermissionCheckBanner} from './PermissionCheckBanner' import {FormView} from './documentViews' import {DocumentPanelHeader} from './header' -import {ScrollContainer, useTimelineSelector, VirtualizerScrollInstanceProvider} from 'sanity' +import {useFormState, useTimelineSelector} from 'sanity/document' +import {ScrollContainer, VirtualizerScrollInstanceProvider} from 'sanity' interface DocumentPanelProps { footerHeight: number | null @@ -47,22 +48,18 @@ const Scroller = styled(ScrollContainer)<{$disabled: boolean}>(({$disabled}) => export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { const {footerHeight, isInspectOpen, rootElement, setDocumentPanelPortalElement} = props + const {activeViewId, inspector, views} = useDocumentPane() const { - activeViewId, - displayed, documentId, - editState, - inspector, value, - views, ready, schemaType, permissions, isPermissionsLoading, + editState, isDeleting, isDeleted, - timelineStore, - } = useDocumentPane() + } = useFormState() const {collapsed: layoutCollapsed} = usePaneLayout() const {collapsed} = usePane() const parentPortal = usePortal() @@ -103,21 +100,18 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { createElement(activeView.component, { document: { draft: editState?.draft || null, - displayed: displayed || value, - historical: displayed, + displayed: value, + historical: value, published: editState?.published || null, }, documentId, options: activeView.options, schemaType, }), - [activeView, displayed, documentId, editState?.draft, editState?.published, schemaType, value], + [activeView, documentId, editState?.draft, editState?.published, schemaType, value], ) - const lastNonDeletedRevId = useTimelineSelector( - timelineStore, - (state) => state.lastNonDeletedRevId, - ) + const lastNonDeletedRevId = useTimelineSelector((state) => state.lastNonDeletedRevId) // Scroll to top as `documentId` changes useEffect(() => { @@ -132,10 +126,6 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { } }, [portalElement, setDocumentPanelPortalElement]) - const inspectDialog = useMemo(() => { - return isInspectOpen ? : null - }, [isInspectOpen, displayed, value]) - const showInspector = Boolean(!collapsed && inspector) return ( @@ -182,7 +172,7 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { {activeViewNode} - {inspectDialog} + {isInspectOpen ? : null}
diff --git a/packages/sanity/src/desk/panes/document/documentPanel/documentViews/FormView.tsx b/packages/sanity/src/desk/panes/document/documentPanel/documentViews/FormView.tsx index 6917ad5858cb..baa759fdde1d 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/documentViews/FormView.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/documentViews/FormView.tsx @@ -6,6 +6,7 @@ import {tap} from 'rxjs/operators' import {useDocumentPane} from '../../useDocumentPane' import {Delay} from '../../../../components/Delay' import {useConditionalToast} from './useConditionalToast' +import {useFormState} from 'sanity/document' import { DocumentMutationEvent, DocumentRebaseEvent, @@ -16,6 +17,7 @@ import { fromMutationPatches, useDocumentPresence, useDocumentStore, + useEditState, } from 'sanity' interface FormViewProps { @@ -28,25 +30,24 @@ const preventDefault = (ev: React.FormEvent) => ev.preventDefault() export const FormView = forwardRef(function FormView(props, ref) { const {hidden, margins} = props + const {fieldActions} = useDocumentPane() const { - collapsedFieldSets, - collapsedPaths, - displayed: value, - editState, documentId, documentType, - fieldActions, - onChange, - validation, + value, ready, formState, - onFocus, - onBlur, - onSetCollapsedPath, - onPathOpen, - onSetCollapsedFieldSet, - onSetActiveFieldGroup, - } = useDocumentPane() + collapsedFieldsets, + collapsedPaths, + patchValue, + setActiveFieldGroup, + setFocusPath, + setOpenPath, + setFieldsetCollapsed, + setPathCollapsed, + validation, + } = useFormState() + const editState = useEditState(documentId, documentType) const documentStore = useDocumentStore() const presence = useDocumentPresence(documentId) @@ -158,7 +159,7 @@ export const FormView = forwardRef(function FormV (function FormV groups={formState.groups} id="root" members={formState.members} - onChange={onChange} - onFieldGroupSelect={onSetActiveFieldGroup} - onPathBlur={onBlur} - onPathFocus={onFocus} - onPathOpen={onPathOpen} - onSetFieldSetCollapsed={onSetCollapsedFieldSet} - onSetPathCollapsed={onSetCollapsedPath} + onChange={patchValue} + onFieldGroupSelect={setActiveFieldGroup} + onPathBlur={() => { + // TODO + }} + onPathFocus={setFocusPath} + onPathOpen={setOpenPath} + onSetFieldSetCollapsed={setFieldsetCollapsed} + onSetPathCollapsed={setPathCollapsed} presence={presence} readOnly={formState.readOnly} schemaType={formState.schemaType} diff --git a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTabs.tsx b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTabs.tsx index 8cfe3ca5ef54..f27d176e362f 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTabs.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTabs.tsx @@ -2,6 +2,7 @@ import React, {useCallback} from 'react' import {Tab, TabList} from '@sanity/ui' import {useDocumentPane} from '../../useDocumentPane' import {usePaneRouter} from '../../../../components' +import {useFormState} from 'sanity/document' export function DocumentHeaderTabs() { const {activeViewId, paneKey, views} = useDocumentPane() @@ -33,7 +34,7 @@ function DocumentHeaderTab(props: { viewId: string | null }) { const {isActive, tabPanelId, viewId} = props - const {ready} = useDocumentPane() + const {ready} = useFormState() const {setView} = usePaneRouter() const handleClick = useCallback(() => setView(viewId), [setView, viewId]) diff --git a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTitle.tsx b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTitle.tsx index cf7455d5cb30..30dc737041f4 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTitle.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentHeaderTitle.tsx @@ -1,9 +1,11 @@ import React, {ReactElement} from 'react' import {useDocumentPane} from '../../useDocumentPane' +import {useFormState} from 'sanity/document' import {unstable_useValuePreview as useValuePreview} from 'sanity' export function DocumentHeaderTitle(): ReactElement { - const {connectionState, schemaType, title, value: documentValue} = useDocumentPane() + const {value: documentValue, schemaType, connectionState} = useFormState() + const {title} = useDocumentPane() const subscribed = Boolean(documentValue) && connectionState === 'connected' const {error, value} = useValuePreview({ diff --git a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentPanelHeader.tsx b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentPanelHeader.tsx index b8a8efa4169a..700fd25e2abd 100644 --- a/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentPanelHeader.tsx +++ b/packages/sanity/src/desk/panes/document/documentPanel/header/DocumentPanelHeader.tsx @@ -14,7 +14,8 @@ import {isMenuNodeButton, isNotMenuNodeButton, resolveMenuNodes} from '../../../ import {useDeskTool} from '../../../../useDeskTool' import {DocumentHeaderTabs} from './DocumentHeaderTabs' import {DocumentHeaderTitle} from './DocumentHeaderTitle' -import {useFieldActions, useTimelineSelector} from 'sanity' +import {useFormState, useTimelineSelector} from 'sanity/document' +import {useFieldActions} from 'sanity' // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DocumentPanelHeaderProps {} @@ -24,15 +25,13 @@ export const DocumentPanelHeader = memo( _props: DocumentPanelHeaderProps, ref: React.ForwardedRef, ) { + const {ready, schemaType} = useFormState() const { onMenuAction, onPaneClose, onPaneSplit, menuItems, menuItemGroups, - schemaType, - timelineStore, - ready, views, unstable_languageFilter, } = useDocumentPane() @@ -49,7 +48,7 @@ export const DocumentPanelHeader = memo( const showTabs = views.length > 1 // Subscribe to external timeline state changes - const rev = useTimelineSelector(timelineStore, (state) => state.revTime) + const rev = useTimelineSelector((state) => state.revTime) const {collapsed, isLast} = usePane() // Prevent focus if this is the last (non-collapsed) pane. diff --git a/packages/sanity/src/desk/panes/document/getInitialValueTemplateOpts.ts b/packages/sanity/src/desk/panes/document/getInitialValueTemplateOpts.ts deleted file mode 100644 index 67843c254496..000000000000 --- a/packages/sanity/src/desk/panes/document/getInitialValueTemplateOpts.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {Template} from 'sanity' - -interface InitialValueOptions { - documentType: string - panePayload?: unknown - templateName?: string - templateParams?: Record - urlTemplate?: string -} - -/** - * @internal - */ -export function getInitialValueTemplateOpts( - templates: Template[], - opts: InitialValueOptions, -): {templateName: string; templateParams: Record} { - const payload = opts.panePayload || {} - const structureNodeTemplate = opts.templateName - - if (opts.urlTemplate && structureNodeTemplate && structureNodeTemplate !== opts.urlTemplate) { - // eslint-disable-next-line no-console - console.warn( - `Conflicting templates: URL says "${opts.urlTemplate}", structure node says "${structureNodeTemplate}". Using "${structureNodeTemplate}".`, - ) - } - - const template = structureNodeTemplate || opts.urlTemplate - const typeTemplates = templates.filter((t) => t.schemaType === opts.documentType) - - const templateParams = { - ...opts.templateParams, - ...(typeof payload === 'object' ? payload || {} : {}), - } - - let templateName = template - - // If we have not specified a specific template, and we only have a single - // template available for a schema type, use it - if (!template && typeTemplates.length === 1) { - templateName = typeTemplates[0].id - } - - return {templateName: templateName!, templateParams} -} diff --git a/packages/sanity/src/desk/panes/document/inspectors/changes/ChangesInspector.tsx b/packages/sanity/src/desk/panes/document/inspectors/changes/ChangesInspector.tsx index 3284ca810080..d8b8c73a5075 100644 --- a/packages/sanity/src/desk/panes/document/inspectors/changes/ChangesInspector.tsx +++ b/packages/sanity/src/desk/panes/document/inspectors/changes/ChangesInspector.tsx @@ -3,10 +3,10 @@ import {AvatarStack, BoundaryElementProvider, Box, Card, Flex} from '@sanity/ui' import React, {ReactElement, useRef} from 'react' import styled from 'styled-components' import {TimelineMenu} from '../../timeline' -import {useDocumentPane} from '../../useDocumentPane' import {DocumentInspectorHeader} from '../../documentInspector' import {LoadingContent} from './LoadingContent' import {collectLatestAuthorAnnotations} from './helpers' +import {useFormState, useTimelineSelector} from 'sanity/document' import { ChangeFieldWrapper, ChangeList, @@ -18,7 +18,6 @@ import { ObjectSchemaType, ScrollContainer, UserAvatar, - useTimelineSelector, } from 'sanity' const Scroller = styled(ScrollContainer)` @@ -30,16 +29,16 @@ const Scroller = styled(ScrollContainer)` export function ChangesInspector(props: DocumentInspectorProps): ReactElement { const {onClose} = props - const {documentId, schemaType, timelineError, timelineStore, value} = useDocumentPane() + const {documentId, schemaType, value} = useFormState() const scrollRef = useRef(null) // Subscribe to external timeline state changes - const diff = useTimelineSelector(timelineStore, (state) => state.diff) - const onOlderRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) - const selectionState = useTimelineSelector(timelineStore, (state) => state.selectionState) - const sinceTime = useTimelineSelector(timelineStore, (state) => state.sinceTime) - const loading = selectionState === 'loading' - const isComparingCurrent = !onOlderRevision + const diff = useTimelineSelector((state) => state.diff) + const {loading, sinceTime, isComparingCurrent} = useTimelineSelector((state) => ({ + isComparingCurrent: !state.onOlderRevision, + sinceTime: state.sinceTime, + loading: state.selectionState === 'loading', + })) const documentContext: DocumentChangeContextInstance = React.useMemo( () => ({ @@ -91,7 +90,6 @@ export function ChangesInspector(props: DocumentInspectorProps): ReactElement { diff --git a/packages/sanity/src/desk/panes/document/inspectors/validation/ValidationInspector.tsx b/packages/sanity/src/desk/panes/document/inspectors/validation/ValidationInspector.tsx index 7b42884c308b..18d721cc2fdc 100644 --- a/packages/sanity/src/desk/panes/document/inspectors/validation/ValidationInspector.tsx +++ b/packages/sanity/src/desk/panes/document/inspectors/validation/ValidationInspector.tsx @@ -2,9 +2,9 @@ import {ErrorOutlineIcon, IconComponent, InfoOutlineIcon, WarningOutlineIcon} fr import {Box, Card, CardTone, ErrorBoundary, Flex, Stack, Text} from '@sanity/ui' import {ObjectSchemaType, Path, SanityDocument, SchemaType, ValidationMarker} from '@sanity/types' import React, {ErrorInfo, Fragment, createElement, useCallback, useMemo, useState} from 'react' -import {useDocumentPane} from '../../useDocumentPane' import {DocumentInspectorHeader} from '../../documentInspector' import {getPathTypes} from './getPathTypes' +import {useFormState} from 'sanity/document' import {DocumentInspectorProps, pathToString} from 'sanity' const MARKER_ICON: Record<'error' | 'warning' | 'info', IconComponent> = { @@ -21,14 +21,14 @@ const MARKER_TONE: Record<'error' | 'warning' | 'info', CardTone> = { export function ValidationInspector(props: DocumentInspectorProps) { const {onClose} = props - const {onFocus, onPathOpen, schemaType, validation, value} = useDocumentPane() + const {schemaType, value, setFocusPath, setOpenPath, validation} = useFormState() const handleOpen = useCallback( (path: Path) => { - onPathOpen(path) - onFocus(path) + setOpenPath(path) + setFocusPath(path) }, - [onFocus, onPathOpen], + [setFocusPath, setOpenPath], ) return ( diff --git a/packages/sanity/src/desk/panes/document/keyboardShortcuts/DocumentActionShortcuts.tsx b/packages/sanity/src/desk/panes/document/keyboardShortcuts/DocumentActionShortcuts.tsx index c9efd7df466c..6d961e02e2b4 100644 --- a/packages/sanity/src/desk/panes/document/keyboardShortcuts/DocumentActionShortcuts.tsx +++ b/packages/sanity/src/desk/panes/document/keyboardShortcuts/DocumentActionShortcuts.tsx @@ -1,9 +1,15 @@ import isHotkey from 'is-hotkey' import React, {ElementType, createElement, useCallback, useMemo, useState} from 'react' +import {useDocumentId, useDocumentType} from 'sanity/document' import {ActionStateDialog} from '../statusBar' import {RenderActionCollectionState} from '../../../components' import {useDocumentPane} from '../useDocumentPane' -import {DocumentActionDescription, DocumentActionProps, LegacyLayerProvider} from 'sanity' +import { + DocumentActionDescription, + DocumentActionProps, + LegacyLayerProvider, + useEditState, +} from 'sanity' export interface KeyboardShortcutResponderProps { actionsBoxElement: HTMLElement | null @@ -99,7 +105,10 @@ export interface DocumentActionShortcutsProps { export const DocumentActionShortcuts = React.memo( (props: DocumentActionShortcutsProps & Omit, 'as'>) => { const {actionsBoxElement, as = 'div', children, ...rest} = props - const {actions, editState} = useDocumentPane() + const {actions} = useDocumentPane() + const documentId = useDocumentId() + const documentType = useDocumentType() + const editState = useEditState(documentId, documentType) const [activeIndex, setActiveIndex] = useState(-1) const onActionStart = useCallback((idx: number) => { diff --git a/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBar.tsx b/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBar.tsx index 1c0ce351dbf9..a6743367772a 100644 --- a/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBar.tsx +++ b/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBar.tsx @@ -1,10 +1,10 @@ import React, {useMemo} from 'react' import styled from 'styled-components' import {Box, Flex} from '@sanity/ui' +import {useTimelineSelector} from 'sanity/document' import {useDocumentPane} from '../useDocumentPane' import {DocumentStatusBarActions, HistoryStatusBarActions} from './DocumentStatusBarActions' import {DocumentSparkline} from './sparkline/DocumentSparkline' -import {useTimelineSelector} from 'sanity' export interface DocumentStatusBarProps { actionsBoxRef?: React.Ref @@ -17,10 +17,10 @@ const DocumentActionsBox = styled(Box)` export function DocumentStatusBar(props: DocumentStatusBarProps) { const {actionsBoxRef} = props - const {badges, timelineStore} = useDocumentPane() + const {badges} = useDocumentPane() // Subscribe to external timeline state changes - const showingRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) + const showingRevision = useTimelineSelector((state) => state.onOlderRevision) return useMemo( () => ( diff --git a/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBarActions.tsx b/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBarActions.tsx index a377ba616364..0075321790aa 100644 --- a/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBarActions.tsx +++ b/packages/sanity/src/desk/panes/document/statusBar/DocumentStatusBarActions.tsx @@ -1,11 +1,12 @@ import {Box, Flex, Tooltip, Stack, Button, Hotkeys, LayerProvider, Text} from '@sanity/ui' import React, {memo, useMemo, useState} from 'react' +import {useFormState, useTimelineSelector} from 'sanity/document' import {RenderActionCollectionState} from '../../../components' import {HistoryRestoreAction} from '../../../documentActions' import {useDocumentPane} from '../useDocumentPane' import {ActionMenuButton} from './ActionMenuButton' import {ActionStateDialog} from './ActionStateDialog' -import {DocumentActionDescription, useTimelineSelector} from 'sanity' +import {DocumentActionDescription} from 'sanity' interface DocumentStatusBarActionsInnerProps { disabled: boolean @@ -72,7 +73,8 @@ function DocumentStatusBarActionsInner(props: DocumentStatusBarActionsInnerProps } export const DocumentStatusBarActions = memo(function DocumentStatusBarActions() { - const {actions, connectionState, documentId, editState} = useDocumentPane() + const {connectionState, documentId, editState} = useFormState() + const {actions} = useDocumentPane() // const [isMenuOpen, setMenuOpen] = useState(false) // const handleMenuOpen = useCallback(() => setMenuOpen(true), []) // const handleMenuClose = useCallback(() => setMenuOpen(false), []) @@ -107,10 +109,10 @@ export const DocumentStatusBarActions = memo(function DocumentStatusBarActions() }) export const HistoryStatusBarActions = memo(function HistoryStatusBarActions() { - const {connectionState, editState, timelineStore} = useDocumentPane() + const {editState, connectionState} = useFormState() // Subscribe to external timeline state changes - const revTime = useTimelineSelector(timelineStore, (state) => state.revTime) + const revTime = useTimelineSelector((state) => state.revTime) const revision = revTime?.id || '' const disabled = (editState?.draft || editState?.published || {})._rev === revision diff --git a/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentBadges.tsx b/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentBadges.tsx index afa55d9262f7..4962d7e1a897 100644 --- a/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentBadges.tsx +++ b/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentBadges.tsx @@ -1,5 +1,6 @@ import {Badge, BadgeTone, Box, Inline, Text, Tooltip} from '@sanity/ui' import React from 'react' +import {useFormState} from 'sanity/document' import {RenderBadgeCollectionState} from '../../../../components' import {useDocumentPane} from '../../useDocumentPane' import {DocumentBadgeDescription} from 'sanity' @@ -50,7 +51,8 @@ function DocumentBadgesInner({states}: DocumentBadgesInnerProps) { } export function DocumentBadges() { - const {badges, editState} = useDocumentPane() + const {editState} = useFormState() + const {badges} = useDocumentPane() if (!editState || !badges) return null diff --git a/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentSparkline.tsx b/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentSparkline.tsx index d1aed702d991..b87792e2c2f1 100644 --- a/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentSparkline.tsx +++ b/packages/sanity/src/desk/panes/document/statusBar/sparkline/DocumentSparkline.tsx @@ -1,25 +1,20 @@ import {Box, Flex, useElementRect} from '@sanity/ui' import React, {useEffect, useMemo, useState, memo, useLayoutEffect} from 'react' +import {useDocumentId, useDocumentType, useFormState, useTimelineSelector} from 'sanity/document' import {useDocumentPane} from '../../useDocumentPane' import {DocumentBadges} from './DocumentBadges' import {PublishStatus} from './PublishStatus' import {ReviewChangesButton} from './ReviewChangesButton' -import {useSyncState, useTimelineSelector} from 'sanity' +import {useSyncState} from 'sanity' const SYNCING_TIMEOUT = 1000 const SAVED_TIMEOUT = 3000 export const DocumentSparkline = memo(function DocumentSparkline() { - const { - changesOpen, - documentId, - documentType, - editState, - onHistoryClose, - onHistoryOpen, - timelineStore, - value, - } = useDocumentPane() + const documentId = useDocumentId() + const documentType = useDocumentType() + const {value, editState} = useFormState() + const {changesOpen, onHistoryClose, onHistoryOpen} = useDocumentPane() const syncState = useSyncState(documentId, documentType) const lastUpdated = value?._updatedAt @@ -35,7 +30,7 @@ export const DocumentSparkline = memo(function DocumentSparkline() { const [status, setStatus] = useState<'saved' | 'syncing' | null>(null) // Subscribe to TimelineController changes and store internal state. - const showingRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) + const showingRevision = useTimelineSelector((state) => state.onOlderRevision) // eslint-disable-next-line consistent-return useEffect(() => { diff --git a/packages/sanity/src/desk/panes/document/timeline/TimelineError.tsx b/packages/sanity/src/desk/panes/document/timeline/TimelineError.tsx deleted file mode 100644 index 59d77530b1e1..000000000000 --- a/packages/sanity/src/desk/panes/document/timeline/TimelineError.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {ErrorOutlineIcon} from '@sanity/icons' -import {Flex, Stack} from '@sanity/ui' -import React from 'react' -import {TextWithTone} from 'sanity' - -export function TimelineError() { - return ( - - - - - - - An error occurred whilst retrieving document changes. - - - Document history transactions have not been affected. - - - - ) -} diff --git a/packages/sanity/src/desk/panes/document/timeline/TimelineErrorPane.tsx b/packages/sanity/src/desk/panes/document/timeline/TimelineErrorPane.tsx new file mode 100644 index 000000000000..0acf1cc034a1 --- /dev/null +++ b/packages/sanity/src/desk/panes/document/timeline/TimelineErrorPane.tsx @@ -0,0 +1,32 @@ +import {RetryIcon} from '@sanity/icons' +import {Button, Flex, Stack} from '@sanity/ui' +import React from 'react' +import {Pane, PaneContent} from '../../../components' +import {TextWithTone} from 'sanity' + +interface TimelineErrorPaneProps { + paneKey: string + flex?: number + minWidth?: number + onRetry: () => void +} + +export function TimelineErrorPane({paneKey, flex, minWidth, onRetry}: TimelineErrorPaneProps) { + return ( + + + + + + An error occurred whilst retrieving document changes. + + + Document history transactions have not been affected. + +