diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 43eefde3dc4..cb3223dfc06 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -157,7 +157,8 @@ const defaultWorkspace = { icon: SanityMonogram, // eslint-disable-next-line camelcase __internal_serverDocumentActions: { - enabled: true, + // TODO: Switched off because Actions API doesn't support versions (yet). + enabled: false, }, scheduledPublishing: { enabled: true, diff --git a/package.json b/package.json index 2093a9a5f07..002b97ba730 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@repo/package.config": "workspace:*", "@repo/test-config": "workspace:*", "@repo/tsconfig": "workspace:*", - "@sanity/client": "^6.22.1", + "@sanity/client": "bundle-perspective", "@sanity/eslint-config-i18n": "1.0.0", "@sanity/eslint-config-studio": "^4.0.0", "@sanity/mutate": "^0.10.0", @@ -185,6 +185,7 @@ }, "overrides": { "@npmcli/arborist": "^7.5.4", + "@sanity/client": "bundle-perspective", "@sanity/ui@2": "$@sanity/ui", "@typescript-eslint/eslint-plugin": "$@typescript-eslint/eslint-plugin", "@typescript-eslint/parser": "$@typescript-eslint/parser" diff --git a/packages/@sanity/types/src/documents/types.ts b/packages/@sanity/types/src/documents/types.ts index 50a87fba5a4..c6ba8e53235 100644 --- a/packages/@sanity/types/src/documents/types.ts +++ b/packages/@sanity/types/src/documents/types.ts @@ -5,6 +5,7 @@ export interface SanityDocument { _createdAt: string _updatedAt: string _rev: string + _version?: Record [key: string]: unknown } @@ -21,6 +22,7 @@ export interface SanityDocumentLike { _createdAt?: string _updatedAt?: string _rev?: string + _version?: Record [key: string]: unknown } diff --git a/packages/sanity/package.json b/packages/sanity/package.json index 493d0cb910d..ac24c8cef48 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -252,6 +252,7 @@ "rimraf": "^3.0.2", "rxjs": "^7.8.0", "rxjs-exhaustmap-with-trailing": "^2.1.1", + "rxjs-mergemap-array": "^0.1.0", "sanity-diff-patch": "^3.0.2", "scroll-into-view-if-needed": "^3.0.3", "semver": "^7.3.5", diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx index 69f98a55a1c..f0508b4893b 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx @@ -1,6 +1,7 @@ import {type SanityClient} from '@sanity/client' import {Card, LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui' import {buildTheme, type RootTheme} from '@sanity/ui/theme' +import {noop} from 'lodash' import {type ReactNode, Suspense, useEffect, useState} from 'react' import { ChangeConnectorRoot, @@ -18,6 +19,8 @@ import { import {Pane, PaneContent, PaneLayout} from 'sanity/structure' import {styled} from 'styled-components' +import {route} from '../../../../src/router' +import {RouterProvider} from '../../../../src/router/RouterProvider' import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient' import {getMockWorkspace} from '../../../../test/testUtils/getMockWorkspaceFromConfig' @@ -36,6 +39,8 @@ const StyledChangeConnectorRoot = styled(ChangeConnectorRoot)` min-width: 0; ` +const router = route.create('/') + /** * @description This component is used to wrap all tests in the providers it needs to be able to run successfully. * It provides a mock Sanity client and a mock workspace. @@ -72,37 +77,39 @@ export const TestWrapper = (props: TestWrapperProps): JSX.Element | null => { return ( - - - - - - - - - - {}} - onSetFocus={() => {}} - > - - - - {children} - - - - - - - - - - - - - + + + + + + + + + + + {}} + onSetFocus={() => {}} + > + + + + {children} + + + + + + + + + + + + + + ) } diff --git a/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts b/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts new file mode 100644 index 00000000000..cbe9a7b726e --- /dev/null +++ b/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts @@ -0,0 +1,12 @@ +import {createContext} from 'sanity/_createContext' + +import type {ReleasesMetadataContextValue} from '../../core/releases/contexts/ReleasesMetadataProvider' + +/** + * @internal + * @hidden + */ +export const ReleasesMetadataContext = createContext( + 'sanity/_singletons/context/releases-metadata', + null, +) diff --git a/packages/sanity/src/_singletons/context/ReleasesTableContext.ts b/packages/sanity/src/_singletons/context/ReleasesTableContext.ts new file mode 100644 index 00000000000..64f9023bb61 --- /dev/null +++ b/packages/sanity/src/_singletons/context/ReleasesTableContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {TableContextValue} from '../../core/releases/tool/components/Table/TableProvider' + +/** + * @internal + */ +export const TableContext = createContext( + 'sanity/_singletons/context/releases-table', + null, +) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 05761b9609f..6a6ed1c9c2e 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -43,6 +43,8 @@ export * from './context/PresenceTrackerContexts' export * from './context/PreviewCardContext' export * from './context/ReferenceInputOptionsContext' export * from './context/ReferenceItemRefContext' +export * from './context/ReleasesMetadataContext' +export * from './context/ReleasesTableContext' export * from './context/ResourceCacheContext' export * from './context/ReviewChangesContext' export * from './context/RouterContext' diff --git a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx index b90663cb317..083146ae517 100644 --- a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx +++ b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx @@ -263,6 +263,9 @@ export const CommentsListItem = memo(function CommentsListItem(props: CommentsLi } }, [replies]) + /* TODO - once we understand how to set up with "finished" releases + we need to add a condition to the readOnly prop in this component */ + const renderedReplies = useMemo( () => splicedReplies.map((reply) => ( diff --git a/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx index e15c5a4eb0d..b7d69ad1f53 100644 --- a/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx +++ b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx @@ -6,7 +6,7 @@ import {CommentsContext} from 'sanity/_singletons' import {useEditState, useSchema, useUserListWithPermissions} from '../../../hooks' import {useCurrentUser} from '../../../store' import {useAddonDataset, useWorkspace} from '../../../studio' -import {getPublishedId} from '../../../util' +import {getPublishedId, resolveBundlePerspective} from '../../../util' import { type CommentOperationsHookOptions, useCommentOperations, @@ -43,6 +43,7 @@ export interface CommentsProviderProps { children: ReactNode documentId: string documentType: string + perspective?: string type: CommentsType sortOrder: 'asc' | 'desc' @@ -67,7 +68,7 @@ type TransactionId = string export const CommentsProvider = memo(function CommentsProvider(props: CommentsProviderProps) { const { children, - documentId, + documentId: versionOrPublishedId, documentType, isCommentsOpen, onCommentsOpen, @@ -78,20 +79,26 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr selectedCommentId, isConnecting, onPathOpen, + perspective, } = props const commentsEnabled = useCommentsEnabled() const [status, setStatus] = useState('open') const {client, createAddonDataset, isCreatingDataset} = useAddonDataset() - const publishedId = getPublishedId(documentId) - const editState = useEditState(publishedId, documentType, 'low') + + const editState = useEditState( + getPublishedId(versionOrPublishedId), + documentType, + 'default', + resolveBundlePerspective(perspective), + ) const schemaType = useSchema().get(documentType) const currentUser = useCurrentUser() const {name: workspaceName, dataset, projectId} = useWorkspace() const documentValue = useMemo(() => { - return editState.draft || editState.published - }, [editState.draft, editState.published]) + return editState.version || editState.draft || editState.published + }, [editState.version, editState.draft, editState.published]) const documentRevisionId = useMemo(() => documentValue?._rev, [documentValue]) @@ -112,7 +119,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr error, loading, } = useCommentsStore({ - documentId: publishedId, + documentId: versionOrPublishedId, client, transactionsIdMap, onLatestTransactionIdReceived: handleOnLatestTransactionIdReceived, @@ -229,7 +236,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - documentId: publishedId, + documentId: versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -257,7 +264,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - publishedId, + versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -277,7 +284,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr const ctxValue = useMemo( (): CommentsContextValue => ({ - documentId, + documentId: versionOrPublishedId, documentType, isCreatingDataset, @@ -310,7 +317,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr mentionOptions, }), [ - documentId, + versionOrPublishedId, documentType, isCreatingDataset, status, diff --git a/packages/sanity/src/core/comments/store/useCommentsStore.ts b/packages/sanity/src/core/comments/store/useCommentsStore.ts index 0dc5858af7a..d2f7e53267b 100644 --- a/packages/sanity/src/core/comments/store/useCommentsStore.ts +++ b/packages/sanity/src/core/comments/store/useCommentsStore.ts @@ -2,7 +2,7 @@ import {type ListenEvent, type ListenOptions, type SanityClient} from '@sanity/c import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import {catchError, of} from 'rxjs' -import {getPublishedId} from '../../util' +import {getPublishedId, isVersionId} from '../../util' import {type CommentDocument, type Loadable} from '../types' import {commentsReducer, type CommentsReducerAction, type CommentsReducerState} from './reducer' @@ -64,7 +64,10 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur const didInitialFetch = useRef(false) - const params = useMemo(() => ({documentId: getPublishedId(documentId)}), [documentId]) + const params = useMemo( + () => ({documentId: isVersionId(documentId) ? documentId : getPublishedId(documentId)}), + [documentId], + ) const initialFetch = useCallback(async () => { if (!client) { diff --git a/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx b/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx index 16a5190dbf7..8be6b7268a8 100644 --- a/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx +++ b/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx @@ -1,15 +1,20 @@ import {type PreviewValue, type SanityDocument} from '@sanity/types' import {Flex, Text} from '@sanity/ui' +import {useMemo} from 'react' import {styled} from 'styled-components' import {useDateTimeFormat, useRelativeTime} from '../../hooks' import {useTranslation} from '../../i18n' +import {type BundleDocument} from '../../store/bundles' +import {PerspectiveBadge} from '../perspective/PerspectiveBadge' interface DocumentStatusProps { absoluteDate?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + version?: PreviewValue | Partial | null singleLine?: boolean + currentGlobalBundle?: Partial } const StyledText = styled(Text)` @@ -26,9 +31,17 @@ const StyledText = styled(Text)` * * @internal */ -export function DocumentStatus({absoluteDate, draft, published, singleLine}: DocumentStatusProps) { +export function DocumentStatus({ + absoluteDate, + draft, + published, + version, + singleLine, + currentGlobalBundle, +}: DocumentStatusProps) { const {t} = useTranslation() const draftUpdatedAt = draft && '_updatedAt' in draft ? draft._updatedAt : '' + const versionUpdatedAt = version && '_updatedAt' in version ? version._updatedAt : '' const publishedUpdatedAt = published && '_updatedAt' in published ? published._updatedAt : '' const intlDateFormat = useDateTimeFormat({ @@ -39,6 +52,7 @@ export function DocumentStatus({absoluteDate, draft, published, singleLine}: Doc const draftDateAbsolute = draftUpdatedAt && intlDateFormat.format(new Date(draftUpdatedAt)) const publishedDateAbsolute = publishedUpdatedAt && intlDateFormat.format(new Date(publishedUpdatedAt)) + const versionDateAbsolute = versionUpdatedAt && intlDateFormat.format(new Date(versionUpdatedAt)) const draftUpdatedTimeAgo = useRelativeTime(draftUpdatedAt || '', { minimal: true, @@ -48,9 +62,27 @@ export function DocumentStatus({absoluteDate, draft, published, singleLine}: Doc minimal: true, useTemporalPhrase: true, }) + const versionUpdatedTimeAgo = useRelativeTime(versionUpdatedAt || '', { + minimal: true, + useTemporalPhrase: true, + }) const publishedDate = absoluteDate ? publishedDateAbsolute : publishedUpdatedTimeAgo - const updatedDate = absoluteDate ? draftDateAbsolute : draftUpdatedTimeAgo + const updatedDate = absoluteDate + ? versionDateAbsolute || draftDateAbsolute + : versionUpdatedTimeAgo || draftUpdatedTimeAgo + + const {title} = currentGlobalBundle || {} + + const documentStatus = useMemo(() => { + if (published && '_id' in published) { + return 'published' + } else if (version && '_id' in version) { + return 'version' + } + + return 'draft' + }, [published, version]) return ( - {!publishedDate && ( + {version && currentGlobalBundle && ( + + )} + + {!version && !publishedDate && ( {t('document-status.not-published')} )} - {publishedDate && ( + {!version && publishedDate && ( {t('document-status.published', {date: publishedDate})} diff --git a/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx b/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx index 86036a3a8f0..475844e6364 100644 --- a/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx +++ b/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx @@ -7,6 +7,7 @@ import {styled} from 'styled-components' interface DocumentStatusProps { draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + version?: PreviewValue | Partial | null } const Root = styled(Text)` @@ -25,26 +26,30 @@ const Root = styled(Text)` * - Yellow (caution) for published documents with edits * - Gray (default) for unpublished documents (with or without edits) * - * No dot will be displayed for published documents without edits. + * No dot will be displayed for published documents without edits or for version documents. * * @internal */ -export function DocumentStatusIndicator({draft, published}: DocumentStatusProps) { - const $draft = !!draft - const $published = !!published +export function DocumentStatusIndicator({draft, published, version}: DocumentStatusProps) { + const $draft = Boolean(draft) + const $published = Boolean(published) + const $version = Boolean(version) const status = useMemo(() => { + if ($version) return undefined if ($draft && !$published) return 'unpublished' return 'edited' - }, [$draft, $published]) + }, [$draft, $published, $version]) // Return null if the document is: // - Published without edits // - Neither published or without edits (this shouldn't be possible) - if ((!$draft && !$published) || (!$draft && $published)) { + // - A version + if ((!$draft && !$published) || (!$draft && $published) || $version) { return null } + // TODO: Remove debug `status[0]` output. return ( diff --git a/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx b/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx new file mode 100644 index 00000000000..7ea03cf6bd5 --- /dev/null +++ b/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx @@ -0,0 +1,40 @@ +import {Box, Text} from '@sanity/ui' +import {type CSSProperties, useMemo} from 'react' + +export function PerspectiveBadge(props: { + releaseTitle?: string + // TODO: prep work for potentially reusing this on document headers + documentStatus: 'draft' | 'published' | 'version' +}): JSX.Element | null { + const {releaseTitle = 'draft', documentStatus} = props + const isPublished = documentStatus === 'published' + const isDraft = documentStatus === 'draft' + + const displayTitle = useMemo(() => { + if (isPublished) { + return 'published' + } + + if (isDraft) { + return 'edited' + } + + return releaseTitle + }, [isDraft, isPublished, releaseTitle]) + + return ( + + {displayTitle} + + ) +} diff --git a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts index 45554270565..caeac0ef5f7 100644 --- a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts +++ b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts @@ -162,6 +162,7 @@ describe('resolveConfig', () => { {name: 'sanity/comments'}, {name: 'sanity/tasks'}, {name: 'sanity/scheduled-publishing'}, + {name: 'sanity/releases'}, ]) }) @@ -188,6 +189,7 @@ describe('resolveConfig', () => { expect(workspace.__internal.options.plugins).toMatchObject([ {name: 'sanity/comments'}, {name: 'sanity/tasks'}, + {name: 'sanity/releases'}, ]) }) }) diff --git a/packages/sanity/src/core/config/resolveDefaultPlugins.ts b/packages/sanity/src/core/config/resolveDefaultPlugins.ts index 2e79260b243..8bd65115703 100644 --- a/packages/sanity/src/core/config/resolveDefaultPlugins.ts +++ b/packages/sanity/src/core/config/resolveDefaultPlugins.ts @@ -1,4 +1,5 @@ import {comments} from '../comments/plugin' +import {releases, RELEASES_NAME} from '../releases/plugin' import {DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS} from '../scheduledPublishing/constants' import {SCHEDULED_PUBLISHING_NAME, scheduledPublishing} from '../scheduledPublishing/plugin' import {tasks, TASKS_NAME} from '../tasks/plugin' @@ -9,7 +10,7 @@ import { type WorkspaceOptions, } from './types' -const defaultPlugins = [comments(), tasks(), scheduledPublishing()] +const defaultPlugins = [comments(), tasks(), scheduledPublishing(), releases()] export function getDefaultPlugins( options: DefaultPluginsWorkspaceOptions, @@ -23,6 +24,9 @@ export function getDefaultPlugins( if (plugin.name === TASKS_NAME) { return options.tasks.enabled } + if (plugin.name === RELEASES_NAME) { + return options.releases.enabled + } return true }) } @@ -40,5 +44,9 @@ export function getDefaultPluginsOptions( ...DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS, ...workspace.scheduledPublishing, }, + releases: { + enabled: true, + ...workspace.releases, + }, } } diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 6f223b42e60..1955e78a4ac 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -449,6 +449,10 @@ export interface WorkspaceOptions extends SourceOptions { * @internal */ tasks?: DefaultPluginsWorkspaceOptions['tasks'] + /** + * @internal + */ + releases?: DefaultPluginsWorkspaceOptions['releases'] /** * @hidden @@ -892,6 +896,7 @@ export type { export type DefaultPluginsWorkspaceOptions = { tasks: {enabled: boolean} scheduledPublishing: ScheduledPublishingPluginOptions + releases: {enabled: boolean} } /** diff --git a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx index 0fb5b59c251..2b98c8c39af 100644 --- a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx +++ b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx @@ -88,6 +88,7 @@ export default function ChangeListStory() { rootDiff: diff, schemaType, value: {name: 'Test'}, + showFromValue: true, }), [diff, documentId, FieldWrapper, schemaType], ) diff --git a/packages/sanity/src/core/form/FormBuilderContext.ts b/packages/sanity/src/core/form/FormBuilderContext.ts index be5b2f561f6..22c4b6358b0 100644 --- a/packages/sanity/src/core/form/FormBuilderContext.ts +++ b/packages/sanity/src/core/form/FormBuilderContext.ts @@ -58,4 +58,5 @@ export interface FormBuilderContextValue { renderItem: RenderItemCallback renderPreview: RenderPreviewCallback schemaType: ObjectSchemaType + version?: string } diff --git a/packages/sanity/src/core/form/FormBuilderProvider.tsx b/packages/sanity/src/core/form/FormBuilderProvider.tsx index b14969c90a0..50d7fefa30a 100644 --- a/packages/sanity/src/core/form/FormBuilderProvider.tsx +++ b/packages/sanity/src/core/form/FormBuilderProvider.tsx @@ -65,6 +65,7 @@ export interface FormBuilderProviderProps { schemaType: ObjectSchemaType unstable?: Source['form']['unstable'] validation: ValidationMarker[] + version?: string } const missingPatchChannel: PatchChannel = { @@ -113,6 +114,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { schemaType, unstable, validation, + version, } = props const __internal: FormBuilderContextValue['__internal'] = useMemo( @@ -171,6 +173,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, }), [ __internal, @@ -191,6 +194,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, ], ) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index 3aaf9ed11a8..11c516d1393 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -25,6 +25,7 @@ import {useDidUpdate} from '../../hooks/useDidUpdate' import {useScrollIntoViewOnFocusWithin} from '../../hooks/useScrollIntoViewOnFocusWithin' import {set, unset} from '../../patch' import {type ObjectFieldProps, type RenderPreviewCallback} from '../../types' +import {useFormBuilder} from '../../useFormBuilder' import {PreviewReferenceValue} from './PreviewReferenceValue' import {ReferenceFinalizeAlertStrip} from './ReferenceFinalizeAlertStrip' import {ReferenceLinkCard} from './ReferenceLinkCard' @@ -61,6 +62,7 @@ export function ReferenceField(props: ReferenceFieldProps) { const elementRef = useRef(null) const {schemaType, path, open, inputId, children, inputProps} = props const {readOnly, focused, renderPreview, onChange} = props.inputProps + const {version} = useFormBuilder() const [fieldActionsNodes, setFieldActionNodes] = useState([]) const documentId = usePublishedId() @@ -73,6 +75,7 @@ export function ReferenceField(props: ReferenceFieldProps) { path, schemaType, value, + version, }) // this is here to make sure the item is visible if it's being edited behind a modal diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx index 2703be12ed6..a254fd43fc2 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx @@ -52,6 +52,7 @@ export function ReferenceInput(props: ReferenceInputProps) { id, onPathFocus, value, + version, renderPreview, path, elementProps, @@ -62,6 +63,7 @@ export function ReferenceInput(props: ReferenceInputProps) { path, schemaType, value, + version, }) const [searchState, setSearchState] = useState(INITIAL_SEARCH_STATE) @@ -187,6 +189,7 @@ export function ReferenceInput(props: ReferenceInputProps) { const renderOption = useCallback( (option: AutocompleteOption) => { + // TODO: Account for checked-out version. const documentId = option.hit.draft?._id || option.hit.published?._id || option.value return ( @@ -205,6 +208,7 @@ export function ReferenceInput(props: ReferenceInputProps) { const renderValue = useCallback(() => { return ( + loadableReferenceInfo.result?.preview.version?.title || loadableReferenceInfo.result?.preview.draft?.title || loadableReferenceInfo.result?.preview.published?.title || '' @@ -212,6 +216,7 @@ export function ReferenceInput(props: ReferenceInputProps) { }, [ loadableReferenceInfo.result?.preview.draft?.title, loadableReferenceInfo.result?.preview.published?.title, + loadableReferenceInfo.result?.preview.version?.title, ]) const handleFocus = useCallback(() => onPathFocus(['_ref']), [onPathFocus]) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx index 9371f6275a4..c12802a7b2a 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx @@ -27,6 +27,7 @@ export function ReferencePreview(props: { const documentPresence = useDocumentPresence(id) const previewId = + preview.version?._id || preview.draft?._id || preview.published?._id || // note: during publish of the referenced document we might have both a missing draft and a missing published version @@ -44,7 +45,7 @@ export function ReferencePreview(props: { [previewId, refType.name], ) - const {draft, published} = preview + const {draft, published, version} = preview const previewProps = useMemo( () => ({ @@ -57,13 +58,17 @@ export function ReferencePreview(props: { )} - + ), layout, schemaType: refType, - tooltip: , + tooltip: , value: previewStub, }), [ @@ -72,10 +77,12 @@ export function ReferencePreview(props: { layout, preview.draft, preview.published, + preview.version, previewStub, published, refType, showTypeLabel, + version, ], ) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts index 933afbdb68d..4b8ffe6148f 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts @@ -18,6 +18,7 @@ export interface ReferenceInfo { preview: { draft: (PreviewValue & {_id: string; _createdAt?: string; _updatedAt?: string}) | undefined published: (PreviewValue & {_id: string; _createdAt?: string; _updatedAt?: string}) | undefined + version: (PreviewValue & {_id: string; _createdAt?: string; _updatedAt?: string}) | undefined } } @@ -76,4 +77,5 @@ export interface ReferenceInputProps onEditReference: (event: EditReferenceEvent) => void getReferenceInfo: (id: string, type: ReferenceSchemaType) => Observable + version?: string } diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx index 5e56965952f..4b9a99d9872 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -28,10 +28,11 @@ interface Options { path: Path schemaType: ReferenceSchemaType value?: Reference + version?: string } export function useReferenceInput(options: Options) { - const {path, schemaType} = options + const {path, schemaType, version} = options const schema = useSchema() const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = @@ -113,8 +114,8 @@ export function useReferenceInput(options: Options) { }, [disableNew, initialValueTemplateItems, schemaType.to]) const getReferenceInfo = useCallback( - (id: string) => adapter.getReferenceInfo(documentPreviewStore, id, schemaType), - [documentPreviewStore, schemaType], + (id: string) => adapter.getReferenceInfo(documentPreviewStore, id, schemaType, {version}), + [documentPreviewStore, schemaType, version], ) return { diff --git a/packages/sanity/src/core/form/studio/FormBuilder.tsx b/packages/sanity/src/core/form/studio/FormBuilder.tsx index 58e1f1d04bf..95c36662732 100644 --- a/packages/sanity/src/core/form/studio/FormBuilder.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilder.tsx @@ -64,6 +64,7 @@ export interface FormBuilderProps schemaType: ObjectSchemaType validation: ValidationMarker[] value: FormDocumentValue | undefined + version?: string } /** @@ -95,6 +96,7 @@ export function FormBuilder(props: FormBuilderProps) { schemaType, validation, value, + version, } = props const handleCollapseField = useCallback( @@ -273,6 +275,7 @@ export function FormBuilder(props: FormBuilderProps) { validation={validation} readOnly={readOnly} schemaType={schemaType} + version={version} > diff --git a/packages/sanity/src/core/form/studio/FormProvider.tsx b/packages/sanity/src/core/form/studio/FormProvider.tsx index 5e1fbc73a29..e43c957105c 100644 --- a/packages/sanity/src/core/form/studio/FormProvider.tsx +++ b/packages/sanity/src/core/form/studio/FormProvider.tsx @@ -55,6 +55,7 @@ export interface FormProviderProps { readOnly?: boolean schemaType: ObjectSchemaType validation: ValidationMarker[] + version?: string } /** @@ -86,6 +87,7 @@ export function FormProvider(props: FormProviderProps) { readOnly, schemaType, validation, + version, } = props const {file, image} = useSource().form @@ -164,6 +166,7 @@ export function FormProvider(props: FormProviderProps) { renderPreview={renderPreview} schemaType={schemaType} validation={validation} + version={version} > {children} diff --git a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts index 391ffaa5fc2..ca90d3bcfb2 100644 --- a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts +++ b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts @@ -31,22 +31,30 @@ export function getReferenceInfo( documentPreviewStore: DocumentPreviewStore, id: string, referenceType: ReferenceSchemaType, + {version}: {version?: string} = {}, ): Observable { - const {publishedId, draftId} = getIdPair(id) + const {publishedId, draftId, versionId} = getIdPair(id, {version}) - const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id) + const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id, { + version, + }) return pairAvailability$.pipe( switchMap((pairAvailability) => { - if (!pairAvailability.draft.available && !pairAvailability.published.available) { - // combine availability of draft + published + if ( + !pairAvailability.version?.available && + !pairAvailability.draft.available && + !pairAvailability.published.available + ) { + // combine availability of draft + published + version const availability = + pairAvailability.version?.reason === 'PERMISSION_DENIED' || pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND - // short circuit, neither draft nor published is available so no point in trying to get preview + // short circuit, neither draft nor published nor version is available so no point in trying to get preview return of({ id, type: undefined, @@ -54,19 +62,25 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, }, } as const) } const draftRef = {_type: 'reference', _ref: draftId} const publishedRef = {_type: 'reference', _ref: publishedId} + const versionRef = versionId ? {_type: 'reference', _ref: versionId} : undefined const typeName$ = combineLatest([ documentPreviewStore.observeDocumentTypeFromId(draftId), documentPreviewStore.observeDocumentTypeFromId(publishedId), + ...(versionId ? [documentPreviewStore.observeDocumentTypeFromId(versionId)] : []), ]).pipe( - // assume draft + published are always same type - map(([draftTypeName, publishedTypeName]) => draftTypeName || publishedTypeName), + // assume draft + published + version are always same type + map( + ([draftTypeName, publishedTypeName, versionTypeName]) => + versionTypeName || draftTypeName || publishedTypeName, + ), ) return typeName$.pipe( @@ -83,6 +97,7 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, }, } as const) } @@ -98,6 +113,7 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, }, } as const) } @@ -130,10 +146,30 @@ export function getReferenceInfo( startWith(undefined), ) - const value$ = combineLatest([draftPreview$, publishedPreview$]).pipe( - map(([draft, published]) => ({ + const versionPreview$ = + versionId && versionRef + ? documentPreviewStore.observePaths(versionRef, previewPaths).pipe( + map((result) => + result + ? { + _id: versionId, + ...prepareForPreview(result, refSchemaType), + } + : undefined, + ), + startWith(undefined), + ) + : undefined + + const value$ = combineLatest([ + draftPreview$, + publishedPreview$, + ...(versionPreview$ ? [versionPreview$] : []), + ]).pipe( + map(([draft, published, versionValue]) => ({ draft, published, + ...(versionValue ? {version: versionValue} : {}), })), ) @@ -141,9 +177,12 @@ export function getReferenceInfo( map((value): ReferenceInfo => { const availability = // eslint-disable-next-line no-nested-ternary - pairAvailability.draft.available || pairAvailability.published.available + pairAvailability.version?.available || + pairAvailability.draft.available || + pairAvailability.published.available ? READABLE - : pairAvailability.draft.reason === 'PERMISSION_DENIED' || + : pairAvailability.version?.reason === 'PERMISSION_DENIED' || + pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND @@ -154,6 +193,7 @@ export function getReferenceInfo( preview: { draft: isRecord(value.draft) ? value.draft : undefined, published: isRecord(value.published) ? value.published : undefined, + version: isRecord(value.version) ? value.version : undefined, }, } }), 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 ce26ad5c64f..5b65e387510 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -25,6 +25,7 @@ import { type EditReferenceEvent, } from '../../../inputs/ReferenceInput/types' import {type ObjectInputProps} from '../../../types' +import {useFormBuilder} from '../../../useFormBuilder' import {useReferenceInputOptions} from '../../contexts' import * as adapter from '../client-adapters/reference' import {resolveUserDefinedFilter} from './resolveUserDefinedFilter' @@ -61,6 +62,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { const schema = useSchema() const maxFieldDepth = useSearchMaxFieldDepth() const documentPreviewStore = useDocumentPreviewStore() + const {version} = useFormBuilder() const {path, schemaType} = props const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -192,6 +194,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { editReferenceLinkComponent={EditReferenceLink} createOptions={createOptions} onEditReference={handleEditReference} + version={version} /> ) } diff --git a/packages/sanity/src/core/form/types/fieldProps.ts b/packages/sanity/src/core/form/types/fieldProps.ts index e530df7bc60..c9095a2141b 100644 --- a/packages/sanity/src/core/form/types/fieldProps.ts +++ b/packages/sanity/src/core/form/types/fieldProps.ts @@ -61,6 +61,7 @@ export interface BaseFieldProps { changed: boolean children: ReactNode renderDefault: (props: FieldProps) => ReactElement + version?: string } /** diff --git a/packages/sanity/src/core/hooks/useConnectionState.ts b/packages/sanity/src/core/hooks/useConnectionState.ts index 2405544535b..b23971a9104 100644 --- a/packages/sanity/src/core/hooks/useConnectionState.ts +++ b/packages/sanity/src/core/hooks/useConnectionState.ts @@ -11,12 +11,16 @@ 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, + {version}: {version?: string} = {}, +): ConnectionState { const documentStore = useDocumentStore() const observable = useMemo( () => - documentStore.pair.documentEvents(publishedDocId, docTypeName).pipe( + documentStore.pair.documentEvents(publishedDocId, docTypeName, version).pipe( map((ev: {type: string}) => ev.type), map((eventType) => eventType !== 'reconnect'), switchMap((isConnected) => @@ -25,7 +29,7 @@ export function useConnectionState(publishedDocId: string, docTypeName: string): startWith(INITIAL as any), distinctUntilChanged(), ), - [docTypeName, documentStore.pair, publishedDocId], + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/hooks/useDocumentOperation.ts b/packages/sanity/src/core/hooks/useDocumentOperation.ts index 4e67302fd06..105de51f1b3 100644 --- a/packages/sanity/src/core/hooks/useDocumentOperation.ts +++ b/packages/sanity/src/core/hooks/useDocumentOperation.ts @@ -4,11 +4,15 @@ import {useObservable} from 'react-rx' import {type OperationsAPI, useDocumentStore} from '../store' /** @internal */ -export function useDocumentOperation(publishedDocId: string, docTypeName: string): OperationsAPI { +export function useDocumentOperation( + publishedDocId: string, + docTypeName: string, + version?: string, +): OperationsAPI { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.editOperations(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.editOperations(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) /** * We know that since the observable has a startWith operator, it will always emit a value diff --git a/packages/sanity/src/core/hooks/useEditState.ts b/packages/sanity/src/core/hooks/useEditState.ts index 6d2650b73cf..771f973f07e 100644 --- a/packages/sanity/src/core/hooks/useEditState.ts +++ b/packages/sanity/src/core/hooks/useEditState.ts @@ -9,12 +9,13 @@ export function useEditState( publishedDocId: string, docTypeName: string, priority: 'default' | 'low' = 'default', + version?: string, ): EditStateFor { const documentStore = useDocumentStore() const observable = useMemo(() => { if (priority === 'low') { - const base = documentStore.pair.editState(publishedDocId, docTypeName).pipe(share()) + const base = documentStore.pair.editState(publishedDocId, docTypeName, version).pipe(share()) return merge( base.pipe(take(1)), @@ -25,8 +26,8 @@ export function useEditState( ) } - return documentStore.pair.editState(publishedDocId, docTypeName) - }, [docTypeName, documentStore.pair, priority, publishedDocId]) + return documentStore.pair.editState(publishedDocId, docTypeName, version) + }, [docTypeName, documentStore.pair, priority, publishedDocId, version]) /** * We know that since the observable has a startWith operator, it will always emit a value * and that's why the non-null assertion is used here diff --git a/packages/sanity/src/core/hooks/useSyncState.ts b/packages/sanity/src/core/hooks/useSyncState.ts index 385d3205a25..65888a19558 100644 --- a/packages/sanity/src/core/hooks/useSyncState.ts +++ b/packages/sanity/src/core/hooks/useSyncState.ts @@ -14,15 +14,19 @@ const SYNCING = {isSyncing: true} const NOT_SYNCING = {isSyncing: false} /** @internal */ -export function useSyncState(publishedDocId: string, documentType: string): SyncState { +export function useSyncState( + publishedDocId: string, + documentType: string, + {version}: {version?: string} = {}, +): SyncState { const documentStore = useDocumentStore() const observable = useMemo( () => documentStore.pair - .consistencyStatus(publishedDocId, documentType) + .consistencyStatus(publishedDocId, documentType, version) .pipe(map((isConsistent) => (isConsistent ? NOT_SYNCING : SYNCING))), - [documentStore.pair, documentType, publishedDocId], + [documentStore.pair, documentType, publishedDocId, version], ) return useObservable>(observable, NOT_SYNCING) } diff --git a/packages/sanity/src/core/hooks/useValidationStatus.ts b/packages/sanity/src/core/hooks/useValidationStatus.ts index 165e059d932..2411b0eabe8 100644 --- a/packages/sanity/src/core/hooks/useValidationStatus.ts +++ b/packages/sanity/src/core/hooks/useValidationStatus.ts @@ -7,12 +7,16 @@ import {type ValidationStatus} from '../validation' const INITIAL: ValidationStatus = {validation: [], isValidating: false} /** @internal */ -export function useValidationStatus(publishedDocId: string, docTypeName: string): ValidationStatus { +export function useValidationStatus( + publishedDocId: string, + docTypeName: string, + version?: string, +): ValidationStatus { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.validation(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.validation(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts index e46949db912..05b3df9ac78 100644 --- a/packages/sanity/src/core/i18n/bundles/studio.ts +++ b/packages/sanity/src/core/i18n/bundles/studio.ts @@ -125,6 +125,10 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Text shown in usage dialog for an image asset when there are zero, one or more documents using the *unnamed* image **/ 'asset-source.usage-list.documents-using-image_unnamed_zero': 'No documents are using this image', + /** Label when a release has been deleted by a different user */ + 'banners.deleted-bundle-banner.text': + "The '{{title}}' release has been deleted.", + /** Action message for navigating to next month */ 'calendar.action.go-to-next-month': 'Go to next month', /** Action message for navigating to next year */ @@ -359,6 +363,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'document-status.not-published': 'Not published', /** Label to show in the document footer indicating the published date of the document */ 'document-status.published': 'Published {{date}}', + /** Label to show in the document footer indicating the revision from date of the document */ + 'document-status.revision-from': 'Revision from {{date}}', /** The value of the _key property must be a unique string. */ 'form.error.duplicate-keys-alert.details.additional-description': @@ -1132,6 +1138,36 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /* Relative time, just now */ 'relative-time.just-now': 'just now', + /** Action message to add document to release */ + 'release.action.add-to-release': 'Add to {{title}}', + /** Action message for when document is already in release */ + 'release.action.already-in-release': 'Already in release {{title}}', + /** Action message for creating releases */ + 'release.action.create': 'Create release', + /** Action message for when document is already in release */ + 'release.action.discard-version': 'Discard version', + /** Description for toast when version discarding failed */ + 'release.action.discard-version.failure': 'Failed to discard version', + /** Description for toast when version deletion is successfully discarded */ + 'release.action.discard-version.success': + '{{title}} version was successfully discarded', + /** Label for tooltip on deleted release */ + 'release.deleted-tooltip': 'This release has been deleted', + /** Title for creating releases dialog */ + 'release.dialog.create.title': 'Create release', + /** Title for editing releases dialog */ + 'release.dialog.edit.title': 'Edit release', + /** Label for the description form field when creating releases */ + 'release.form.description': 'Description', + /** Placeholder for the icon and colour picker */ + 'release.form.search-icon': 'Search icons', + /** Tooltip label for the icon display */ + 'release.form.search-icon-tooltip': 'Select release icon', + /** Label for the title form field when creating releases */ + 'release.form.title': 'Title', + /** Tooltip for the dropdown to show all versions of document */ + 'release.version-list.tooltip': 'See all document versions', + /** Accessibility label to open search action when the search would go fullscreen (eg on narrower screens) */ 'search.action-open-aria-label': 'Open search', /** Action label for adding a search filter */ diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index 83140342883..fc5c99bba0a 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -21,6 +21,15 @@ export * from './hooks' export * from './i18n' export * from './presence' export * from './preview' +export { + getDocumentIsInPerspective, + LATEST, + ReleaseActions, + ReleaseBadge, + ReleasesMenu, + useDocumentVersions, + usePerspective, +} from './releases' export * from './scheduledPublishing' export * from './schema' export type {SearchFactoryOptions, SearchOptions, SearchSort, SearchTerms} from './search' diff --git a/packages/sanity/src/core/preview/availability.ts b/packages/sanity/src/core/preview/availability.ts index 62965cc2871..57ca560882d 100644 --- a/packages/sanity/src/core/preview/availability.ts +++ b/packages/sanity/src/core/preview/availability.ts @@ -6,7 +6,7 @@ import {combineLatest, defer, from, type Observable, of} from 'rxjs' import {distinctUntilChanged, map, mergeMap, reduce, switchMap} from 'rxjs/operators' import shallowEquals from 'shallow-equals' -import {getDraftId, getPublishedId, isRecord} from '../util' +import {getDraftId, getPublishedId, getVersionId, isRecord, isVersionId} from '../util' import { AVAILABILITY_NOT_FOUND, AVAILABILITY_PERMISSION_DENIED, @@ -139,18 +139,26 @@ export function createPreviewAvailabilityObserver( */ return function observeDocumentPairAvailability( id: string, + {version}: {version?: string} = {}, ): Observable { const draftId = getDraftId(id) const publishedId = getPublishedId(id) + const versionId = isVersionId(id) && version ? getVersionId(id, version) : undefined return combineLatest([ observeDocumentAvailability(draftId), observeDocumentAvailability(publishedId), + ...(versionId ? [observeDocumentAvailability(versionId)] : []), ]).pipe( distinctUntilChanged(shallowEquals), - map(([draftReadability, publishedReadability]) => { + map(([draftReadability, publishedReadability, versionReadability]) => { return { draft: draftReadability, published: publishedReadability, + ...(versionReadability + ? { + version: versionReadability, + } + : {}), } }), ) diff --git a/packages/sanity/src/core/preview/createGlobalListener.ts b/packages/sanity/src/core/preview/createGlobalListener.ts index ac76417b386..7be43f93e31 100644 --- a/packages/sanity/src/core/preview/createGlobalListener.ts +++ b/packages/sanity/src/core/preview/createGlobalListener.ts @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) { includePreviousRevision: false, includeMutations: false, visibility: 'query', + effectFormat: 'mendoza', tag: 'preview.global', }, ) diff --git a/packages/sanity/src/core/preview/createObserveDocument.ts b/packages/sanity/src/core/preview/createObserveDocument.ts new file mode 100644 index 00000000000..6b384a7fbf8 --- /dev/null +++ b/packages/sanity/src/core/preview/createObserveDocument.ts @@ -0,0 +1,87 @@ +import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {memoize, uniq} from 'lodash' +import {EMPTY, finalize, type Observable, of} from 'rxjs' +import {concatMap, map, scan, shareReplay} from 'rxjs/operators' + +import {type ApiConfig} from './types' +import {applyMendozaPatch} from './utils/applyMendozaPatch' +import {debounceCollect} from './utils/debounceCollect' + +export function createObserveDocument({ + mutationChannel, + client, +}: { + client: SanityClient + mutationChannel: Observable +}) { + const getBatchFetcher = memoize( + function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) { + const _client = client.withConfig(apiConfig) + + function batchFetchDocuments(ids: [string][]) { + return _client.observable + .fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'}) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))), + ) + } + return debounceCollect(batchFetchDocuments, 100) + }, + (apiConfig) => apiConfig.dataset + apiConfig.projectId, + ) + + const MEMO: Record> = {} + + function observeDocument(id: string, apiConfig?: ApiConfig) { + const _apiConfig = apiConfig || { + dataset: client.config().dataset!, + projectId: client.config().projectId!, + } + const fetchDocument = getBatchFetcher(_apiConfig) + return mutationChannel.pipe( + concatMap((event) => { + if (event.type === 'welcome') { + return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document}))) + } + return event.documentId === id ? of(event) : EMPTY + }), + scan((current: SanityDocument | undefined, event) => { + if (event.type === 'sync') { + return event.document + } + if (event.type === 'mutation') { + return applyMutationEvent(current, event) + } + //@ts-expect-error - this should never happen + throw new Error(`Unexpected event type: "${event.type}"`) + }, undefined), + ) + } + return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) { + const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id + if (!(key in MEMO)) { + MEMO[key] = observeDocument(id, apiConfig).pipe( + finalize(() => delete MEMO[key]), + shareReplay({bufferSize: 1, refCount: true}), + ) + } + return MEMO[key] + } +} + +function applyMutationEvent(current: SanityDocument | undefined, event: MutationEvent) { + if (event.previousRev !== current?._rev) { + console.warn('Document out of sync, skipping mutation') + return current + } + if (!event.effects) { + throw new Error( + 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', + ) + } + const next = applyMendozaPatch(current, event.effects.apply) + // next will be undefined in case of deletion + return next ? {...next, _rev: event.resultRev} : undefined +} diff --git a/packages/sanity/src/core/preview/createPreviewObserver.ts b/packages/sanity/src/core/preview/createPreviewObserver.ts index 49b294f961c..20bde34d05f 100644 --- a/packages/sanity/src/core/preview/createPreviewObserver.ts +++ b/packages/sanity/src/core/preview/createPreviewObserver.ts @@ -42,6 +42,7 @@ export function createPreviewObserver(context: { value: Previewable, type: PreviewableType, options: { + perspective?: string viewOptions?: PrepareViewOptions apiConfig?: ApiConfig } = {}, diff --git a/packages/sanity/src/core/preview/documentPair.ts b/packages/sanity/src/core/preview/documentPair.ts index dae8945130e..1d618af1a38 100644 --- a/packages/sanity/src/core/preview/documentPair.ts +++ b/packages/sanity/src/core/preview/documentPair.ts @@ -19,15 +19,21 @@ export function createObservePathsDocumentPair(options: { ) => Observable> { const {observeDocumentPairAvailability, observePaths} = options - const ALWAYS_INCLUDED_SNAPSHOT_PATHS: PreviewPath[] = [['_updatedAt'], ['_createdAt'], ['_type']] + const ALWAYS_INCLUDED_SNAPSHOT_PATHS: PreviewPath[] = [ + ['_updatedAt'], + ['_createdAt'], + ['_type'], + ['_version'], + ] return function observePathsDocumentPair( id: string, paths: PreviewPath[], + {version}: {version?: string} = {}, ): Observable> { - const {draftId, publishedId} = getIdPair(id) + const {draftId, publishedId, versionId} = getIdPair(id, {version}) - return observeDocumentPairAvailability(draftId).pipe( + return observeDocumentPairAvailability(draftId, {version}).pipe( switchMap((availability) => { if (!availability.draft.available && !availability.published.available) { // short circuit, neither draft nor published is available so no point in trying to get a snapshot @@ -42,6 +48,14 @@ export function createObservePathsDocumentPair(options: { availability: availability.published, snapshot: undefined, }, + ...(availability.version + ? { + version: { + availability: availability.version, + snapshot: undefined, + }, + } + : {}), }) } @@ -50,10 +64,12 @@ export function createObservePathsDocumentPair(options: { return combineLatest([ observePaths({_type: 'reference', _ref: draftId}, snapshotPaths), observePaths({_type: 'reference', _ref: publishedId}, snapshotPaths), + ...(version ? [observePaths({_type: 'reference', _ref: versionId}, snapshotPaths)] : []), ]).pipe( - map(([draftSnapshot, publishedSnapshot]) => { + map(([draftSnapshot, publishedSnapshot, versionSnapshot]) => { // note: assume type is always the same const type = + (isRecord(versionSnapshot) && '_type' in versionSnapshot && versionSnapshot._type) || (isRecord(draftSnapshot) && '_type' in draftSnapshot && draftSnapshot._type) || (isRecord(publishedSnapshot) && '_type' in publishedSnapshot && @@ -71,6 +87,14 @@ export function createObservePathsDocumentPair(options: { availability: availability.published, snapshot: publishedSnapshot as T, }, + ...(availability.version + ? { + version: { + availability: availability.version, + snapshot: versionSnapshot as T, + }, + } + : {}), } }), ) diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index 154c7e56c26..8f21f972fef 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,14 +1,21 @@ -import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import { + type MutationEvent, + type QueryParams, + type SanityClient, + type WelcomeEvent, +} from '@sanity/client' import {type PrepareViewOptions, type SanityDocument} from '@sanity/types' -import {type Observable} from 'rxjs' +import {combineLatest, type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' import {isRecord} from '../util' import {createPreviewAvailabilityObserver} from './availability' import {createGlobalListener} from './createGlobalListener' +import {createObserveDocument} from './createObserveDocument' import {createPathObserver} from './createPathObserver' import {createPreviewObserver} from './createPreviewObserver' import {createObservePathsDocumentPair} from './documentPair' +import {createDocumentIdSetObserver, type DocumentIdSetObserverState} from './liveDocumentIdSet' import {createObserveFields} from './observeFields' import { type ApiConfig, @@ -50,12 +57,51 @@ export interface DocumentPreviewStore { */ unstable_observeDocumentPairAvailability: ( id: string, + options?: {version?: string}, ) => Observable unstable_observePathsDocumentPair: ( id: string, paths: PreviewPath[], + options?: {version?: string}, ) => Observable> + + /** + * Observes a set of document IDs that matches the given groq-filter. The document ids are returned in ascending order and will update in real-time + * Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be pushed to subscribers. + * The query is performed once, initially, and thereafter the set of ids are patched based on the `appear` and `disappear` + * transitions on the received listener events. + * This provides a lightweight way of subscribing to a list of ids for simple cases where you just want to subscribe to a set of documents ids + * that matches a particular filter. + * @hidden + * @beta + * @param filter - A groq filter to use for the document set + * @param params - Parameters to use with the groq filter + * @param options - Options for the observer + */ + unstable_observeDocumentIdSet: ( + filter: string, + params?: QueryParams, + options?: { + /** + * Where to insert new items into the set. Defaults to 'sorted' which is based on the lexicographic order of the id + */ + insert?: 'sorted' | 'prepend' | 'append' + }, + ) => Observable + + /** + * Observe a complete document with the given ID + * @hidden + * @beta + */ + unstable_observeDocument: (id: string) => Observable + /** + * Observe a list of complete documents with the given IDs + * @hidden + * @beta + */ + unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]> } /** @internal */ @@ -79,6 +125,8 @@ export function createDocumentPreviewStore({ map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)), ) + const observeDocument = createObserveDocument({client, mutationChannel: globalListener}) + const observeFields = createObserveFields({client: versionedClient, invalidationChannel}) const observePaths = createPathObserver({observeFields}) @@ -86,12 +134,16 @@ export function createDocumentPreviewStore({ id: string, apiConfig?: ApiConfig, ): Observable { - return observePaths({_type: 'reference', _ref: id}, ['_type'], apiConfig).pipe( + return observePaths({_type: 'reference', _ref: id}, ['_type', '_version'], apiConfig).pipe( map((res) => (isRecord(res) && typeof res._type === 'string' ? res._type : undefined)), distinctUntilChanged(), ) } + const observeDocumentIdSet = createDocumentIdSetObserver( + versionedClient.withConfig({apiVersion: 'X'}), + ) + const observeForPreview = createPreviewObserver({observeDocumentTypeFromId, observePaths}) const observeDocumentPairAvailability = createPreviewAvailabilityObserver( versionedClient, @@ -110,6 +162,10 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, + unstable_observeDocumentIdSet: observeDocumentIdSet, + unstable_observeDocument: observeDocument, + unstable_observeDocuments: (ids: string[]) => + combineLatest(ids.map((id) => observeDocument(id))), unstable_observeDocumentPairAvailability: observeDocumentPairAvailability, unstable_observePathsDocumentPair: observePathsDocumentPair, } diff --git a/packages/sanity/src/core/preview/index.ts b/packages/sanity/src/core/preview/index.ts index 5a283024f4d..7e9a3043d57 100644 --- a/packages/sanity/src/core/preview/index.ts +++ b/packages/sanity/src/core/preview/index.ts @@ -3,6 +3,7 @@ export * from './components/PreviewLoader' export * from './components/SanityDefaultPreview' export * from './documentPreviewStore' export * from './types' +export {useObserveDocument as unstable_useObserveDocument} from './useObserveDocument' export * from './useValuePreview' export {getPreviewPaths} from './utils/getPreviewPaths' export {getPreviewStateObservable} from './utils/getPreviewStateObservable' diff --git a/packages/sanity/src/core/preview/liveDocumentIdSet.ts b/packages/sanity/src/core/preview/liveDocumentIdSet.ts new file mode 100644 index 00000000000..49d8401cde1 --- /dev/null +++ b/packages/sanity/src/core/preview/liveDocumentIdSet.ts @@ -0,0 +1,112 @@ +import {type QueryParams, type SanityClient} from '@sanity/client' +import {sortedIndex} from 'lodash' +import {of} from 'rxjs' +import {distinctUntilChanged, filter, map, mergeMap, scan, tap} from 'rxjs/operators' + +export type DocumentIdSetObserverState = { + status: 'reconnecting' | 'connected' + documentIds: string[] +} + +interface LiveDocumentIdSetOptions { + insert?: 'sorted' | 'prepend' | 'append' +} + +export function createDocumentIdSetObserver(client: SanityClient) { + return function observe( + queryFilter: string, + params?: QueryParams, + options: LiveDocumentIdSetOptions = {}, + ) { + const {insert: insertOption = 'sorted'} = options + + const query = `*[${queryFilter}]._id` + function fetchFilter() { + return client.observable + .fetch(query, params, { + tag: 'preview.observe-document-set.fetch', + }) + .pipe( + tap((result) => { + if (!Array.isArray(result)) { + throw new Error( + `Expected query to return array of documents, but got ${typeof result}`, + ) + } + }), + ) + } + return client.observable + .listen(query, params, { + visibility: 'transaction', + events: ['welcome', 'mutation', 'reconnect'], + includeResult: false, + includeMutations: false, + tag: 'preview.observe-document-set.listen', + }) + .pipe( + mergeMap((event) => { + return event.type === 'welcome' + ? fetchFilter().pipe(map((result) => ({type: 'fetch' as const, result}))) + : of(event) + }), + scan( + ( + state: DocumentIdSetObserverState | undefined, + event, + ): DocumentIdSetObserverState | undefined => { + if (event.type === 'reconnect') { + return { + documentIds: state?.documentIds || [], + ...state, + status: 'reconnecting' as const, + } + } + if (event.type === 'fetch') { + return {...state, status: 'connected' as const, documentIds: event.result} + } + if (event.type === 'mutation') { + if (event.transition === 'update') { + // ignore updates, as we're only interested in documents appearing and disappearing from the set + return state + } + if (event.transition === 'appear') { + return { + status: 'connected', + documentIds: insert(state?.documentIds || [], event.documentId, insertOption), + } + } + if (event.transition === 'disappear') { + return { + status: 'connected', + documentIds: state?.documentIds + ? state.documentIds.filter((id) => id !== event.documentId) + : [], + } + } + } + return state + }, + undefined, + ), + distinctUntilChanged(), + filter( + (state: DocumentIdSetObserverState | undefined): state is DocumentIdSetObserverState => + state !== undefined, + ), + ) + } +} + +function insert(array: T[], element: T, strategy: 'sorted' | 'prepend' | 'append') { + let index + if (strategy === 'prepend') { + index = 0 + } else if (strategy === 'append') { + index = array.length + } else { + index = sortedIndex(array, element) + } + + return array.toSpliced(index, 0, element) +} diff --git a/packages/sanity/src/core/preview/types.ts b/packages/sanity/src/core/preview/types.ts index 0adca210afe..f89e5288e4d 100644 --- a/packages/sanity/src/core/preview/types.ts +++ b/packages/sanity/src/core/preview/types.ts @@ -91,6 +91,11 @@ export interface DraftsModelDocumentAvailability { * document readability for the draft document */ draft: DocumentAvailability + + /** + * document readability for the version document + */ + version?: DocumentAvailability } /** @@ -107,6 +112,10 @@ export interface DraftsModelDocument + ( + id: string, + options?: {version?: string}, + ): Observable<{ + draft: DocumentAvailability + published: DocumentAvailability + version?: DocumentAvailability + }> } diff --git a/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts new file mode 100644 index 00000000000..2fa3eff626d --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts @@ -0,0 +1,47 @@ +import {type QueryParams} from '@sanity/client' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {scan} from 'rxjs/operators' + +import {useDocumentPreviewStore} from '../store/_legacy/datastores' +import {type DocumentIdSetObserverState} from './liveDocumentIdSet' + +const INITIAL_STATE = {status: 'loading' as const, documentIds: []} + +export type LiveDocumentSetState = + | {status: 'loading'; documentIds: string[]} + | DocumentIdSetObserverState + +/** + * @internal + * @beta + * Returns document ids that matches the provided GROQ-filter, and loading state + * The document ids are returned in ascending order and will update in real-time + * Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be returned. + * This provides a lightweight way of subscribing to a list of ids for simple cases where you just want the documents ids + * that matches a particular filter. + */ +export function useLiveDocumentIdSet( + filter: string, + params?: QueryParams, + options: { + // how to insert new document ids. Defaults to `sorted` + insert?: 'sorted' | 'prepend' | 'append' + } = {}, +) { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo( + () => + documentPreviewStore.unstable_observeDocumentIdSet(filter, params, options).pipe( + scan( + (currentState: LiveDocumentSetState, nextState) => ({ + ...currentState, + ...nextState, + }), + INITIAL_STATE, + ), + ), + [documentPreviewStore, filter, params, options], + ) + return useObservable(observable, INITIAL_STATE) +} diff --git a/packages/sanity/src/core/preview/useLiveDocumentSet.ts b/packages/sanity/src/core/preview/useLiveDocumentSet.ts new file mode 100644 index 00000000000..16c5c27be24 --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentSet.ts @@ -0,0 +1,34 @@ +import {type QueryParams} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {map} from 'rxjs/operators' +import {mergeMapArray} from 'rxjs-mergemap-array' + +import {useDocumentPreviewStore} from '../store' + +const INITIAL_VALUE = {loading: true, documents: []} + +/** + * @internal + * @beta + * + * Observes a set of documents matching the filter and returns an array of complete documents + * A new array will be pushed whenever a document in the set changes + * Document ids are returned in ascending order + * Any sorting beyond that must happen client side + */ +export function useLiveDocumentSet( + groqFilter: string, + params?: QueryParams, +): {loading: boolean; documents: SanityDocument[]} { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo(() => { + return documentPreviewStore.unstable_observeDocumentIdSet(groqFilter, params).pipe( + map((state) => (state.documentIds || []) as string[]), + mergeMapArray((id) => documentPreviewStore.unstable_observeDocument(id)), + map((docs) => ({loading: false, documents: docs as SanityDocument[]})), + ) + }, [documentPreviewStore, groqFilter, params]) + return useObservable(observable, INITIAL_VALUE) +} diff --git a/packages/sanity/src/core/preview/useObserveDocument.ts b/packages/sanity/src/core/preview/useObserveDocument.ts new file mode 100644 index 00000000000..7d386265984 --- /dev/null +++ b/packages/sanity/src/core/preview/useObserveDocument.ts @@ -0,0 +1,32 @@ +import {type SanityDocument} from '@sanity/types' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {map} from 'rxjs/operators' + +import {useDocumentPreviewStore} from '../store/_legacy/datastores' + +const INITIAL_STATE = {loading: true, document: null} + +/** + * @internal + * @beta + * + * Observes a document by its ID and returns the document and loading state + * it will listen to the document changes. + */ +export function useObserveDocument( + documentId: string, +): { + document: T | null + loading: boolean +} { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo( + () => + documentPreviewStore + .unstable_observeDocument(documentId) + .pipe(map((document) => ({loading: false, document: document as T}))), + [documentId, documentPreviewStore], + ) + return useObservable(observable, INITIAL_STATE) +} diff --git a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts new file mode 100644 index 00000000000..0c1be69450c --- /dev/null +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -0,0 +1,18 @@ +import {type SanityDocument} from '@sanity/types' +import {applyPatch, type RawPatch} from 'mendoza' + +function omitRev(document: SanityDocument | undefined) { + if (document === undefined) { + return undefined + } + const {_rev, ...doc} = document + return doc +} + +export function applyMendozaPatch( + document: SanityDocument | undefined, + patch: RawPatch, +): SanityDocument | undefined { + const next = applyPatch(omitRev(document), patch) + return next === null ? undefined : next +} diff --git a/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts b/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts index 81f277ec0ac..1f2540a54e4 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts @@ -32,6 +32,7 @@ describe('getPreviewPaths', () => { ['image'], ['_createdAt'], ['_updatedAt'], + ['_version'], ]) }) }) diff --git a/packages/sanity/src/core/preview/utils/getPreviewPaths.ts b/packages/sanity/src/core/preview/utils/getPreviewPaths.ts index 5ef09e29fb2..ccb6c2af8b2 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewPaths.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewPaths.ts @@ -1,6 +1,6 @@ import {type PreviewableType, type PreviewPath} from '../types' -const DEFAULT_PREVIEW_PATHS: PreviewPath[] = [['_createdAt'], ['_updatedAt']] +const DEFAULT_PREVIEW_PATHS: PreviewPath[] = [['_createdAt'], ['_updatedAt'], ['_version']] /** @internal */ export function getPreviewPaths(preview: PreviewableType['preview']): PreviewPath[] | undefined { diff --git a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts index f37e7490868..2351a65262b 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -3,13 +3,14 @@ import {type ReactNode} from 'react' import {combineLatest, type Observable, of} from 'rxjs' import {map, startWith} from 'rxjs/operators' -import {getDraftId, getPublishedId} from '../../util/draftUtils' +import {getDraftId, getPublishedId, getVersionId} from '../../util/draftUtils' import {type DocumentPreviewStore} from '../documentPreviewStore' export interface PreviewState { isLoading?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + version?: PreviewValue | Partial | null } const isLiveEditEnabled = (schemaType: SchemaType) => schemaType.liveEdit === true @@ -24,22 +25,31 @@ export function getPreviewStateObservable( schemaType: SchemaType, documentId: string, title: ReactNode, + perspective?: string, ): Observable { const draft$ = isLiveEditEnabled(schemaType) ? of({snapshot: null}) : documentPreviewStore.observeForPreview({_id: getDraftId(documentId)}, schemaType) + const version$ = perspective + ? documentPreviewStore.observeForPreview( + {_id: getVersionId(documentId, perspective)}, + schemaType, + ) + : of({snapshot: null}) + const published$ = documentPreviewStore.observeForPreview( {_id: getPublishedId(documentId)}, schemaType, ) - return combineLatest([draft$, published$]).pipe( - map(([draft, published]) => ({ + return combineLatest([draft$, published$, version$]).pipe( + map(([draft, published, version]) => ({ draft: draft.snapshot ? {title, ...(draft.snapshot || {})} : null, isLoading: false, published: published.snapshot ? {title, ...(published.snapshot || {})} : null, + version: version.snapshot ? {title, ...(version.snapshot || {})} : null, })), - startWith({draft: null, isLoading: true, published: null}), + startWith({draft: null, isLoading: true, published: null, version: null}), ) } diff --git a/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx b/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx index 7f41bbbd800..ba4ca303e38 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx +++ b/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx @@ -2,6 +2,8 @@ import {WarningOutlineIcon} from '@sanity/icons' import {type PreviewValue, type SanityDocument} from '@sanity/types' import {assignWith} from 'lodash' +import {resolveBundlePerspective} from '../../util' + const getMissingDocumentFallback = (item: SanityDocument) => ({ title: {item.title ? String(item.title) : 'Missing document'}, subtitle: {item.title ? `Missing document ID: ${item._id}` : `Document ID: ${item._id}`}, @@ -18,12 +20,27 @@ export const getPreviewValueWithFallback = ({ value, draft, published, + version, + perspective, }: { value: SanityDocument draft?: Partial | PreviewValue | null published?: Partial | PreviewValue | null + version?: Partial | PreviewValue | null + perspective?: string }) => { - const snapshot = draft || published + let snapshot: Partial | PreviewValue | null | undefined + + switch (true) { + case typeof resolveBundlePerspective(perspective) !== 'undefined': + snapshot = version || draft || published + break + case perspective === 'published': + snapshot = published || draft + break + default: + snapshot = draft || published + } if (!snapshot) { return getMissingDocumentFallback(value) diff --git a/packages/sanity/src/core/preview/utils/prepareForPreview.ts b/packages/sanity/src/core/preview/utils/prepareForPreview.ts index f07f16f421b..aeb11dcb7d2 100644 --- a/packages/sanity/src/core/preview/utils/prepareForPreview.ts +++ b/packages/sanity/src/core/preview/utils/prepareForPreview.ts @@ -2,6 +2,7 @@ import { isTitledListValue, type PrepareViewOptions, type PreviewValue, + type SanityDocument, type SchemaType, type TitledListValue, } from '@sanity/types' @@ -13,7 +14,7 @@ import {type PreviewableType} from '../types' import {keysOf} from './keysOf' import {extractTextFromBlocks, isPortableTextPreviewValue} from './portableText' -const PRESERVE_KEYS = ['_id', '_type', '_upload', '_createdAt', '_updatedAt'] +const PRESERVE_KEYS = ['_id', '_type', '_upload', '_createdAt', '_updatedAt', '_version'] const EMPTY: never[] = [] type SelectedValue = Record @@ -263,7 +264,7 @@ export function prepareForPreview( rawValue: unknown, type: PreviewableType, viewOptions: PrepareViewOptions = {}, -): PreviewValue & {_createdAt?: string; _updatedAt?: string} { +): PreviewValue & {_createdAt?: string; _updatedAt?: string} & Pick { const hasCustomPrepare = typeof type.preview?.prepare === 'function' const selection: Record = type.preview?.select || {} const targetKeys = Object.keys(selection) diff --git a/packages/sanity/src/core/preview/utils/replayLatest.test.ts b/packages/sanity/src/core/preview/utils/replayLatest.test.ts new file mode 100644 index 00000000000..03796d77b38 --- /dev/null +++ b/packages/sanity/src/core/preview/utils/replayLatest.test.ts @@ -0,0 +1,37 @@ +import {expect, test} from '@jest/globals' +import {concat, from, lastValueFrom, of, share, timer} from 'rxjs' +import {concatMap, delay, mergeMap, take, toArray} from 'rxjs/operators' + +import {shareReplayLatest} from './shareReplayLatest' + +test('replayLatest() replays matching value to new subscribers', async () => { + const observable = from(['foo', 'bar', 'baz']).pipe( + concatMap((value) => of(value).pipe(delay(100))), + share(), + shareReplayLatest((v) => v === 'foo'), + ) + + const result = observable.pipe( + mergeMap((value) => + value === 'bar' ? concat(of(value), observable.pipe(take(1))) : of(value), + ), + toArray(), + ) + expect(await lastValueFrom(result)).toEqual(['foo', 'bar', 'foo', 'baz']) +}) + +test('replayLatest() doesnt keep the replay value after resets', async () => { + const observable = timer(0, 10).pipe( + shareReplayLatest({ + resetOnRefCountZero: true, + resetOnComplete: true, + predicate: (v) => v < 2, + }), + ) + + const result = observable.pipe(take(5), toArray()) + expect(await lastValueFrom(result)).toEqual([0, 1, 2, 3, 4]) + + const resultAfter = observable.pipe(take(5), toArray()) + expect(await lastValueFrom(resultAfter)).toEqual([0, 1, 2, 3, 4]) +}) diff --git a/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts new file mode 100644 index 00000000000..35b678ed4de --- /dev/null +++ b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts @@ -0,0 +1,84 @@ +import {defineEvent} from '@sanity/telemetry' + +interface VersionInfo { + /** + * document type that was added + */ + schemaType: string + + /** + * the origin of the version created (from a draft or from a version) + */ + documentOrigin: 'draft' | 'version' +} + +export interface OriginInfo { + /** + * determines where the release was created, either from the structure view or the release plugin + */ + origin: 'structure' | 'release-plugin' +} + +/** + * When a document (version) is successfully added to a release + * @internal + */ +export const AddedVersion = defineEvent({ + name: 'Add version of document to release', + version: 1, + description: 'User added a document to a release', +}) + +/** When a release is successfully created + * @internal + */ +export const CreatedRelease = defineEvent({ + name: 'Create release', + version: 1, + description: 'User created a release', +}) + +/** When a release is successfully updated + * @internal + */ +export const UpdatedRelease = defineEvent({ + name: 'Update release', + version: 1, + description: 'User updated a release', +}) + +/** When a release is successfully deleted + * @internal + */ +export const DeletedRelease = defineEvent({ + name: 'Delete release', + version: 1, + description: 'User deleted a release', +}) + +/** When a release is successfully published + * @internal + */ +export const PublishedRelease = defineEvent({ + name: 'Publish release', + version: 1, + description: 'User published a release', +}) + +/** When a release is successfully archived + * @internal + */ +export const ArchivedRelease = defineEvent({ + name: 'Archive release', + version: 1, + description: 'User archived a release', +}) + +/** When a release is successfully unarchived + * @internal + */ +export const UnarchivedRelease = defineEvent({ + name: 'Unarchive release', + version: 1, + description: 'User unarchived a release', +}) diff --git a/packages/sanity/src/core/releases/components/ReleaseBadge.tsx b/packages/sanity/src/core/releases/components/ReleaseBadge.tsx new file mode 100644 index 00000000000..d7554a4b0c3 --- /dev/null +++ b/packages/sanity/src/core/releases/components/ReleaseBadge.tsx @@ -0,0 +1,83 @@ +import {type ColorHueKey, hues} from '@sanity/color' +import {ChevronDownIcon, Icon, type IconSymbol} from '@sanity/icons' +import {Box, Flex, rgba, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {css, styled} from 'styled-components' + +import {Tooltip} from '../../../ui-components' +import {type BundleDocument} from '../../store/bundles/types' + +const BadgeRoot = styled(Flex)<{ + $hue: ColorHueKey + $isDisabled: boolean +}>((props) => { + const {color} = getTheme_v2(props.theme) + const hue: ColorHueKey = props.$hue + + return css` + --card-bg-color: ${rgba(hues[hue][color._dark ? 700 : 300].hex, 0.2)}; + --card-fg-color: ${hues[hue][color._dark ? 400 : 600].hex}; + --card-icon-color: ${hues[hue][color._dark ? 400 : 600].hex}; + background-color: var(--card-bg-color); + border-radius: 9999px; + opacity: ${props.$isDisabled ? 0.5 : 1}; + ` +}) +/** + * @internal + */ +export function ReleaseBadge( + props: Partial< + BundleDocument & { + icon: IconSymbol + hue: ColorHueKey + openButton: boolean + padding: number + title: string + isDisabled: boolean + } + >, +): JSX.Element { + const {hue = 'gray', icon, openButton, padding = 3, title, isDisabled = false} = props + + return ( + + {icon && ( + + + + + + )} + {title && ( + + + + {title} + + + + )} + {openButton && ( + + + + + + )} + + ) +} diff --git a/packages/sanity/src/core/releases/components/ReleasesMenu.tsx b/packages/sanity/src/core/releases/components/ReleasesMenu.tsx new file mode 100644 index 00000000000..84ea89f21bf --- /dev/null +++ b/packages/sanity/src/core/releases/components/ReleasesMenu.tsx @@ -0,0 +1,165 @@ +import {CheckmarkIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- MenuItem requires props, only supported by @sanity/ui +import {Box, Flex, Menu, MenuDivider, MenuItem, Spinner, Text} from '@sanity/ui' +import {memo, type ReactElement, useCallback, useMemo} from 'react' +import {styled} from 'styled-components' + +import {MenuButton, Tooltip} from '../../../ui-components' +import {useTranslation} from '../../i18n' +import {type BundleDocument} from '../../store/bundles/types' +import {useBundles} from '../../store/bundles/useBundles' +import {usePerspective} from '../hooks' +import {LATEST} from '../util/const' +import {isDraftOrPublished} from '../util/util' +import {ReleaseBadge} from './ReleaseBadge' + +const StyledMenu = styled(Menu)` + min-width: 200px; +` + +const StyledBox = styled(Box)` + overflow: auto; + max-height: 200px; +` + +interface BundleListProps { + button: ReactElement + bundles: BundleDocument[] | null + loading: boolean + actions?: ReactElement + perspective?: string +} + +/** + * @internal + */ +export const ReleasesMenu = memo(function ReleasesMenu(props: BundleListProps): ReactElement { + const {bundles, loading, actions, button, perspective} = props + const {deletedBundles} = useBundles() + const {currentGlobalBundle, setPerspective} = usePerspective(perspective) + const {t} = useTranslation() + + const sortedBundlesToDisplay = useMemo(() => { + if (!bundles) return [] + + return bundles + .filter(({_id, archivedAt}) => !isDraftOrPublished(_id) && !archivedAt) + .sort(({_id: aId}, {_id: bId}) => Number(deletedBundles[aId]) - Number(deletedBundles[bId])) + }, [bundles, deletedBundles]) + const hasBundles = sortedBundlesToDisplay.length > 0 + + const handleBundleChange = useCallback( + (bundleId: string) => () => { + setPerspective(bundleId) + }, + [setPerspective], + ) + + const isBundleDeleted = useCallback( + (bundleId: string) => Boolean(deletedBundles[bundleId]), + [deletedBundles], + ) + + return ( + <> + + {loading ? ( + + + + ) : ( + <> + + ) : undefined + } + onClick={handleBundleChange('drafts')} + pressed={false} + text={LATEST.title} + data-testid="latest-menu-item" + /> + {hasBundles && ( + <> + + + {sortedBundlesToDisplay.map((bundle) => ( + + + + + + + + {bundle.title} + + + + {/* + + {bundle.publishAt ? ( + + ) : ( + 'No target date' + )} + + */} + + + + + + + + + + ))} + + + )} + + {actions && ( + <> + + {actions} + + )} + + )} + + } + popover={{ + placement: 'bottom-start', + portal: true, + zOffset: 3000, + }} + /> + + ) +}) diff --git a/packages/sanity/src/core/releases/components/__tests__/ReleasesMenu.test.tsx b/packages/sanity/src/core/releases/components/__tests__/ReleasesMenu.test.tsx new file mode 100644 index 00000000000..27a8816fb9e --- /dev/null +++ b/packages/sanity/src/core/releases/components/__tests__/ReleasesMenu.test.tsx @@ -0,0 +1,280 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {fireEvent, render, screen, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {act} from 'react' +import {type BundleDocument, useBundles} from 'sanity' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import {Button} from '../../../../ui-components' +import {usePerspective} from '../../hooks/usePerspective' +import {LATEST} from '../../util/const' +import {ReleasesMenu} from '../ReleasesMenu' + +jest.mock('../../hooks/usePerspective', () => ({ + usePerspective: jest.fn().mockReturnValue({ + currentGlobalBundle: {}, + setPerspective: jest.fn(), + }), +})) + +jest.mock('../../util/util', () => ({ + isDraftOrPublished: jest.fn(), +})) + +jest.mock('../../../store/bundles/useBundles', () => ({ + useBundles: jest.fn().mockReturnValue({deletedBundles: {}}), +})) + +const mockUseBundles = useBundles as jest.Mock + +describe('ReleasesMenu', () => { + const mockUsePerspective = usePerspective as jest.Mock + const ButtonTest =