-
Notifications
You must be signed in to change notification settings - Fork 450
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
base: next
Are you sure you want to change the base?
[WIP] Document Provider #4938
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,6 +16,7 @@ | |
/cli.js | ||
/desk.js | ||
/router.js | ||
/document.js | ||
|
||
# Playwright-ct artifacts | ||
/playwright-ct/report | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from '../src/core/document' |
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 />?') | ||
} | ||
} |
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) |
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> | ||
) | ||
} |
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> | ||
) | ||
} |
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 | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we use the opportunity to rename |
||
|
||
/** | ||
* 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
* 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be good to document what cases valueOrigin may be |
||
|
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: boolean | ||
|
||
/** | ||
* Propagates changes described by a patch event message to the form value. | ||
*/ | ||
patchValue: (event: PatchEvent) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
/** | ||
* Contains the prepared root form node state. This is the result of | ||
* `prepareFormState`. | ||
*/ | ||
formState: DocumentFormNode | null | ||
|
||
focusPath: Path | ||
setFocusPath: (path: Path) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named |
||
|
||
openPath: Path | ||
setOpenPath: (path: Path) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. self-note for future reference: |
||
|
||
collapsedFieldsets: StateTree<boolean> | ||
setFieldsetCollapsed: (path: Path, collapsed: boolean) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named |
||
|
||
collapsedPaths: StateTree<boolean> | ||
setPathCollapsed: (path: Path, collapsed: boolean) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named |
||
|
||
activeFieldGroups: StateTree<string> | ||
setActiveFieldGroup: (path: Path, groupName: string) => void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be named |
||
|
||
validation: ValidationMarker[] | ||
permissions: PermissionCheckResult | undefined | ||
isPermissionsLoading: boolean | ||
|
||
connectionState: ConnectionState | ||
|
||
delete: () => void | ||
isDeleting: boolean | ||
isDeleted: boolean | ||
} | ||
|
||
export const FormStateContext = createContext<FormStateContextValue<SanityDocumentLike> | null>( | ||
null, | ||
) |
There was a problem hiding this comment.
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