Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Document Provider #4938

Draft
wants to merge 1 commit into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/sanity/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
/cli.js
/desk.js
/router.js
/document.js

# Playwright-ct artifacts
/playwright-ct/report
Expand Down
1 change: 1 addition & 0 deletions packages/sanity/exports/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../src/core/document'
11 changes: 11 additions & 0 deletions packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/sanity/src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export interface Tool<Options = any> {
options?: Options

/**
* The router for the tool. See {@link router.Router}
* The router for the tool. See {@link Router}
*/
router?: Router

Expand Down
10 changes: 10 additions & 0 deletions packages/sanity/src/core/document/DocumentContextError.ts
Original file line number Diff line number Diff line change
@@ -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
* `<DocumentProvider />`.
*/
export class DocumentContextError extends Error {
constructor() {
super('Could not find context value. Did you wrap this component in a <DocumentProvider />?')
}
}
24 changes: 24 additions & 0 deletions packages/sanity/src/core/document/DocumentIdAndTypeContext.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentIdAndTypeContextValue | null>(null)
96 changes: 96 additions & 0 deletions packages/sanity/src/core/document/DocumentIdAndTypeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<Error | null>(null)
if (error) throw error

// generate a lookup object for templates by their IDs
const templatesById = useMemo(() => {
return templates.reduce<Record<string, Template>>((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<string | null>(() => {
// 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 (
<DocumentIdAndTypeContext.Provider value={contextValue}>
{children}
</DocumentIdAndTypeContext.Provider>
)
}
69 changes: 69 additions & 0 deletions packages/sanity/src/core/document/DocumentProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DocumentIdAndTypeProvider
documentId={documentId}
documentType={documentType}
templateName={templateName}
fallback={fallback}
>
<TimelineProvider timelineRange={timelineRange} onTimelineRangeChange={onTimelineRangeChange}>
<InitialValueProvider
templateName={templateName}
templateParams={templateParams}
fallback={fallback}
>
<ReferenceInputOptionsProvider
EditReferenceLinkComponent={EditReferenceLinkComponent}
onEditReference={onEditReference}
activePath={activePath}
fallback={fallback}
>
<FormStateProvider
initialFocusPath={initialFocusPath}
isHistoryInspectorOpen={isHistoryInspectorOpen}
>
{children}
</FormStateProvider>
</ReferenceInputOptionsProvider>
</InitialValueProvider>
</TimelineProvider>
</DocumentIdAndTypeProvider>
)
}
83 changes: 83 additions & 0 deletions packages/sanity/src/core/document/formState/FormStateContext.ts
Original file line number Diff line number Diff line change
@@ -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<TDocument extends SanityDocumentLike> {
documentId: string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably document whether this is the draft id or published id

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the opportunity to rename EditStateFor => DocumentEditState ?


/**
* 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current-value isn't valid for valueOrigin?

* 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would there be cases where you need a combination of several of these? I think it's great that we only have one value to refer to as the document currently being edited, but wondering if it will make it hard to know e.g. whether there exists a published version of the draft currently being edited.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to document what cases valueOrigin may be 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "edit states" refer to here? IIRC, the edit state is already a downstream dependency of connection state. Also wondering whether "timeline ready" state should be kept separate (e.g. we don't need the timeline to be ready in order to edit I believe)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bjoerge and i were talking about this in a meeting and we feel like ready does not communicate exactly what is ready and we may rename this one.

*/
ready: boolean

/**
* Propagates changes described by a patch event message to the form value.
*/
patchValue: (event: PatchEvent) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onPatchValue or simply onPatch to match already established conventions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bjoerge and I discussed this in a call as well and we think we like the convention handle- instead of on to signify that the caller can call this function instead of having to provide an implementation


/**
* Contains the prepared root form node state. This is the result of
* `prepareFormState`.
*/
formState: DocumentFormNode | null

focusPath: Path
setFocusPath: (path: Path) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onPathFocus for consistency with existing APIs


openPath: Path
setOpenPath: (path: Path) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onPathOpen for consistency with existing APIs

Copy link
Contributor Author

@ricokahler ricokahler Sep 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-note for future reference: on <subject> <verb>


collapsedFieldsets: StateTree<boolean>
setFieldsetCollapsed: (path: Path, collapsed: boolean) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onSetFieldsetCollapsed for consistency with existing APIs


collapsedPaths: StateTree<boolean>
setPathCollapsed: (path: Path, collapsed: boolean) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onSetPathCollapsed for consistency with existing APIs


activeFieldGroups: StateTree<string>
setActiveFieldGroup: (path: Path, groupName: string) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named onSetActiveFieldGroup for consistency with existing APIs


validation: ValidationMarker[]
permissions: PermissionCheckResult | undefined
isPermissionsLoading: boolean

connectionState: ConnectionState

delete: () => void
isDeleting: boolean
isDeleted: boolean
}

export const FormStateContext = createContext<FormStateContextValue<SanityDocumentLike> | null>(
null,
)
Loading