From c1fbfb3ca45120ea1c00d446d0b2933a234daa5c Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 11 Nov 2024 15:06:38 +0100 Subject: [PATCH 01/53] feat(core): add beta events api flag --- dev/test-studio/sanity.config.ts | 5 +++++ .../src/core/config/configPropertyReducers.ts | 20 +++++++++++++++++++ .../sanity/src/core/config/prepareConfig.ts | 4 ++++ packages/sanity/src/core/config/types.ts | 3 +++ 4 files changed, 32 insertions(+) diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 6e27f1d221d..86149e987b4 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -254,6 +254,11 @@ export default defineConfig([ unstable_tasks: { enabled: true, }, + beta: { + eventsAPI: { + enabled: true, + }, + }, }, { name: 'custom-components', diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index ce9c2d888cf..24184997784 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -364,6 +364,26 @@ export const internalTasksReducer = (opts: { return result } +export const eventsAPIReducer = (opts: {config: PluginOptions; initialValue: boolean}): boolean => { + const {config, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + const result = flattenedConfig.reduce((acc: boolean, {config: innerConfig}) => { + const enabled = innerConfig.beta?.eventsAPI?.enabled + + if (!enabled) return acc + if (typeof enabled === 'boolean') return enabled + + throw new Error( + `Expected \`__internal__tasks\` to be an object with footerAction, but received ${getPrintableType( + enabled, + )}`, + ) + }, initialValue) + + return result +} + export const partialIndexingEnabledReducer = (opts: { config: PluginOptions initialValue: boolean diff --git a/packages/sanity/src/core/config/prepareConfig.ts b/packages/sanity/src/core/config/prepareConfig.ts index 7b771f7596c..87caa731f31 100644 --- a/packages/sanity/src/core/config/prepareConfig.ts +++ b/packages/sanity/src/core/config/prepareConfig.ts @@ -32,6 +32,7 @@ import { documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, + eventsAPIReducer, fileAssetSourceResolver, imageAssetSourceResolver, initialDocumentActions, @@ -655,6 +656,9 @@ function resolveSource({ }, beta: { + eventsAPI: { + enabled: eventsAPIReducer({config, initialValue: false}), + }, treeArrayEditing: { // This beta feature is no longer available. enabled: false, diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 0f811f37cd4..209ec3ffdb9 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -1007,4 +1007,7 @@ export interface BetaFeatures { */ fallbackStudioOrigin?: string } + eventsAPI?: { + enabled: boolean + } } From 7f71d3ae59c50864b0d54ec06c14e5ad40ce626e Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 11 Nov 2024 15:42:36 +0100 Subject: [PATCH 02/53] chore(structure): insert timeline through history provider, add support for future flag --- dev/test-studio/sanity.config.ts | 5 ++ .../src/_singletons/context/HistoryContext.ts | 12 ++++ packages/sanity/src/_singletons/index.ts | 1 + .../structure/panes/document/DocumentPane.tsx | 41 ++++++------ .../panes/document/DocumentPaneProvider.tsx | 19 +----- .../panes/document/HistoryProvider.tsx | 65 +++++++++++++++++++ 6 files changed, 107 insertions(+), 36 deletions(-) create mode 100644 packages/sanity/src/_singletons/context/HistoryContext.ts create mode 100644 packages/sanity/src/structure/panes/document/HistoryProvider.tsx diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 86149e987b4..cc97ecf999e 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -220,6 +220,11 @@ export default defineConfig([ dataset: 'playground', plugins: [sharedSettings()], basePath: '/playground', + beta: { + eventsAPI: { + enabled: true, + }, + }, }, { name: 'listener-events', diff --git a/packages/sanity/src/_singletons/context/HistoryContext.ts b/packages/sanity/src/_singletons/context/HistoryContext.ts new file mode 100644 index 00000000000..2ba43e38e87 --- /dev/null +++ b/packages/sanity/src/_singletons/context/HistoryContext.ts @@ -0,0 +1,12 @@ +import {createContext} from 'sanity/_createContext' + +import type {TimelineStore} from '../../core/store/_legacy/history/useTimelineStore' + +export interface HistoryContextValue { + store: TimelineStore + error: Error | null +} +export const HistoryContext = createContext( + 'sanity/_singletons/context/history', + null, +) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index fbcb629d610..7b505431ee2 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -28,6 +28,7 @@ export * from './context/FormFieldPresenceContext' export * from './context/FormValueContext' export * from './context/FreeTrialContext' export * from './context/GetFormValueContext' +export * from './context/HistoryContext' export * from './context/HoveredFieldContext' export * from './context/IsLastPaneContext' export * from './context/LocaleContext' diff --git a/packages/sanity/src/structure/panes/document/DocumentPane.tsx b/packages/sanity/src/structure/panes/document/DocumentPane.tsx index 2c8dff0d6ee..67abc71bc81 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPane.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPane.tsx @@ -23,6 +23,7 @@ import {LoadingPane} from '../loading' import {CommentsWrapper} from './comments' import {useDocumentLayoutComponent} from './document-layout' import {DocumentPaneProvider} from './DocumentPaneProvider' +import {HistoryProvider} from './HistoryProvider' import {type DocumentPaneProviderProps} from './types' type DocumentPaneOptions = DocumentPaneNode['options'] @@ -129,26 +130,28 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) { } 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 */} - + - - - - - + {/* 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 */} + + + + + + + ) } diff --git a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx index c5b010691d6..7a845f4a5fb 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx @@ -46,7 +46,6 @@ import { useSource, useTemplates, useTimelineSelector, - useTimelineStore, useTranslation, useUnique, useValidationStatus, @@ -66,6 +65,7 @@ import { } from './constants' import {type DocumentPaneContextValue} from './DocumentPaneContext' import {getInitialValueTemplateOpts} from './getInitialValueTemplateOpts' +import {useHistory} from './HistoryProvider' import {type DocumentPaneProviderProps} from './types' import {usePreviewUrl} from './usePreviewUrl' @@ -224,22 +224,7 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { 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, - version: selectedReleaseId, - }) - + const {store: timelineStore, error: timelineError} = useHistory() // Subscribe to external timeline state changes const onOlderRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) const revTime = useTimelineSelector(timelineStore, (state) => state.revTime) diff --git a/packages/sanity/src/structure/panes/document/HistoryProvider.tsx b/packages/sanity/src/structure/panes/document/HistoryProvider.tsx new file mode 100644 index 00000000000..a22c43a4e14 --- /dev/null +++ b/packages/sanity/src/structure/panes/document/HistoryProvider.tsx @@ -0,0 +1,65 @@ +import {useContext, useState} from 'react' +import { + getPublishedId, + resolveBundlePerspective, + usePerspective, + useSource, + useTimelineStore, +} from 'sanity' +import {HistoryContext, type HistoryContextValue} from 'sanity/_singletons' +import {usePaneRouter} from 'sanity/structure' + +import {EMPTY_PARAMS} from './constants' + +interface LegacyStoreProviderProps { + children: React.ReactNode + documentId: string + documentType: string +} +function LegacyStoreProvider({children, documentId, documentType}: LegacyStoreProviderProps) { + const paneRouter = usePaneRouter() + const {perspective} = usePerspective() + const bundlePerspective = resolveBundlePerspective(perspective) + + const params = paneRouter.params || EMPTY_PARAMS + + 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: getPublishedId(documentId), + documentType, + onError: setTimelineError, + rev: params.rev, + since: params.since, + version: bundlePerspective, + }) + + return ( + + {children} + + ) +} + +function EventsStoreProvider(props: LegacyStoreProviderProps) { + return +} +export function HistoryProvider(props: LegacyStoreProviderProps) { + const source = useSource() + if (source.beta?.eventsAPI?.enabled) { + return + } + return +} +export function useHistory(): HistoryContextValue { + const context = useContext(HistoryContext) + if (context === null) { + throw new Error('useHistory must be used within a HistoryProvider') + } + return context +} From a46511c99cdf1459c80e0c9b59d098042dd70707 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 18 Nov 2024 17:16:34 +0100 Subject: [PATCH 03/53] feat(core): wip implementation for events in publish view --- .eslintrc.cjs | 1 + dev/test-studio/schema/author.ts | 16 +- .../src/_singletons/context/EventsContext.ts | 8 + .../src/_singletons/context/HistoryContext.ts | 2 + packages/sanity/src/_singletons/index.ts | 1 + .../field/diff/components/DiffTooltip.tsx | 56 +-- .../diff/contexts/DocumentChangeContext.tsx | 3 + packages/sanity/src/core/field/types.ts | 4 +- .../releases/util/getReleaseIdFromName.ts | 5 + .../_legacy/history/history/diffValue.ts | 52 +-- .../store/_legacy/history/history/utils.ts | 2 +- .../core/store/events/getDocumentChanges.ts | 129 +++++++ .../sanity/src/core/store/events/index.ts | 3 + .../src/core/store/events/reconstruct.ts | 126 +++++++ .../sanity/src/core/store/events/types.ts | 324 ++++++++++++++++++ .../src/core/store/events/useEventsStore.ts | 243 +++++++++++++ packages/sanity/src/core/store/index.ts | 1 + .../panes/document/DocumentPaneProvider.tsx | 29 +- .../panes/document/HistoryProvider.tsx | 55 ++- .../header/DocumentPanelHeader.tsx | 13 - .../inspectors/changes/ChangesTabs.tsx | 22 +- .../inspectors/changes/EventsInspector.tsx | 183 ++++++++++ .../inspectors/changes/EventsSelector.tsx | 98 ++++++ .../panes/document/timeline/events/Event.tsx | 191 +++++++++++ .../timeline/events/EventTimelineItem.tsx | 53 +++ .../timeline/events/EventsTimeline.tsx | 135 ++++++++ .../timeline/events/EventsTimelineMenue.tsx | 222 ++++++++++++ .../timeline/events/PublishedEventMenu.tsx | 100 ++++++ .../timeline/events/VersionInlineBadge.tsx | 14 + .../document/timeline/events/constants.ts | 56 +++ 30 files changed, 2061 insertions(+), 86 deletions(-) create mode 100644 packages/sanity/src/_singletons/context/EventsContext.ts create mode 100644 packages/sanity/src/core/releases/util/getReleaseIdFromName.ts create mode 100644 packages/sanity/src/core/store/events/getDocumentChanges.ts create mode 100644 packages/sanity/src/core/store/events/index.ts create mode 100644 packages/sanity/src/core/store/events/reconstruct.ts create mode 100644 packages/sanity/src/core/store/events/types.ts create mode 100644 packages/sanity/src/core/store/events/useEventsStore.ts create mode 100644 packages/sanity/src/structure/panes/document/inspectors/changes/EventsInspector.tsx create mode 100644 packages/sanity/src/structure/panes/document/inspectors/changes/EventsSelector.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/Event.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/EventTimelineItem.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/EventsTimeline.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/EventsTimelineMenue.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/PublishedEventMenu.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/VersionInlineBadge.tsx create mode 100644 packages/sanity/src/structure/panes/document/timeline/events/constants.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1ebfa915f43..ae46d4acec1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -132,6 +132,7 @@ const config = { 'status', 'group', 'textWeight', + 'showChangesBy', ], }, }, diff --git a/dev/test-studio/schema/author.ts b/dev/test-studio/schema/author.ts index e84fe5271c2..6cdf331a614 100644 --- a/dev/test-studio/schema/author.ts +++ b/dev/test-studio/schema/author.ts @@ -117,13 +117,13 @@ export default defineType({ initialValue: () => ({ name: 'Foo', - bestFriend: {_type: 'reference', _ref: 'foo-bar'}, - image: { - _type: 'image', - asset: { - _ref: 'image-8dcc1391e06e4b4acbdc6bbf2e8c8588d537cbb8-4896x3264-jpg', - _type: 'reference', - }, - }, + // bestFriend: {_type: 'reference', _ref: 'foo-bar'}, + // image: { + // _type: 'image', + // asset: { + // _ref: 'image-8dcc1391e06e4b4acbdc6bbf2e8c8588d537cbb8-4896x3264-jpg', + // _type: 'reference', + // }, + // }, }), }) diff --git a/packages/sanity/src/_singletons/context/EventsContext.ts b/packages/sanity/src/_singletons/context/EventsContext.ts new file mode 100644 index 00000000000..49dc1023aa3 --- /dev/null +++ b/packages/sanity/src/_singletons/context/EventsContext.ts @@ -0,0 +1,8 @@ +import {createContext} from 'sanity/_createContext' + +import type {EventsStore} from '../../core/store/events/types' + +export const EventsContext = createContext( + 'sanity/_singletons/context/events', + null, +) diff --git a/packages/sanity/src/_singletons/context/HistoryContext.ts b/packages/sanity/src/_singletons/context/HistoryContext.ts index 2ba43e38e87..c45bfb2f792 100644 --- a/packages/sanity/src/_singletons/context/HistoryContext.ts +++ b/packages/sanity/src/_singletons/context/HistoryContext.ts @@ -1,10 +1,12 @@ import {createContext} from 'sanity/_createContext' import type {TimelineStore} from '../../core/store/_legacy/history/useTimelineStore' +import type {EventsStore} from '../../core/store/events/types' export interface HistoryContextValue { store: TimelineStore error: Error | null + eventsStore?: EventsStore } export const HistoryContext = createContext( 'sanity/_singletons/context/history', diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 7b505431ee2..83837177369 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -21,6 +21,7 @@ export * from './context/DocumentFieldActionsContext' export * from './context/DocumentIdContext' export * from './context/DocumentPaneContext' export * from './context/DocumentSheetListContext' +export * from './context/EventsContext' export * from './context/FieldActionsContext' export * from './context/FormBuilderContext' export * from './context/FormCallbacksContext' diff --git a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx index 084e5217f97..2c3c7af147f 100644 --- a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx +++ b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx @@ -1,7 +1,8 @@ import {type Path} from '@sanity/types' -import {Flex, Inline, Stack, Text} from '@sanity/ui' +import {Card, Flex, Inline, Stack, Text} from '@sanity/ui' import {type ReactElement, type ReactNode} from 'react' +import {Event} from '../../../../structure/panes/document/timeline/events/Event' import {Tooltip, type TooltipProps} from '../../../../ui-components' import {LegacyLayerProvider, UserAvatar} from '../../../components' import {useRelativeTime} from '../../../hooks' @@ -46,7 +47,7 @@ function DiffTooltipWithAnnotation(props: DiffTooltipWithAnnotationsProps) { } const content = ( - + {description || t('changes.changed-label')} @@ -75,26 +76,39 @@ function AnnotationItem({annotation}: {annotation: AnnotationDetails}) { const {t} = useTranslation() return ( - - - - - - {user ? user.displayName : t('changes.loading-author')} + <> + {annotation.event ? ( + <> + + + + ) : ( + + + + + + {user ? user.displayName : t('changes.loading-author')} + + + + + {timeAgo} - - - {timeAgo} - - + )} + ) } diff --git a/packages/sanity/src/core/field/diff/contexts/DocumentChangeContext.tsx b/packages/sanity/src/core/field/diff/contexts/DocumentChangeContext.tsx index 507ab0bd06e..1f3e9ee73fd 100644 --- a/packages/sanity/src/core/field/diff/contexts/DocumentChangeContext.tsx +++ b/packages/sanity/src/core/field/diff/contexts/DocumentChangeContext.tsx @@ -8,6 +8,9 @@ export type DocumentChangeContextInstance = { documentId: string schemaType: SchemaType rootDiff: ObjectDiff | null + /** + * This decides if the diff shows the reverting changes button or not. + */ isComparingCurrent: boolean FieldWrapper: ComponentType<{path: Path; children: ReactNode; hasHover: boolean}> value: Partial diff --git a/packages/sanity/src/core/field/types.ts b/packages/sanity/src/core/field/types.ts index a281913b163..01f1ba0314c 100644 --- a/packages/sanity/src/core/field/types.ts +++ b/packages/sanity/src/core/field/types.ts @@ -25,6 +25,7 @@ import { } from '@sanity/types' import {type ComponentType} from 'react' +import {type DocumentGroupEvent} from '../store/events' import {type FieldValueError} from './validation' /** @@ -69,7 +70,8 @@ export type Chunk = { * @beta */ export type AnnotationDetails = { - chunk: Chunk + chunk?: Chunk // This is not used anywhere, could be removed + event?: DocumentGroupEvent timestamp: string author: string } diff --git a/packages/sanity/src/core/releases/util/getReleaseIdFromName.ts b/packages/sanity/src/core/releases/util/getReleaseIdFromName.ts new file mode 100644 index 00000000000..a9b62df91d9 --- /dev/null +++ b/packages/sanity/src/core/releases/util/getReleaseIdFromName.ts @@ -0,0 +1,5 @@ +import {RELEASE_DOCUMENTS_PATH} from '../store/constants' + +export function getReleaseIdFromName(name: string) { + return `${RELEASE_DOCUMENTS_PATH}.${name}` +} diff --git a/packages/sanity/src/core/store/_legacy/history/history/diffValue.ts b/packages/sanity/src/core/store/_legacy/history/history/diffValue.ts index 934a3b2b7b2..d85a84b10a9 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/diffValue.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/diffValue.ts @@ -15,26 +15,26 @@ import {isSameAnnotation} from './utils' export type Meta = {chunk: Chunk; transactionIndex: number} | null -export type AnnotationExtractor = { - fromValue(value: incremental.Value): Annotation - fromMeta(meta: Meta): Annotation +export type AnnotationExtractor = { + fromValue(value: incremental.Value): Annotation + fromMeta(meta: T): Annotation } -class ArrayContentWrapper implements ArrayInput { +class ArrayContentWrapper implements ArrayInput { type = 'array' as const value: unknown[] length: number annotation: Annotation - extractor: AnnotationExtractor + extractor: AnnotationExtractor - private content: incremental.ArrayContent + private content: incremental.ArrayContent private elements: Input[] = [] constructor( - content: incremental.ArrayContent, + content: incremental.ArrayContent, value: unknown[], annotation: Annotation, - extractor: AnnotationExtractor, + extractor: AnnotationExtractor, ) { this.content = content this.value = value @@ -62,21 +62,21 @@ class ArrayContentWrapper implements ArrayInput { } } -class ObjectContentWrapper implements ObjectInput { +class ObjectContentWrapper implements ObjectInput { type = 'object' as const value: Record keys: string[] annotation: Annotation - extractor: AnnotationExtractor + extractor: AnnotationExtractor - private content: incremental.ObjectContent + private content: incremental.ObjectContent private fields: Record> = {} constructor( - content: incremental.ObjectContent, + content: incremental.ObjectContent, value: Record, annotation: Annotation, - extractor: AnnotationExtractor, + extractor: AnnotationExtractor, ) { this.content = content this.value = value @@ -96,19 +96,19 @@ class ObjectContentWrapper implements ObjectInput { } } -class StringContentWrapper implements StringInput { +class StringContentWrapper implements StringInput { type = 'string' as const value: string annotation: Annotation - extractor: AnnotationExtractor + extractor: AnnotationExtractor - private content: incremental.StringContent + private content: incremental.StringContent constructor( - content: incremental.StringContent, + content: incremental.StringContent, value: string, annotation: Annotation, - extractor: AnnotationExtractor, + extractor: AnnotationExtractor, ) { this.content = content this.value = value @@ -156,26 +156,26 @@ class StringContentWrapper implements StringInput { } } -function wrapValue( - value: incremental.Value, +export function wrapValue( + value: incremental.Value, raw: unknown, - extractor: AnnotationExtractor, + extractor: AnnotationExtractor, ): Input { const annotation = extractor.fromValue(value) if (value.content) { switch (value.content.type) { case 'array': - return new ArrayContentWrapper(value.content, raw as unknown[], annotation, extractor) + return new ArrayContentWrapper(value.content, raw as unknown[], annotation, extractor) case 'object': - return new ObjectContentWrapper( + return new ObjectContentWrapper( value.content, raw as Record, annotation, extractor, ) case 'string': - return new StringContentWrapper(value.content, raw as string, annotation, extractor) + return new StringContentWrapper(value.content, raw as string, annotation, extractor) default: // do nothing } @@ -230,7 +230,7 @@ export function diffValue( to: incremental.Value, toRaw: unknown, ): Diff { - const fromInput = wrapValue(from, fromRaw, { + const fromInput = wrapValue(from, fromRaw, { fromValue(value) { return extractAnnotationForFromInput(timeline, firstChunk, value.endMeta) }, @@ -239,7 +239,7 @@ export function diffValue( }, }) - const toInput = wrapValue(to, toRaw, { + const toInput = wrapValue(to, toRaw, { fromValue(value) { return extractAnnotationForToInput(timeline, value.startMeta) }, diff --git a/packages/sanity/src/core/store/_legacy/history/history/utils.ts b/packages/sanity/src/core/store/_legacy/history/history/utils.ts index e7a9762c8f7..874aa57e3d5 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/utils.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/utils.ts @@ -3,7 +3,7 @@ import {type CombinedDocument} from './types' export function isSameAnnotation(a: Annotation, b: Annotation): boolean { if (a && b) { - return a.author === b.author && a.chunk === b.chunk + return a.author === b.author && a.timestamp === b.timestamp } if (!a && !b) { diff --git a/packages/sanity/src/core/store/events/getDocumentChanges.ts b/packages/sanity/src/core/store/events/getDocumentChanges.ts new file mode 100644 index 00000000000..8d04cefdc67 --- /dev/null +++ b/packages/sanity/src/core/store/events/getDocumentChanges.ts @@ -0,0 +1,129 @@ +import {diffInput, wrap} from '@sanity/diff' +import {type SanityDocument, type TransactionLogEventWithEffects} from '@sanity/types' +import {from, map, type Observable, of, startWith} from 'rxjs' +import {type SanityClient} from 'sanity' + +import {type ObjectDiff} from '../../field' +import {getJsonStream} from '../_legacy/history/history/getJsonStream' +import {calculateDiff} from './reconstruct' +import {type DocumentGroupEvent} from './types' + +const TRANSLOG_ENTRY_LIMIT = 50 + +const buildDocumentForDiffInput = (document?: Partial | null) => { + if (!document) return {} + // Remove internal fields and undefined values + const {_id, _rev, _createdAt, _updatedAt, _type, _version, ...rest} = JSON.parse( + JSON.stringify(document), + ) + + return rest +} + +const documentTransactionsCache: Record = + Object.create(null) + +async function getDocumentTransactions({ + documentId, + client, + toTransaction, + fromTransaction, +}: { + documentId: string + client: SanityClient + toTransaction: string + fromTransaction: string +}) { + const cacheKey = `${documentId}-${toTransaction}-${fromTransaction}` + if (documentTransactionsCache[cacheKey]) { + return documentTransactionsCache[cacheKey] + } + const clientConfig = client.config() + const dataset = clientConfig.dataset + + const queryParams = new URLSearchParams({ + tag: 'sanity.studio.documents.history', + effectFormat: 'mendoza', + excludeContent: 'true', + includeIdentifiedDocumentsOnly: 'true', + // reverse: 'true', + limit: TRANSLOG_ENTRY_LIMIT.toString(), + // https://www.sanity.io/docs/history-api#toTransaction-d28ec5b5ff40 + toTransaction: toTransaction, + // https://www.sanity.io/docs/history-api#fromTransaction-db53ef83c809 + fromTransaction: fromTransaction, + }) + const transactionsUrl = client.getUrl( + `/data/history/${dataset}/transactions/${documentId}?${queryParams.toString()}`, + ) + const transactions: TransactionLogEventWithEffects[] = [] + + const stream = await getJsonStream(transactionsUrl, clientConfig.token) + const reader = stream.getReader() + for (;;) { + // eslint-disable-next-line no-await-in-loop + const result = await reader.read() + if (result.done) break + + if ('error' in result.value) { + throw new Error(result.value.error.description || result.value.error.type) + } + if (result.value.id === fromTransaction) continue + else transactions.push(result.value) + } + documentTransactionsCache[cacheKey] = transactions + return transactions +} + +export function getDocumentChanges({ + events, + documentId, + client, + to, + since, +}: { + events: DocumentGroupEvent[] + documentId: string + client: SanityClient + to: SanityDocument + since: SanityDocument | null +}): Observable<{loading: boolean; diff: ObjectDiff | null}> { + // Extremely raw implementation to get the differences between two versions. + // Transactions could be cached, given they are not gonna change EVER. + // We could also cache the diff, given it's not gonna change either + // Transactions are in an order, so if we have [rev4, rev3, rev2] and we already got [rev4, rev3] we can just get the diff between rev3 and rev2 and increment it. + // We need to expose this differently, as we need to also expose the transactions for versions and drafts, this implementation only works for published. + // We need to find a way to listen to the incoming transactions and in the case of published documents, refetch the events when a new transaction comes in. + // For versions and drafts we can keep the list of transactions updated just by the received transactions. + if (!since) { + return of({loading: false, diff: null}) + } + + return from( + getDocumentTransactions({ + documentId, + client, + toTransaction: to._rev, + fromTransaction: since._rev, + }), + ).pipe( + map((transactions) => { + return { + loading: false, + diff: calculateDiff({ + initialDoc: since, + finalDoc: to, + transactions, + events: events, + }) as ObjectDiff, + } + }), + startWith({ + loading: true, + diff: diffInput( + wrap(buildDocumentForDiffInput(since), null), + wrap(buildDocumentForDiffInput(to), null), + ) as ObjectDiff, + }), + ) +} diff --git a/packages/sanity/src/core/store/events/index.ts b/packages/sanity/src/core/store/events/index.ts new file mode 100644 index 00000000000..7fb893ff6e4 --- /dev/null +++ b/packages/sanity/src/core/store/events/index.ts @@ -0,0 +1,3 @@ +export * from './reconstruct' +export * from './types' +export * from './useEventsStore' diff --git a/packages/sanity/src/core/store/events/reconstruct.ts b/packages/sanity/src/core/store/events/reconstruct.ts new file mode 100644 index 00000000000..fbbcb960594 --- /dev/null +++ b/packages/sanity/src/core/store/events/reconstruct.ts @@ -0,0 +1,126 @@ +import {diffInput} from '@sanity/diff' +import {type SanityDocument, type TransactionLogEventWithEffects} from '@sanity/types' +import {incremental} from 'mendoza' + +import {type Annotation} from '../../field' +import {wrapValue} from '../_legacy/history/history/diffValue' +import {type DocumentGroupEvent} from './types' + +type EventMeta = { + transactionIndex: number + event?: DocumentGroupEvent +} | null + +function omitRev(document: SanityDocument | undefined) { + if (document === undefined) { + return undefined + } + const {_rev, ...doc} = document + return doc +} + +function annotationForTransactionIndex( + transactions: TransactionLogEventWithEffects[], + idx: number, + event?: DocumentGroupEvent, +) { + const tx = transactions[idx] + if (!tx) return null + + return { + timestamp: tx.timestamp, + author: tx.author, + event: event, + } +} + +function extractAnnotationForFromInput( + transactions: TransactionLogEventWithEffects[], + meta: EventMeta, +): Annotation { + if (meta) { + // The next transaction is where it disappeared: + return annotationForTransactionIndex(transactions, meta.transactionIndex + 1, meta.event) + } + + return null +} +function extractAnnotationForToInput( + transactions: TransactionLogEventWithEffects[], + meta: EventMeta, +): Annotation { + if (meta) { + return annotationForTransactionIndex(transactions, meta.transactionIndex, meta.event) + } + + return null +} + +function diffValue({ + transactions, + from, + fromRaw, + to, + toRaw, +}: { + transactions: TransactionLogEventWithEffects[] + from: incremental.Value + fromRaw: unknown + to: incremental.Value + toRaw: unknown +}) { + const fromInput = wrapValue(from, fromRaw, { + fromValue(value) { + return extractAnnotationForFromInput(transactions, value.endMeta) + }, + fromMeta(meta) { + return extractAnnotationForFromInput(transactions, meta) + }, + }) + + const toInput = wrapValue(to, toRaw, { + fromValue(value) { + return extractAnnotationForToInput(transactions, value.startMeta) + }, + fromMeta(meta) { + return extractAnnotationForToInput(transactions, meta) + }, + }) + return diffInput(fromInput, toInput) +} + +export function calculateDiff({ + initialDoc, + finalDoc, + transactions, + events = [], +}: { + initialDoc: SanityDocument + finalDoc: SanityDocument + transactions: TransactionLogEventWithEffects[] + events: DocumentGroupEvent[] +}) { + const documentId = initialDoc._id + + const initialValue = incremental.wrap(omitRev(initialDoc), null) + let document = incremental.wrap(omitRev(initialDoc), null) + + transactions.forEach((transaction, index) => { + const meta: EventMeta = { + transactionIndex: index, + event: events.find((event) => 'revisionId' in event && event.revisionId === transaction.id), + } + const effect = transaction.effects[documentId] + if (effect) { + document = incremental.applyPatch(document, effect.apply, meta) + } + }) + const diff = diffValue({ + transactions, + from: initialValue, + fromRaw: initialDoc, + to: document, + toRaw: finalDoc, + }) + return diff +} diff --git a/packages/sanity/src/core/store/events/types.ts b/packages/sanity/src/core/store/events/types.ts new file mode 100644 index 00000000000..9e0a6879090 --- /dev/null +++ b/packages/sanity/src/core/store/events/types.ts @@ -0,0 +1,324 @@ +/** + * # Draft model + * + * The following describes the semantics of the draft model in Content Releases. + * + * ## Terminology + * + * In this world we have the following terms: + * + * - "Document" is unfortunately an overloaded term. It _may_ refer to the + * user's perspective of a document in Studio It _may_ refer to a specific + * document as observed through the API, or it _may_ refer to user's + * perspective of a document in Studio (which is a single "document group" + * represented by multiple documents). + * - "Document version" is a document with the ID of `drafts.` or `versions.{bundleId}.` + * - "Document group" is an explicit way of referring to the published + * document and all of its versions. + * - "Event" (either on "a document" or on "a release") represents a change in the + * state. They are often caused by actions, but they are not 1-to-1. The + * "publish release" action causes a `ScheduleDocumentVersionEvent` for each + * of the document versions inside the release. + * + * These are higher level events and you can not assume that they are being + * caused by a single document actions. For instance, scheduling/publishing a + * _release_ causes a `ScheduleDocumentEvent` to appear in the document's event + * list. + * + * ## Document group event + * + * The completely lifecycle of a document group can be described with a series + * of _events_. These are the higher level changes such as "document was + * published", "version was created", "document was scheduled", and so forth. + * Every event has a single timestamp. + * + * We're also using the following conventions: + * + * - `documentId` always refers to the published document ID (which is also what + * we consider the ID for the whole group). + * - `revisionId` refers to a revision on the published document. + * - `versionId` refers to a document version ID. + * - `releaseId` refers to the release of the document version. + * This will be not present if `versionId` starts with`drafts.`. + * - `versionRevisionId` refers to a revision on a document version. + * + * See {@link DocumentGroupEvent} for the full list of events. + * + * ## Document changes + * + * Interestingly, there's no document group events about the _contents_ of a + * document. Instead we have a separate concept of _document changes_ which are + * the actual changes of the attributes to a document. + * + * Document changes are constructed from edits (i.e. through the Edit action), + * but are distinct objects. They have a time_span_, instead of a time_stamp_, + * and can have multiple authors and/or fields modified in a single "change". + * The change could be represented by "these fields have been modifed in some + * way" or "here's a detailed attribution of every new character that appeared + * in this Portable Text". + * + * ## Release events + * + * There's a separate set of events for releases which deals with changes done + * at the whole release level (e.g. schedule/publish) that are _critical_ for + * its behavior. These events intentionally do not include changes to + * non-critical metadata (e.g. title). This is currently not defined here. + * + * ## Release activity + * + * When looking at the complete activity of a release it should be composed of + * three different sources: + * + * 1. Release events (schedule/unschedule/publish etc). + * 2. Release metadata changes. + * 2. Document group events related to release – with the exclusion of events + * which are caused by release-level actions. + * + * ## Relation to Content Lake APIs + * + * "Document group events", "document changes" and "release events" are + * currently not exposed by the REST API in Content Lake. Some of these data + * _might_ however already be inferred through using the History Transactions + * API. + * + * The intention is for Studio to internally refer to these concepts using an + * implementation which uses the _current_ Content Lake APIs. Over time we + * aspire to extend the API to provide access to this data natively and + * efficiently. + * + * ## Overall document lifecycle + * + * The overall document's existence is defined by the existance of either the + * published document or a draft (either the main draft or a version in a + * release). + * + * This means that there are two ways a document can be _created_: + * + * 1. `CreateDocumentVersionEvent`: This is what Studio does through an Edit action. + * 2. `CreateLiveDocumentEvent`: A raw Create mutation sent outside of the Studio. + * + * The whole document is considered _deleted_ through a single event: + * + * 1. `DeleteDocumentGroupEvent`: This is caused either by the Delete action, + * or when discarding the last draft. + * + * ## Version lifecycle + * + * A document version has the following lifecycle: + * + * 1. "Version doesn't exist". + * - `CreateDocumentVersionEvent`: Edit action - "Version exists" + * - `UnpublishDocumentEvent`: Unpublish action - "Version exists" + * 2. "Version exists". + * - `DeleteDocumentVersionEvent`: DiscardDraft action - "Version doesn't + * exist" + * - `PublishDocumentVersionEvent`: Publish document/release action - "Version doesn't exist" + * - `ScheduleDocumentVersionEvent`: Schedule release action - "Version is scheduled" + * - `DeleteDocumentGroupEvent`: Delete action _OR_ DiscardDraft [the last one] - "Version doesn't exist" + * 3. "Version is scheduled". + * - `PublishDocumentVersionEvent`: Automatically, on schedule - "Version doesn't exist" + * - `UnscheduleDocumentVersionEvent`: Unschedule release action - "Version exists" + * + * ## Published lifecycle + * + * The published document has the following lifecycle: + * + * 1. "Published document doesn't exist". + * - `PublishDocumentVersionEvent`: Publish document/release action - "Published document exists". + * - `CreateLiveDocumentEvent`: Raw Create mutation - "Published document exists" + * 2. "Published document exists" + * - `PublishDocumentVersionEvent`: Publish document/release action - "Published document exists" + * - `UnpublishDocumentEvent`: Unpublish action - "Published document doesn't exist" + * - `DeleteDocumentGroupEvent`: Delete action - "Published document doesn't exist" + * - `UpdateLiveDocumentEvent`: Raw Update mutation - "Published document exists" + */ +import {type Diff} from '@sanity/diff' +import {type ReleaseDocument, type SanityDocument} from 'sanity' + +import {ObjectDiff, type Annotation} from '../../field' +import {Observable} from 'rxjs' + +/** + * Events relevant for the whole document group. + **/ +export type DocumentGroupEvent = + | CreateDocumentVersionEvent + | DeleteDocumentVersionEvent + | PublishDocumentVersionEvent + | UnpublishDocumentEvent + | ScheduleDocumentVersionEvent + | UnscheduleDocumentVersionEvent + | DeleteDocumentGroupEvent + | CreateLiveDocumentEvent + | UpdateLiveDocumentEvent + | EditDocumentVersionEvent + +/** + * A generic event with a type and a timestamp. + */ +export interface BaseEvent { + timestamp: string + author: string +} + +export interface CreateDocumentVersionEvent extends BaseEvent { + type: 'CreateDocumentVersion' + documentId: string + + releaseId?: string + versionId: string + versionRevisionId: string + + revisionId: string +} + +export interface DeleteDocumentVersionEvent extends BaseEvent { + type: 'DeleteDocumentVersion' + documentId: string + + releaseId?: string + versionId: string + versionRevisionId: string +} + +export interface PublishDocumentVersionEvent extends BaseEvent { + type: 'PublishDocumentVersion' + documentId: string + revisionId: string + + versionId: string + releaseId?: string + + /** This is only available when it was triggered by Publish action. */ + versionRevisionId?: string + + /** What caused this document to be published. */ + publishCause: PublishCause + + /** + * This is added client side to enhance the UI. + */ + release?: ReleaseDocument +} + +export type PublishCause = + | { + // The document was explicitly published. + type: 'document.publish' + author: string + } + | { + // The whole release was explicitly published. + type: 'release.publish' + author: string + } + | { + // The whole release was published through a schedule. + type: 'release.schedule' + author: string + scheduledAt: string + } + +export interface UnpublishDocumentEvent extends BaseEvent { + type: 'UnpublishDocument' + documentId: string + + /** The version that was created based on it */ + versionId: string + versionRevisionId: string + releaseId?: string + + author: string +} + +export interface ScheduleDocumentVersionEvent extends BaseEvent { + type: 'ScheduleDocumentVersion' + documentId: string + + releaseId: string + versionId: string + versionRevisionId: string + + /** The _current_ state of this schedule. */ + state: 'pending' | 'unscheduled' | 'published' + + author: string + publishAt: string +} + +export interface UnscheduleDocumentVersionEvent extends BaseEvent { + type: 'UnscheduleDocumentVersion' + documentId: string + + releaseId: string + versionId: string + versionRevisionId: string + + author: string +} + +export interface DeleteDocumentGroupEvent extends BaseEvent { + type: 'DeleteDocumentGroup' + documentId: string + + author: string +} + +export interface CreateLiveDocumentEvent extends BaseEvent { + type: 'CreateLiveDocument' + documentId: string + revisionId: string + + author: string +} + +export interface UpdateLiveDocumentEvent extends BaseEvent { + type: 'UpdateLiveDocument' + documentId: string + revisionId: string + + author: string +} + +/** + * This event won't be exposed by the API, it needs to be generated by validating the + * transactions that occurred between two events. Usually, between two PublishDocumentEvents. + * Or a create event and a publish event. + */ +export interface EditDocumentVersionEvent extends BaseEvent { + type: 'EditDocumentVersion' + // Given this event could be a result of multiple edits, we could have more than one author. + authors: string[] + releaseId?: string + revisionId: string + versionRevisionId: string + mergedEvents?: EditDocumentVersionEvent[] +} + +export interface EventsStoreRevision { + revisionId: string + loading: boolean + document?: SanityDocument | null +} + +export interface EventsStore { + events: DocumentGroupEvent[] + nextCursor: string | null + loading: boolean + error: Error | null + revision: EventsStoreRevision | null + sinceRevision: EventsStoreRevision | null + findRangeForRevision: (nextRev: string) => [string | null, string | null] + findRangeForSince: (nextSince: string) => [string | null, string | null] + loadMoreEvents: () => void + changesList: (docs: { + to: SanityDocument + from: SanityDocument | null + }) => Observable<{diff: ObjectDiff | null; loading: boolean}> +} + +/** + * @internal + * @beta + **/ +export type DocumentVersionEventType = DocumentGroupEvent['type'] diff --git a/packages/sanity/src/core/store/events/useEventsStore.ts b/packages/sanity/src/core/store/events/useEventsStore.ts new file mode 100644 index 00000000000..b64aadf5a99 --- /dev/null +++ b/packages/sanity/src/core/store/events/useEventsStore.ts @@ -0,0 +1,243 @@ +import {useCallback, useMemo} from 'react' +import {useObservable} from 'react-rx' +import {combineLatest, type Observable, of} from 'rxjs' +import {catchError, map, startWith, tap} from 'rxjs/operators' +import {getVersionFromId, type SanityClient, type SanityDocument} from 'sanity' + +import {useClient} from '../../hooks' +import {useReleasesStore} from '../../releases/store/useReleasesStore' +import {getReleaseIdFromName} from '../../releases/util/getReleaseIdFromName' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {getDocumentChanges} from './getDocumentChanges' +import {type DocumentGroupEvent, type EventsStore, type EventsStoreRevision} from './types' + +const INITIAL_VALUE = { + events: [], + nextCursor: null, + loading: true, + error: null, +} +const documentRevisionCache: Record = Object.create(null) + +function getDocumentAtRevision({ + client, + documentId, + revisionId, +}: { + client: SanityClient + documentId: string + revisionId: string | null | undefined +}): Observable { + if (!revisionId) { + return of(null) + } + const cacheKey = `${documentId}@${revisionId}` + const dataset = client.config().dataset + if (documentRevisionCache[cacheKey]) { + return of({document: documentRevisionCache[cacheKey], loading: false, revisionId: revisionId}) + } + return client.observable + .request<{documents: SanityDocument[]}>({ + url: `/data/history/${dataset}/documents/${documentId}?revision=${revisionId}`, + tag: 'get-document-revision', + }) + .pipe( + map((response) => { + const document = response.documents[0] + return {document: document, loading: false, revisionId: revisionId} + }), + tap((resp) => { + documentRevisionCache[cacheKey] = resp.document + }), + catchError((error: Error) => { + // TODO: Handle error + console.error('Error fetching document at revision', error) + return [{document: null, loading: false, revisionId: revisionId}] + }), + startWith({document: null, loading: true, revisionId: revisionId}), + ) +} + +export function useEventsStore({ + documentId, + rev, + since, +}: { + documentId: string + rev?: string + since?: string +}): EventsStore { + const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const {state$} = useReleasesStore() + + const eventsObservable$: Observable<{ + events: DocumentGroupEvent[] + nextCursor: string + loading: boolean + error: null | Error + }> = useMemo(() => { + const params = new URLSearchParams({ + // This is not working yet, CL needs to fix it. + limit: '2', + }) + return client.observable + .request<{ + events: Record + nextCursor: string + }>({ + url: `/data/events/${client.config().dataset}/documents/${documentId}?${params.toString()}`, + tag: 'get-document-events', + }) + .pipe( + map((response) => { + console.log('response', response) + return { + events: response.events[documentId] || [], + nextCursor: response.nextCursor, + loading: false, + error: null, + } + }), + catchError((error: Error) => { + console.error('Error fetching events', error) + return [{events: [], nextCursor: '', loading: false, error: error}] + }), + startWith({events: [], nextCursor: '', loading: true, error: null}), + ) + }, [client, documentId]) + + const observable$ = useMemo(() => { + return combineLatest([state$, eventsObservable$]).pipe( + map(([releases, {events, nextCursor, loading, error}]) => { + return { + events: events.map((event) => { + if (event.type === 'PublishDocumentVersion') { + const releaseId = getVersionFromId(event.versionId) + + if (releaseId) { + const release = releases.releases.get(getReleaseIdFromName(releaseId)) + + return { + ...event, + release: release, + } + } + return event + } + return event + }), + nextCursor: nextCursor, + loading: loading, + error: error, + } + }), + ) + }, [state$, eventsObservable$]) + + const {events, loading, error, nextCursor} = useObservable(observable$, INITIAL_VALUE) + + const revision$ = useMemo( + () => + getDocumentAtRevision({ + client, + documentId, + revisionId: rev, + }), + [rev, client, documentId], + ) + const revision = useObservable(revision$, null) + + const sinceId = useMemo(() => { + if (since && since !== '@lastPublished') return since + + // We want to try to infer the since Id from the events, we want to compare to the last event that happened before the rev as fallback + if (!events) return null + if (!rev) { + // rev has not been selected, the user will be seeing the last version of the document. + // we need to select the event that comes after + return events.slice(1).find((event) => 'revisionId' in event)?.revisionId || null + } + + // If the user has a rev, we should show here the id of the event that is the previous event to the rev. + const revisionEventIndex = events.findIndex( + (event) => 'revisionId' in event && event.revisionId === rev, + ) + if (revisionEventIndex === -1) { + return null + } + + return ( + events.slice(revisionEventIndex + 1).find((event) => 'revisionId' in event)?.revisionId || + null + ) + }, [events, rev, since]) + + const since$ = useMemo( + () => getDocumentAtRevision({client, documentId, revisionId: sinceId}), + [sinceId, client, documentId], + ) + const sinceRevision = useObservable(since$, null) + + const findRangeForRevision = useCallback( + (nextRev: string): [string | null, string | null] => { + if (!events) return [null, null] + if (!since) return [null, nextRev] + const revisionIndex = events.findIndex( + (event) => 'revisionId' in event && event.revisionId === nextRev, + ) + const sinceIndex = events.findIndex( + (event) => 'revisionId' in event && event.revisionId === since, + ) + + if (sinceIndex === -1 || revisionIndex === -1) return [null, nextRev] + if (sinceIndex > revisionIndex) return [null, nextRev] + return [since, nextRev] + }, + [events, since], + ) + + const findRangeForSince = useCallback( + (nextSince: string): [string | null, string | null] => { + if (!events) return [null, null] + if (!rev) return [nextSince, null] + const revisionIndex = events.findIndex( + (event) => 'revisionId' in event && event.revisionId === rev, + ) + const sinceIndex = events.findIndex( + (event) => 'revisionId' in event && event.revisionId === nextSince, + ) + if (sinceIndex === -1 || revisionIndex === -1) return [nextSince, null] + if (sinceIndex < revisionIndex) return [nextSince, null] + return [nextSince, rev] + }, + [events, rev], + ) + + const changesList = useCallback( + ({to, from}: {to: SanityDocument; from: SanityDocument | null}) => { + return getDocumentChanges({ + client, + events, + documentId, + to, + since: from, + }) + }, + [client, events, documentId], + ) + + return { + events: events, + nextCursor: nextCursor, + loading: loading, + error: error, + revision: revision, + sinceRevision: sinceRevision, + findRangeForRevision: findRangeForRevision, + findRangeForSince: findRangeForSince, + loadMoreEvents: () => { + console.log('IMPLEMENT ME PLEASE') + }, + changesList, + } +} diff --git a/packages/sanity/src/core/store/index.ts b/packages/sanity/src/core/store/index.ts index 3373bb62f9d..33c2a778b85 100644 --- a/packages/sanity/src/core/store/index.ts +++ b/packages/sanity/src/core/store/index.ts @@ -1,2 +1,3 @@ export * from './_legacy' +export * from './events' export * from './user' diff --git a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx index 7a845f4a5fb..daeb5b01f4c 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx @@ -224,7 +224,7 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { const activeViewId = params.view || (views[0] && views[0].id) || null const [timelineMode, setTimelineMode] = useState<'since' | 'rev' | 'closed'>('closed') - const {store: timelineStore, error: timelineError} = useHistory() + const {store: timelineStore, error: timelineError, eventsStore} = useHistory() // Subscribe to external timeline state changes const onOlderRevision = useTimelineSelector(timelineStore, (state) => state.onOlderRevision) const revTime = useTimelineSelector(timelineStore, (state) => state.revTime) @@ -290,9 +290,10 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { const {t} = useTranslation(structureLocaleNamespace) const inspectOpen = params.inspect === 'on' - const compareValue: Partial | null = changesOpen - ? sinceAttributes - : editState?.published || null + const compareValue: Partial | null = + eventsStore?.sinceRevision?.document || changesOpen + ? sinceAttributes + : editState?.published || null const fieldActions: DocumentFieldAction[] = useMemo( () => (schemaType ? fieldActionsResolver({documentId, documentType, schemaType}) : []), @@ -316,10 +317,22 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { editState.ready && (!params.rev || timelineReady || !!timelineError) - const displayed: Partial | undefined = useMemo( - () => (onOlderRevision ? timelineDisplayed || {_id: value._id, _type: value._type} : value), - [onOlderRevision, timelineDisplayed, value], - ) + const displayed: Partial | undefined = useMemo(() => { + if (eventsStore?.revision?.revisionId) { + return eventsStore?.revision?.document || {_id: value._id, _type: value._type} + } + if (onOlderRevision) { + console.log('RETURNING OLDER REVISION; WHY ARE WE HERE???') + return timelineDisplayed || {_id: value._id, _type: value._type} + } + return value + }, [ + eventsStore?.revision?.document, + eventsStore?.revision?.revisionId, + onOlderRevision, + timelineDisplayed, + value, + ]) const setTimelineRange = useCallback( (newSince: string, newRev: string | null) => { diff --git a/packages/sanity/src/structure/panes/document/HistoryProvider.tsx b/packages/sanity/src/structure/panes/document/HistoryProvider.tsx index a22c43a4e14..b3dfac3e68f 100644 --- a/packages/sanity/src/structure/panes/document/HistoryProvider.tsx +++ b/packages/sanity/src/structure/panes/document/HistoryProvider.tsx @@ -1,12 +1,16 @@ import {useContext, useState} from 'react' import { + type EventsStore, + getDraftId, getPublishedId, + getVersionId, resolveBundlePerspective, + useEventsStore, usePerspective, useSource, useTimelineStore, } from 'sanity' -import {HistoryContext, type HistoryContextValue} from 'sanity/_singletons' +import {EventsContext, HistoryContext, type HistoryContextValue} from 'sanity/_singletons' import {usePaneRouter} from 'sanity/structure' import {EMPTY_PARAMS} from './constants' @@ -16,7 +20,14 @@ interface LegacyStoreProviderProps { documentId: string documentType: string } -function LegacyStoreProvider({children, documentId, documentType}: LegacyStoreProviderProps) { +function LegacyStoreProvider({ + children, + documentId, + documentType, + eventsStore, +}: LegacyStoreProviderProps & { + eventsStore?: EventsStore +}) { const paneRouter = usePaneRouter() const {perspective} = usePerspective() const bundlePerspective = resolveBundlePerspective(perspective) @@ -40,21 +51,55 @@ function LegacyStoreProvider({children, documentId, documentType}: LegacyStorePr }) return ( - + {children} ) } function EventsStoreProvider(props: LegacyStoreProviderProps) { - return + const {params = EMPTY_PARAMS, setParams} = usePaneRouter() + + const {perspective} = usePerspective() + const bundlePerspective = resolveBundlePerspective(perspective) + const documentId = + // eslint-disable-next-line no-nested-ternary + typeof perspective === 'undefined' + ? getDraftId(props.documentId) + : // eslint-disable-next-line no-nested-ternary + perspective === 'published' + ? getPublishedId(props.documentId) + : bundlePerspective + ? getVersionId(props.documentId, bundlePerspective) + : props.documentId + + const eventsStore = useEventsStore({ + documentId, + rev: params.rev, + since: params.since, + }) + + return ( + + + + ) +} +export function useEvents(): EventsStore { + const context = useContext(EventsContext) + if (context === null) { + throw new Error('useEvents must be used within a EventsProvider') + } + return context } + export function HistoryProvider(props: LegacyStoreProviderProps) { const source = useSource() + if (source.beta?.eventsAPI?.enabled) { return } - return + return // This is the timeline } export function useHistory(): HistoryContextValue { const context = useContext(HistoryContext) diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx index 0daec94307b..0822dd9cfc0 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx @@ -167,19 +167,6 @@ export const DocumentPanelHeader = memo( {menuButtonNodes.map((item) => ( ))} - - {/* todo update translation */} - {/*eslint-disable-next-line i18next/no-literal-string*/} - History} placement="bottom" portal> -