diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 161632a3718..c64bc434731 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -132,6 +132,8 @@ const config = { 'sortOrder', 'status', 'group', + 'textWeight', + 'showChangesBy', ], }, }, diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index e8c93272bfe..f2ca5ca521a 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -19,7 +19,7 @@ "@portabletext/block-tools": "^1.0.2", "@portabletext/editor": "^1.21.5", "@portabletext/react": "^3.0.0", - "@sanity/assist": "^3.0.2", + "@sanity/assist": "^3.1.0", "@sanity/client": "^6.25.0", "@sanity/color": "^3.0.0", "@sanity/color-input": "^4.0.1", @@ -34,7 +34,7 @@ "@sanity/logos": "^2.1.2", "@sanity/migrate": "workspace:*", "@sanity/preview-url-secret": "^2.0.0", - "@sanity/react-loader": "^1.8.3", + "@sanity/react-loader": "^1.10.35", "@sanity/tsdoc": "1.0.169", "@sanity/types": "workspace:*", "@sanity/ui": "^2.11.2", diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 273b3b42da4..f26f0ab683d 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -225,6 +225,7 @@ export default defineConfig([ dataset: 'playground', plugins: [sharedSettings()], basePath: '/playground', + beta: {eventsAPI: {enabled: true}}, }, { name: 'listener-events', @@ -253,6 +254,7 @@ export default defineConfig([ plugins: [sharedSettings()], basePath: '/staging', apiHost: 'https://api.sanity.work', + beta: {eventsAPI: {enabled: true}}, auth: { loginMethod: 'token', }, diff --git a/packages/@sanity/types/src/schema/preview.ts b/packages/@sanity/types/src/schema/preview.ts index bc72b404992..d297ce1c056 100644 --- a/packages/@sanity/types/src/schema/preview.ts +++ b/packages/@sanity/types/src/schema/preview.ts @@ -10,6 +10,9 @@ export interface PrepareViewOptions { /** @public */ export interface PreviewValue { + _id?: string + _createdAt?: string + _updatedAt?: string title?: string subtitle?: string description?: string diff --git a/packages/sanity/package.json b/packages/sanity/package.json index e7dce82c053..c9aa6e7e195 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -256,6 +256,7 @@ "rimraf": "^5.0.10", "rxjs": "^7.8.0", "rxjs-exhaustmap-with-trailing": "^2.1.1", + "rxjs-mergemap-array": "^0.1.0", "sanity-diff-patch": "^4.0.0", "scroll-into-view-if-needed": "^3.0.3", "semver": "^7.3.5", diff --git a/packages/sanity/src/_singletons/context/EventsContext.ts b/packages/sanity/src/_singletons/context/EventsContext.ts new file mode 100644 index 00000000000..ea8c144535b --- /dev/null +++ b/packages/sanity/src/_singletons/context/EventsContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {EventsStore} from '../../core/store/events/types' + +/** + * @internal + */ +export const EventsContext = createContext( + 'sanity/_singletons/context/events', + null, +) 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 f4b0cdd2dcf..ae1a767f46a 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' @@ -51,6 +52,8 @@ export * from './context/PresentationSharedStateContext' 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/context/comments/CommentsProvider.tsx b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx index e15c5a4eb0d..2468e14bc1a 100644 --- a/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx +++ b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx @@ -1,3 +1,4 @@ +import {type ReleaseId} from '@sanity/client' import {type Path} from '@sanity/types' import {orderBy} from 'lodash' import {memo, type ReactNode, useCallback, useMemo, useState} from 'react' @@ -6,7 +7,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, getVersionId} from '../../../util' import { type CommentOperationsHookOptions, useCommentOperations, @@ -43,6 +44,7 @@ export interface CommentsProviderProps { children: ReactNode documentId: string documentType: string + releaseId?: ReleaseId type: CommentsType sortOrder: 'asc' | 'desc' @@ -78,20 +80,23 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr selectedCommentId, isConnecting, onPathOpen, + releaseId, } = 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 versionOrPublishedId = releaseId ? getVersionId(documentId, releaseId) : publishedId + const editState = useEditState(publishedId, documentType, 'low', releaseId) const schemaType = useSchema().get(documentType) const currentUser = useCurrentUser() const {name: workspaceName, dataset, projectId} = useWorkspace() const documentValue = useMemo(() => { + if (releaseId) return editState.version return editState.draft || editState.published - }, [editState.draft, editState.published]) + }, [editState.version, editState.draft, editState.published, releaseId]) const documentRevisionId = useMemo(() => documentValue?._rev, [documentValue]) @@ -112,7 +117,8 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr error, loading, } = useCommentsStore({ - documentId: publishedId, + documentId, + releaseId, client, transactionsIdMap, onLatestTransactionIdReceived: handleOnLatestTransactionIdReceived, @@ -229,7 +235,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - documentId: publishedId, + documentId: versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -257,7 +263,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - publishedId, + versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -277,7 +283,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr const ctxValue = useMemo( (): CommentsContextValue => ({ - documentId, + documentId: versionOrPublishedId, documentType, isCreatingDataset, @@ -310,7 +316,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..c19d4c75238 100644 --- a/packages/sanity/src/core/comments/store/useCommentsStore.ts +++ b/packages/sanity/src/core/comments/store/useCommentsStore.ts @@ -1,8 +1,13 @@ -import {type ListenEvent, type ListenOptions, type SanityClient} from '@sanity/client' +import { + type ListenEvent, + type ListenOptions, + type ReleaseId, + type SanityClient, +} from '@sanity/client' import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import {catchError, of} from 'rxjs' -import {getPublishedId} from '../../util' +import {getPublishedId, getVersionId} from '../../util' import {type CommentDocument, type Loadable} from '../types' import {commentsReducer, type CommentsReducerAction, type CommentsReducerState} from './reducer' @@ -14,6 +19,7 @@ export interface CommentsStoreOptions { documentId: string onLatestTransactionIdReceived: (documentId: DocumentId) => void transactionsIdMap: Map + releaseId?: ReleaseId } interface CommentsStoreReturnType extends Loadable { @@ -56,7 +62,7 @@ const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreReturnType { - const {client, documentId, onLatestTransactionIdReceived, transactionsIdMap} = opts + const {client, documentId, onLatestTransactionIdReceived, transactionsIdMap, releaseId} = opts const [state, dispatch] = useReducer(commentsReducer, INITIAL_STATE) const [loading, setLoading] = useState(client !== null) @@ -64,7 +70,12 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur const didInitialFetch = useRef(false) - const params = useMemo(() => ({documentId: getPublishedId(documentId)}), [documentId]) + const params = useMemo( + () => ({ + documentId: releaseId ? getVersionId(documentId, releaseId) : getPublishedId(documentId), + }), + [documentId, releaseId], + ) 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..86441745142 100644 --- a/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx +++ b/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx @@ -1,21 +1,24 @@ import {type PreviewValue, type SanityDocument} from '@sanity/types' -import {Flex, Text} from '@sanity/ui' -import {styled} from 'styled-components' +import {type BadgeTone, Flex, Text} from '@sanity/ui' +import {useMemo} from 'react' -import {useDateTimeFormat, useRelativeTime} from '../../hooks' +import {useRelativeTime} from '../../hooks' import {useTranslation} from '../../i18n' +import {type VersionsRecord} from '../../preview/utils/getPreviewStateObservable' +import { + getReleaseIdFromReleaseDocumentId, + getReleaseTone, + ReleaseAvatar, + useReleases, +} from '../../releases' interface DocumentStatusProps { - absoluteDate?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + versions?: VersionsRecord | Record singleLine?: boolean } -const StyledText = styled(Text)` - white-space: nowrap; -` - /** * Displays document status indicating both last published and edited dates in either relative (the default) * or absolute formats. @@ -26,55 +29,89 @@ const StyledText = styled(Text)` * * @internal */ -export function DocumentStatus({absoluteDate, draft, published, singleLine}: DocumentStatusProps) { +export function DocumentStatus({draft, published, versions, singleLine}: DocumentStatusProps) { + const {data: releases} = useReleases() + const versionsList = useMemo(() => Object.entries(versions ?? {}), [versions]) const {t} = useTranslation() - const draftUpdatedAt = draft && '_updatedAt' in draft ? draft._updatedAt : '' - const publishedUpdatedAt = published && '_updatedAt' in published ? published._updatedAt : '' - - const intlDateFormat = useDateTimeFormat({ - dateStyle: 'medium', - timeStyle: 'short', - }) - - const draftDateAbsolute = draftUpdatedAt && intlDateFormat.format(new Date(draftUpdatedAt)) - const publishedDateAbsolute = - publishedUpdatedAt && intlDateFormat.format(new Date(publishedUpdatedAt)) - - const draftUpdatedTimeAgo = useRelativeTime(draftUpdatedAt || '', { - minimal: true, - useTemporalPhrase: true, - }) - const publishedUpdatedTimeAgo = useRelativeTime(publishedUpdatedAt || '', { - minimal: true, - useTemporalPhrase: true, - }) - - const publishedDate = absoluteDate ? publishedDateAbsolute : publishedUpdatedTimeAgo - const updatedDate = absoluteDate ? draftDateAbsolute : draftUpdatedTimeAgo return ( - {!publishedDate && ( - - {t('document-status.not-published')} - - )} - {publishedDate && ( - - {t('document-status.published', {date: publishedDate})} - + {published && ( + )} - {updatedDate && ( - - {t('document-status.edited', {date: updatedDate})} - + {draft && ( + )} + {versionsList.map(([versionName, {snapshot}]) => { + const release = releases?.find( + (r) => getReleaseIdFromReleaseDocumentId(r._id) === versionName, + ) + return ( + + ) + })} + + ) +} + +type Mode = 'edited' | 'created' | 'draft' | 'published' + +const labels: Record = { + draft: 'document-status.edited', + published: 'document-status.date', + edited: 'document-status.edited', + created: 'document-status.created', +} + +const VersionStatus = ({ + title, + timestamp, + mode, + tone, +}: { + title: string | undefined + mode: Mode + timestamp?: string + tone: BadgeTone +}) => { + const {t} = useTranslation() + + const relativeTime = useRelativeTime(timestamp || '', { + minimal: true, + useTemporalPhrase: true, + }) + + return ( + + + + {title || t('release.placeholder-untitled-release')}{' '} + + {t(labels[mode], {date: relativeTime})} + + ) } diff --git a/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx b/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx index 86036a3a8f0..2df847107f1 100644 --- a/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx +++ b/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx @@ -1,53 +1,94 @@ -import {DotIcon} from '@sanity/icons' import {type PreviewValue, type SanityDocument} from '@sanity/types' -import {Text} from '@sanity/ui' +import {Flex} from '@sanity/ui' import {useMemo} from 'react' import {styled} from 'styled-components' +import {type VersionsRecord} from '../../preview/utils/getPreviewStateObservable' +import {useReleases} from '../../releases/store/useReleases' +import {getReleaseIdFromReleaseDocumentId} from '../../releases/util/getReleaseIdFromReleaseDocumentId' + interface DocumentStatusProps { draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + versions: VersionsRecord | undefined } -const Root = styled(Text)` - &[data-status='edited'] { - --card-icon-color: var(--card-badge-caution-dot-color); - } - &[data-status='unpublished'] { +const Dot = styled.div<{$index: number}>` + width: 5px; + height: 5px; + background-color: var(--card-icon-color); + border-radius: 999px; + box-shadow: 0 0 0 1px var(--card-bg-color); + z-index: ${({$index}) => $index}; + &[data-status='not-published'] { --card-icon-color: var(--card-badge-default-dot-color); opacity: 0.5 !important; } + &[data-status='draft'] { + --card-icon-color: var(--card-badge-caution-dot-color); + } + &[data-status='asap'] { + --card-icon-color: var(--card-badge-critical-dot-color); + } + &[data-status='undecided'] { + --card-icon-color: var(--card-badge-explore-dot-color); + } + &[data-status='scheduled'] { + --card-icon-color: var(--card-badge-primary-dot-color); + } ` +type Status = 'not-published' | 'draft' | 'asap' | 'scheduled' | 'undecided' + /** * Renders a dot indicating the current document status. * - * - 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. - * * @internal */ -export function DocumentStatusIndicator({draft, published}: DocumentStatusProps) { - const $draft = !!draft - const $published = !!published - - const status = useMemo(() => { - if ($draft && !$published) return 'unpublished' - return 'edited' - }, [$draft, $published]) +export function DocumentStatusIndicator({draft, published, versions}: DocumentStatusProps) { + const {data: releases} = useReleases() + const versionsList = useMemo( + () => + versions + ? Object.keys(versions).map((versionName) => { + const release = releases?.find( + (r) => getReleaseIdFromReleaseDocumentId(r._id) === versionName, + ) + return release?.metadata.releaseType + }) + : [], + [releases, versions], + ) - // Return null if the document is: - // - Published without edits - // - Neither published or without edits (this shouldn't be possible) - if ((!$draft && !$published) || (!$draft && $published)) { - return null - } + const indicators: { + status: Status + show: boolean + }[] = [ + { + status: draft && !published ? 'not-published' : 'draft', + show: Boolean(draft), + }, + { + status: 'asap', + show: versionsList.includes('asap'), + }, + { + status: 'scheduled', + show: versionsList.includes('scheduled'), + }, + { + status: 'undecided', + show: versionsList.includes('undecided'), + }, + ] return ( - - - + + {indicators + .filter(({show}) => show) + .map(({status}, index) => ( + + ))} + ) } diff --git a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts index eff10151d30..6a0ace3a6a4 100644 --- a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts +++ b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts @@ -165,6 +165,7 @@ describe('resolveConfig', () => { {name: 'sanity/tasks'}, {name: 'sanity/scheduled-publishing'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) @@ -192,6 +193,7 @@ describe('resolveConfig', () => { {name: 'sanity/comments'}, {name: 'sanity/tasks'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) }) diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index 1fcc613e510..2fc432e9468 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 (typeof enabled === 'undefined') return acc + if (typeof enabled === 'boolean') return enabled + + throw new Error( + `Expected \`beta.eventsAPI.enabled\` to be a boolean, but received ${getPrintableType( + enabled, + )}`, + ) + }, initialValue) + + return result +} + export const serverDocumentActionsReducer = (opts: { config: PluginOptions initialValue: boolean | undefined diff --git a/packages/sanity/src/core/config/prepareConfig.tsx b/packages/sanity/src/core/config/prepareConfig.tsx index f9b7c4966c2..db2443984a6 100644 --- a/packages/sanity/src/core/config/prepareConfig.tsx +++ b/packages/sanity/src/core/config/prepareConfig.tsx @@ -26,6 +26,7 @@ import { documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, + eventsAPIReducer, fileAssetSourceResolver, imageAssetSourceResolver, initialDocumentActions, @@ -645,6 +646,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/resolveDefaultPlugins.ts b/packages/sanity/src/core/config/resolveDefaultPlugins.ts index b567b8f839a..7fb2e887263 100644 --- a/packages/sanity/src/core/config/resolveDefaultPlugins.ts +++ b/packages/sanity/src/core/config/resolveDefaultPlugins.ts @@ -1,5 +1,6 @@ import {comments} from '../comments/plugin' import {createIntegration} from '../create/createIntegrationPlugin' +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' @@ -10,7 +11,7 @@ import { type WorkspaceOptions, } from './types' -const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration()] +const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration(), releases()] export function getDefaultPlugins( options: DefaultPluginsWorkspaceOptions, @@ -24,6 +25,9 @@ export function getDefaultPlugins( if (plugin.name === TASKS_NAME) { return options.tasks.enabled } + if (plugin.name === RELEASES_NAME) { + return options.releases.enabled + } return true }) } @@ -41,5 +45,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 656de9a7c94..09785148e54 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -483,6 +483,10 @@ export interface WorkspaceOptions extends SourceOptions { * @internal */ tasks?: DefaultPluginsWorkspaceOptions['tasks'] + /** + * @internal + */ + releases?: DefaultPluginsWorkspaceOptions['releases'] /** * @hidden @@ -542,6 +546,13 @@ export interface ResolveProductionUrlContext extends ConfigContext { document: SanityDocumentLike } +/** + * @hidden + * @beta + */ + +export type DocumentActionsVersionType = 'published' | 'draft' | 'revision' | 'version' + /** * @hidden * @beta @@ -549,6 +560,11 @@ export interface ResolveProductionUrlContext extends ConfigContext { export interface DocumentActionsContext extends ConfigContext { documentId?: string schemaType: string + + /** releaseId of the open document, it's undefined if it's published or the draft */ + releaseId?: string + /** the type of the currently active document. */ + versionType?: DocumentActionsVersionType } /** @@ -937,6 +953,7 @@ export type { export type DefaultPluginsWorkspaceOptions = { tasks: {enabled: boolean} scheduledPublishing: ScheduledPublishingPluginOptions + releases: {enabled: boolean} } /** @@ -992,4 +1009,14 @@ export interface BetaFeatures { */ fallbackStudioOrigin?: string } + /** + * Config for the history events API . + * + * If enabled, it will use the new events API to fetch document history. + * + * If it is not enabled, it will continue using the legacy Timeline. + */ + eventsAPI?: { + 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/field/diff/components/ChangesError.tsx b/packages/sanity/src/core/field/diff/components/ChangesError.tsx new file mode 100644 index 00000000000..df32868746f --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/ChangesError.tsx @@ -0,0 +1,22 @@ +import {Card, Stack, Text} from '@sanity/ui' + +import {useTranslation} from '../../../i18n' + +/** + * @internal + * */ +export function ChangesError() { + const {t} = useTranslation() + return ( + + + + {t('changes.error-title')} + + + {t('changes.error-description')} + + + + ) +} diff --git a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx index 8fb65329191..6c0100d1cc0 100644 --- a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx +++ b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx @@ -1,5 +1,5 @@ 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 ReactNode} from 'react' import {Tooltip, type TooltipProps} from '../../../../ui-components' @@ -9,6 +9,7 @@ import {useTranslation} from '../../../i18n' import {useUser} from '../../../store' import {type AnnotationDetails, type Diff} from '../../types' import {getAnnotationAtPath, useAnnotationColor} from '../annotations' +import {Event} from '../components/Event' /** @internal */ export interface DiffTooltipProps extends TooltipProps { @@ -46,7 +47,7 @@ function DiffTooltipWithAnnotation(props: DiffTooltipWithAnnotationsProps) { } const content = ( - + {description || t('changes.changed-label')} @@ -75,26 +76,35 @@ 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/components/Event.tsx b/packages/sanity/src/core/field/diff/components/Event.tsx new file mode 100644 index 00000000000..ebe1edd22c0 --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/Event.tsx @@ -0,0 +1,204 @@ +import {type AvatarSize, AvatarStack, Box, Flex, Skeleton, Stack, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2, type ThemeColorAvatarColorKey} from '@sanity/ui/theme' +import {useMemo} from 'react' +import {css, styled} from 'styled-components' + +import {Tooltip} from '../../../../ui-components' +import {UserAvatar} from '../../../components/userAvatar/UserAvatar' +import {useDateTimeFormat} from '../../../hooks/useDateTimeFormat' +import {type RelativeTimeOptions, useRelativeTime} from '../../../hooks/useRelativeTime' +import {useTranslation} from '../../../i18n/hooks/useTranslation' +import {VersionInlineBadge} from '../../../releases/components/VersionInlineBadge' +import {getReleaseTone} from '../../../releases/util/getReleaseTone' +import { + type DocumentGroupEvent, + isEditDocumentVersionEvent, + isPublishDocumentVersionEvent, +} from '../../../store/events/types' +import {useUser} from '../../../store/user/hooks' +import {getDocumentVariantType} from '../../../util/getDocumentVariantType' +import { + TIMELINE_ICON_COMPONENTS, + TIMELINE_ITEM_EVENT_TONE, + TIMELINE_ITEM_I18N_KEY_MAPPING, +} from './constants' + +interface UserAvatarStackProps { + maxLength?: number + userIds: string[] + size?: AvatarSize + withTooltip?: boolean +} + +function UserAvatarStack({maxLength, userIds, size, withTooltip = true}: UserAvatarStackProps) { + return ( + + {userIds.map((userId) => ( + + ))} + + ) +} + +const IconBox = styled(Flex)<{$color: ThemeColorAvatarColorKey}>((props) => { + const theme = getTheme_v2(props.theme) + const color = props.$color + + return css` + --card-icon-color: ${theme.color.avatar[color].fg}; + background-color: ${theme.color.avatar[color].bg}; + box-shadow: 0 0 0 1px var(--card-bg-color); + + position: absolute; + width: ${theme.avatar.sizes[0].size}px; + height: ${theme.avatar.sizes[0].size}px; + right: -3px; + bottom: -3px; + border-radius: 50%; + ` +}) + +const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = { + minimal: true, + useTemporalPhrase: true, +} + +const AvatarSkeleton = styled(Skeleton)((props) => { + const theme = getTheme_v2(props.theme) + return css` + border-radius: 50%; + width: ${theme.avatar.sizes[1].size}px; + height: ${theme.avatar.sizes[1].size}px; + ` +}) + +const NameSkeleton = styled(Skeleton)((props) => { + const theme = getTheme_v2(props.theme) + return css` + width: 6ch; + height: ${theme.font.text.sizes[0].lineHeight}px; + ` +}) + +const UserLine = ({userId}: {userId: string}) => { + const [user, loading] = useUser(userId) + + return ( + + {loading || !user ? : } + + {loading || !user?.displayName ? ( + + + + ) : ( + + {user.displayName} + + )} + + + ) +} +const ChangesBy = ({collaborators}: {collaborators: string[]}) => { + const {t} = useTranslation('studio') + return ( + + + + {t('timeline.changes.title')} + + + {collaborators.map((userId) => ( + + ))} + + ) +} + +interface TimelineItemProps { + event: DocumentGroupEvent + showChangesBy: 'tooltip' | 'inline' | 'hidden' +} +/** + * @internal + */ +export function Event({event, showChangesBy = 'tooltip'}: TimelineItemProps) { + const {t} = useTranslation('studio') + const documentVariantType = getDocumentVariantType(event.documentId) + const {type, timestamp} = event + + const IconComponent = TIMELINE_ICON_COMPONENTS[type] + const contributors = 'contributors' in event ? event.contributors || [] : [] + + const dateFormat = useDateTimeFormat({dateStyle: 'medium', timeStyle: 'short'}) + const date = new Date(timestamp) + + const updatedTimeAgo = useRelativeTime(date || '', RELATIVE_TIME_OPTIONS) + + const formattedTimestamp = useMemo(() => { + const parsedDate = new Date(timestamp) + const formattedDate = dateFormat.format(parsedDate) + + return formattedDate + }, [timestamp, dateFormat]) + + const userIds = isEditDocumentVersionEvent(event) ? event.contributors : [event.author] + + return ( + <> + +
+ + + {IconComponent && } + +
+ + + {t(TIMELINE_ITEM_I18N_KEY_MAPPING[type])} + {isPublishDocumentVersionEvent(event) && documentVariantType === 'published' && ( + <> + {' '} + {event.release ? ( + + {event.release.metadata.title} + + ) : ( + + {t('changes.versions.draft')} + + )} + + )} + + + + {updatedTimeAgo} + + + + {contributors.length > 0 && showChangesBy == 'tooltip' && ( + + } portal> + + + + + + )} +
+ {contributors.length > 0 && showChangesBy === 'inline' && ( + + + + )} + + ) +} diff --git a/packages/sanity/src/core/field/diff/components/NoChanges.tsx b/packages/sanity/src/core/field/diff/components/NoChanges.tsx index 437096ba9c3..c99cf5e6ac0 100644 --- a/packages/sanity/src/core/field/diff/components/NoChanges.tsx +++ b/packages/sanity/src/core/field/diff/components/NoChanges.tsx @@ -6,7 +6,7 @@ import {useTranslation} from '../../../i18n' export function NoChanges() { const {t} = useTranslation() return ( - + {t('changes.no-changes-title')} diff --git a/packages/sanity/src/core/field/diff/components/constants.ts b/packages/sanity/src/core/field/diff/components/constants.ts new file mode 100644 index 00000000000..5a0c3fffa71 --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/constants.ts @@ -0,0 +1,62 @@ +import { + AddCircleIcon, + CalendarIcon, + CircleIcon, + CloseIcon, + EditIcon, + type IconComponent, + PublishIcon, + TrashIcon, + UnpublishIcon, +} from '@sanity/icons' +import {type ThemeColorAvatarColorKey} from '@sanity/ui/theme' + +import {type StudioLocaleResourceKeys} from '../../../i18n/bundles/studio' +import {type DocumentVersionEventType} from '../../../store/events/types' + +export const TIMELINE_ICON_COMPONENTS: Record = { + createDocumentVersion: AddCircleIcon, + createLiveDocument: AddCircleIcon, + deleteDocumentGroup: TrashIcon, + deleteDocumentVersion: CloseIcon, + editDocumentVersion: EditIcon, + updateLiveDocument: EditIcon, + publishDocumentVersion: PublishIcon, + unpublishDocument: UnpublishIcon, + scheduleDocumentVersion: CalendarIcon, + unscheduleDocumentVersion: CircleIcon, +} + +export const TIMELINE_ITEM_EVENT_TONE: Record = + { + createDocumentVersion: 'green', + createLiveDocument: 'blue', + updateLiveDocument: 'green', + editDocumentVersion: 'yellow', + unpublishDocument: 'orange', + deleteDocumentVersion: 'orange', + deleteDocumentGroup: 'orange', + scheduleDocumentVersion: 'cyan', + unscheduleDocumentVersion: 'cyan', + publishDocumentVersion: 'green', + } + +/** + * @internal + * mapping of events types with a readable key for translation + */ +export const TIMELINE_ITEM_I18N_KEY_MAPPING: Record< + DocumentVersionEventType, + StudioLocaleResourceKeys +> = { + createDocumentVersion: 'timeline.operation.created', + publishDocumentVersion: 'timeline.operation.published', + updateLiveDocument: 'timeline.operation.edited-live', + editDocumentVersion: 'timeline.operation.edited-draft', + unpublishDocument: 'timeline.operation.unpublished', + deleteDocumentVersion: 'timeline.operation.draft-discarded', + deleteDocumentGroup: 'timeline.operation.deleted', + scheduleDocumentVersion: 'timeline.operation.published', + unscheduleDocumentVersion: 'timeline.operation.published', + createLiveDocument: 'timeline.operation.created', +} diff --git a/packages/sanity/src/core/field/diff/components/index.ts b/packages/sanity/src/core/field/diff/components/index.ts index 073e58023cb..1fe0d20e381 100644 --- a/packages/sanity/src/core/field/diff/components/index.ts +++ b/packages/sanity/src/core/field/diff/components/index.ts @@ -1,13 +1,16 @@ export * from './ChangeBreadcrumb' export * from './ChangeList' export * from './ChangeResolver' +export * from './ChangesError' export * from './ChangeTitleSegment' +export {TIMELINE_ITEM_I18N_KEY_MAPPING} from './constants' export * from './DiffCard' export * from './DiffErrorBoundary' export * from './DiffFromTo' export * from './DiffInspectWrapper' export * from './DiffString' export * from './DiffTooltip' +export * from './Event' export * from './FallbackDiff' export * from './FieldChange' export * from './FromTo' diff --git a/packages/sanity/src/core/field/types.ts b/packages/sanity/src/core/field/types.ts index a281913b163..111d59f32c7 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,7 @@ export type Chunk = { * @beta */ export type AnnotationDetails = { - chunk: Chunk + event?: DocumentGroupEvent timestamp: string author: string } 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 2df13d15d09..89c960265a6 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' @@ -62,6 +63,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() @@ -74,6 +76,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 17eca82c815..de06351c2c6 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) @@ -188,6 +190,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 ( @@ -206,6 +209,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 || '' @@ -213,6 +217,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..41daf411011 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,8 +45,6 @@ export function ReferencePreview(props: { [previewId, refType.name], ) - const {draft, published} = preview - const previewProps = useMemo( () => ({ children: ( @@ -57,23 +56,32 @@ export function ReferencePreview(props: { )} - + ), layout, schemaType: refType, - tooltip: , + tooltip: ( + + ), value: previewStub, }), [ documentPresence, - draft, layout, preview.draft, preview.published, + preview.versions, previewStub, - published, refType, showTypeLabel, ], diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts index ed129607c31..76a5b3fbaec 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts @@ -9,6 +9,7 @@ import {type ComponentType, type ReactNode} from 'react' import {type Observable} from 'rxjs' import {type DocumentAvailability} from '../../../preview' +import {type VersionsRecord} from '../../../preview/utils/getPreviewStateObservable' import {type ObjectInputProps} from '../../types' export type PreviewDocumentValue = PreviewValue & { @@ -24,6 +25,8 @@ export interface ReferenceInfo { preview: { draft: PreviewDocumentValue | undefined published: PreviewDocumentValue | undefined + version: PreviewDocumentValue | undefined + versions: VersionsRecord } } @@ -82,4 +85,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 cdfdc0c2df5..85c4c2861ee 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -12,6 +12,8 @@ import { import {type FIXME} from '../../../FIXME' import {useSchema} from '../../../hooks' +import {usePerspective} from '../../../releases/hooks/usePerspective' +import {useReleases} from '../../../releases/store/useReleases' import {useDocumentPreviewStore} from '../../../store' import {isNonNullable} from '../../../util' import {useFormValue} from '../../contexts/FormValue' @@ -31,11 +33,14 @@ 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 perspective = usePerspective() + const releases = useReleases() const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -116,8 +121,18 @@ 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}, + { + bundleIds: releases.releasesIds, + bundleStack: perspective.perspectiveStack, + }, + ), + [documentPreviewStore, schemaType, version, releases.releasesIds, perspective.perspectiveStack], ) return { diff --git a/packages/sanity/src/core/form/studio/FormBuilder.tsx b/packages/sanity/src/core/form/studio/FormBuilder.tsx index 121c5b07659..aa6e88dc4c2 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 d8230175e04..aba54c92a58 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 @@ -1,12 +1,24 @@ import {type SanityClient} from '@sanity/client' import {DEFAULT_MAX_FIELD_DEPTH} from '@sanity/schema/_internal' import {type ReferenceFilterSearchOptions, type ReferenceSchemaType} from '@sanity/types' -import {combineLatest, type Observable, of} from 'rxjs' -import {map, mergeMap, switchMap} from 'rxjs/operators' +import {omit} from 'lodash' +import {combineLatest, from, type Observable, of} from 'rxjs' +import {map, mergeMap, scan, startWith, switchMap} from 'rxjs/operators' -import {type DocumentPreviewStore} from '../../../../preview' +import {type DocumentPreviewStore, getPreviewPaths, prepareForPreview} from '../../../../preview' +import { + type VersionsRecord, + type VersionTuple, +} from '../../../../preview/utils/getPreviewStateObservable' import {createSearch} from '../../../../search' -import {collate, type CollatedHit, getDraftId, getIdPair} from '../../../../util' +import { + collate, + type CollatedHit, + getDraftId, + getIdPair, + getVersionId, + isRecord, +} from '../../../../util' import { type PreviewDocumentValue, type ReferenceInfo, @@ -35,22 +47,31 @@ export function getReferenceInfo( documentPreviewStore: DocumentPreviewStore, id: string, referenceType: ReferenceSchemaType, + {version}: {version?: string} = {}, + perspective: {bundleIds: string[]; bundleStack: string[]} = {bundleIds: [], bundleStack: []}, ): 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) { + if ( + !pairAvailability.draft.available && + !pairAvailability.published.available && + !pairAvailability.published.available + ) { // combine availability of draft + published 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, @@ -58,6 +79,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -65,9 +88,13 @@ export function getReferenceInfo( 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( @@ -84,6 +111,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -99,10 +128,12 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } - + const previewPaths = getPreviewPaths(refSchemaType?.preview) || [] const draftPreview$ = documentPreviewStore.observeForPreview( {_id: draftId}, refSchemaType, @@ -113,10 +144,67 @@ export function getReferenceInfo( refSchemaType, ) - const value$ = combineLatest([draftPreview$, publishedPreview$]).pipe( - map(([draft, published]) => ({ + const versions$ = from(perspective.bundleIds).pipe( + mergeMap>((bundleId) => + documentPreviewStore + .observePaths({_id: getVersionId(id, bundleId)}, previewPaths) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => + result + ? [ + bundleId, + { + snapshot: { + _id: versionId, + ...prepareForPreview(result, refSchemaType), + }, + }, + ] + : [bundleId, {snapshot: null}], + ), + ), + ), + scan((byBundleId, [bundleId, value]) => { + if (value.snapshot === null) { + return omit({...byBundleId}, [bundleId]) + } + + return { + ...byBundleId, + [bundleId]: value, + } + }, {}), + startWith({}), + ) + + // Iterate the release stack in descending precedence, returning the highest precedence existing + // version document. + const versionPreview$ = versionId + ? versions$.pipe( + map((versions) => { + for (const bundleId of perspective.bundleStack) { + if (bundleId in versions) { + return versions[bundleId] + } + } + return null + }), + startWith(undefined), + ) + : undefined + + const value$ = combineLatest([ + draftPreview$, + publishedPreview$, + ...(versionPreview$ ? [versionPreview$] : []), + versions$, + ]).pipe( + map(([draft, published, versionValue, versions]) => ({ draft, published, + ...(versionValue ? {version: versionValue} : {}), + versions: versions, })), ) @@ -124,9 +212,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 @@ -135,10 +226,17 @@ export function getReferenceInfo( id: publishedId, availability, preview: { - draft: (value.draft.snapshot || undefined) as PreviewDocumentValue | undefined, - published: (value.published.snapshot || undefined) as + draft: (isRecord(value.draft.snapshot) ? value.draft : undefined) as | PreviewDocumentValue | undefined, + published: (isRecord(value.published.snapshot) ? value.published : undefined) as + | PreviewDocumentValue + | undefined, + version: (isRecord(value.version?.snapshot) + ? value.version.snapshot + : undefined) as PreviewDocumentValue | undefined, + + versions: isRecord(value.versions) ? value.versions : {}, }, } }), 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 30eb1c4662e..41fecc15ee3 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -26,6 +26,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' @@ -64,6 +65,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() @@ -190,6 +192,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 4dda84e5f85..f3ff9c41fce 100644 --- a/packages/sanity/src/core/form/types/fieldProps.ts +++ b/packages/sanity/src/core/form/types/fieldProps.ts @@ -60,6 +60,7 @@ export interface BaseFieldProps { index: number changed: boolean children: ReactNode + version?: string renderDefault: (props: FieldProps) => React.JSX.Element } 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..aec7be4afad 100644 --- a/packages/sanity/src/core/hooks/useEditState.ts +++ b/packages/sanity/src/core/hooks/useEditState.ts @@ -9,12 +9,16 @@ export function useEditState( publishedDocId: string, docTypeName: string, priority: 'default' | 'low' = 'default', + version?: string | undefined, ): EditStateFor { + if (version === 'published' || version === 'draft') { + throw new Error('Version cannot be published or daft') + } 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 +29,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 1dfd634d41c..5e849081ce6 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 */ @@ -237,6 +241,10 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'changes.error-boundary.developer-info': 'Check the developer console for more information', /** Text shown when a diff component crashes during rendering, triggering the error boundary */ 'changes.error-boundary.title': 'Rendering the changes to this field caused an error', + /* Error description when changes could not be loaded */ + 'changes.error-description': "We're unable to load the changes for this document.", + /** Error title when changes could not be loaded */ + 'changes.error-title': 'Something went wrong', /** Error message shown when the value of a field is not the expected one */ 'changes.error.incorrect-type-message': 'Value error: Value is of type "{{actualType}}", expected "{{expectedType}}"', @@ -271,6 +279,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'Edit the document or select an older version in the timeline to see a list of changes appear in this panel.', /** No Changes title in the Review Changes pane */ 'changes.no-changes-title': 'There are no changes', + /* Label for the tooltip that shows when an action is not selectable*/ + 'changes.not-selectable': 'It is not possible to select this event', /** Portable Text diff: An annotation was added */ 'changes.portable-text.annotation_added': 'Added annotation', /** Portable Text diff: An annotation was changed */ @@ -314,6 +324,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'changes.removed-label': 'Removed', /** Title for the Review Changes pane */ 'changes.title': 'History', + /**The title that will be shown in the badge inside the events when the item is a draft */ + 'changes.versions.draft': 'Draft', /** --- Common components --- */ /** Tooltip text for context menu buttons */ @@ -353,6 +365,11 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Title for the default ordering/SortOrder if no orderings are provided and the title field is found */ 'default-orderings.title': 'Sort by Title', + /** Label to show in the document footer indicating the creation date of the document */ + 'document-status.created': 'Created {{date}}', + + /** Label to show in the document status indicating the date of the status */ + 'document-status.date': '{{date}}', /** Label to show in the document footer indicating the last edited date of the document */ 'document-status.edited': 'Edited {{date}}', /** Label to show in the document footer indicating the document is not published*/ @@ -1095,6 +1112,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { * when there are templates/types available for creation */ 'new-document.create-new-document-label': 'New document…', + /** Tooltip message for add document button when the selected perspective is for published or inactive release */ + 'new-document.disabled-release.tooltip': 'You cannot add documents to this release', /** Placeholder for the "filter" input within the new document menu */ 'new-document.filter-placeholder': 'Search document types', /** Loading indicator text within the new document menu */ @@ -1141,6 +1160,94 @@ 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 when you click to view all versions you can copy the current document to */ + 'release.action.copy-to': 'Copy version to', + /** Action message for creating new releases */ + 'release.action.create-new': 'New 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', + /** Action message for when a new release is created off an existing version, draft or published document */ + 'release.action.new-release': 'New Release', + /** Error message for when a version is set to be unpublished */ + 'release.action.unpublish-version.failure': 'Failed to set version to be unpublished on release', + /** Action message for when a version is set to be unpublished successfully */ + 'release.action.unpublish-version.success': + 'Successfully set {{title}} to be unpublished on release', + /** Action message for when the view release is pressed */ + 'release.action.view-release': 'View release', + /** Label for banner when release is scheduled */ + 'release.banner.scheduled-for-publishing-on': 'Scheduled for publishing on {{date}}', + /** Label for Draft chip in document header */ + 'release.chip.draft': 'Draft', + /** Label for Published chip in document header */ + 'release.chip.published': 'Published', + /** Label for tooltip in chip with the created date */ + 'release.chip.tooltip.created-date': 'Created {{date}}', + /** Label for tooltip in chip with the lasted edited date */ + 'release.chip.tooltip.edited-date': 'Edited {{date}}', + /** Label for tooltip in chip when document is intended for a future release that hasn't been scheduled */ + 'release.chip.tooltip.intended-for-date': 'Intended for {{date}}', + /** Label for tooltip in chip when there is no recent draft edits */ + 'release.chip.tooltip.no-edits': 'No edits', + /** Label for tooltip in chip when document isn't published */ + 'release.chip.tooltip.not-published': 'Not published', + /** Label for tooltip in chip with the published date */ + 'release.chip.tooltip.published-date': 'Published {{date}}', + /** Label for tooltip in chip when document is in a release that has been scheduled */ + 'release.chip.tooltip.scheduled-for-date': 'Scheduled for {{date}}', + /** Label for tooltip in scheduled chip without a known date */ + 'release.chip.tooltip.unknown-date': 'Unknown date', + /** 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', + /** Label for description in tooltip to explain release types */ + 'release.dialog.tooltip.description': + 'This makes it possible to show whether documents are in conflict when working on multiple versions.', + /** Label for noting that a release time is not final */ + 'release.dialog.tooltip.note': + 'NOTE: You may change the time of release and set an exact time for scheduled publishing later.', + /** Title for tooltip to explain release time */ + 'release.dialog.tooltip.title': 'Approximate time of release', + /** The placeholder text when the release doesn't have a description */ + 'release.form.placeholer-describe-release': 'Describe the release…', + /** Tooltip for button to hide release visibility */ + 'release.layer.hide': 'Hide release', + /** Label for draft perspective in navbar */ + 'release.navbar.drafts': 'Drafts', + /** Label for published releases in navbar */ + 'release.navbar.published': 'Published', + /** Tooltip for releases navigation in navbar */ + 'release.navbar.tooltip': 'Releases', + /** The placeholder text when the release doesn't have a title */ + 'release.placeholder-untitled-release': 'Untitled release', + /**The toast title that will be shown when the user has a release perspective which is now archived */ + 'release.toast.archived-release.title': "The '{{title}}' release was archived", + /**The toast title that will be shown when the user has a release perspective which is now deleted */ + 'release.toast.not-found-release.title': "The '{{title}}' release could not be found", + /** Label for when a version of a document has already been added to the release */ + 'release.tooltip.already-added': 'A version of this document has already been added', + /** Label for when a release is scheduled / scheduling and a user can't add a document version to it */ + 'release.tooltip.locked': + 'This release has been scheduled. Unsechedule it to add more documents.', + /** Label for the release type 'as soon as possible' */ + 'release.type.asap': 'ASAP', + /** Label for the release type 'at time', meaning it's a release with a scheduled date */ + 'release.type.scheduled': 'At time', + /** Label for the release type 'undecided' */ + 'release.type.undecided': 'Undecided', + /** 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 */ @@ -1630,6 +1737,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'timeline.list.aria-label': 'Document revisions', /** Label for loading history */ 'timeline.loading-history': 'Loading history…', + /* Label for when no previous since events are available*/ + 'timeline.no-previous-events': 'No previous events', /** Label shown in review changes timeline when a document has been created */ 'timeline.operation.created': 'Created', /** Label shown in review changes timeline when a document was initially created */ diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index a2e38b22cde..2752021fab5 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -22,10 +22,33 @@ export * from './hooks' export * from './i18n' export * from './presence' export * from './preview' +export { + AddedVersion, + DiscardVersionDialog, + formatRelativeLocalePublishDate, + getPublishDateFromRelease, + getReleaseIdFromReleaseDocumentId, + getReleaseTone, + isDraftPerspective, + isPublishedPerspective, + isReleaseDocument, + isReleaseScheduledOrScheduling, + LATEST, + type ReleaseDocument, + RELEASES_INTENT, + useDocumentVersions, + useIsReleaseActive, + usePerspective, + useReleases, + useVersionOperations, + VersionChip, + versionDocumentExists, + VersionInlineBadge, +} from './releases' export * from './scheduledPublishing' export * from './schema' export type {SearchFactoryOptions, SearchOptions, SearchSort, SearchTerms} from './search' -export {createSearch, getSearchableTypes} from './search' +export {createSearch, getSearchableTypes, isPerspectiveRaw} from './search' export * from './store' export * from './studio' export * from './studioClient' diff --git a/packages/sanity/src/core/preview/availability.ts b/packages/sanity/src/core/preview/availability.ts index 52131be2c7f..cb5bfaf0218 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 {createSWR, getDraftId, getPublishedId, isRecord} from '../util' +import {createSWR, getDraftId, getPublishedId, getVersionId, isRecord, isVersionId} from '../util' import { AVAILABILITY_NOT_FOUND, AVAILABILITY_PERMISSION_DENIED, @@ -146,18 +146,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/components/SanityDefaultPreview.tsx b/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx index 7bcfab778b7..2c2121e91ef 100644 --- a/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx +++ b/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx @@ -134,8 +134,8 @@ export const SanityDefaultPreview = memo(function SanityDefaultPreview( {/* Currently tooltips won't trigger without a wrapping element */}
{children}
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 487e1a27768..b292520cc85 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,4 +1,9 @@ -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 {combineLatest, type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' @@ -10,6 +15,7 @@ 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, @@ -51,13 +57,39 @@ 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 @@ -101,12 +133,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, @@ -125,6 +161,7 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, + unstable_observeDocumentIdSet: observeDocumentIdSet, unstable_observeDocument: observeDocument, unstable_observeDocuments: (ids: string[]) => combineLatest(ids.map((id) => observeDocument(id))), 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..37d57011b17 100644 --- a/packages/sanity/src/core/preview/types.ts +++ b/packages/sanity/src/core/preview/types.ts @@ -91,6 +91,12 @@ export interface DraftsModelDocumentAvailability { * document readability for the draft document */ draft: DocumentAvailability + + /** + * document readability for the version document + */ + version?: DocumentAvailability + // TODO: validate versions availability? } /** @@ -107,6 +113,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 index 9644eb60c33..b77bb1eb487 100644 --- a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -15,11 +15,11 @@ function omitRev(document: SanityDocument | undefined) { * @param patch - The mendoza patch to apply * @param baseRev - The revision of the document that the patch is calculated from. This is used to ensure that the patch is applied to the correct revision of the document */ -export function applyMendozaPatch( - document: SanityDocument | undefined, +export function applyMendozaPatch( + document: T, patch: RawPatch, baseRev: string, -): SanityDocument | undefined { +): T | undefined { if (baseRev !== document?._rev) { throw new Error( 'Invalid document revision. The provided patch is calculated from a different revision than the current document', diff --git a/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts b/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts index 2926441e648..5b57acf1fba 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..7fdbe76d339 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -1,15 +1,32 @@ import {type PreviewValue, type SanityDocument, type SchemaType} from '@sanity/types' +import {omit} from 'lodash' import {type ReactNode} from 'react' -import {combineLatest, type Observable, of} from 'rxjs' -import {map, startWith} from 'rxjs/operators' +import {combineLatest, from, type Observable, of} from 'rxjs' +import {map, mergeMap, scan, startWith} from 'rxjs/operators' -import {getDraftId, getPublishedId} from '../../util/draftUtils' +import { + getDraftId, + getPublishedId, + getVersionFromId, + getVersionId, + isVersionId, +} from '../../util/draftUtils' import {type DocumentPreviewStore} from '../documentPreviewStore' +import {type PreparedSnapshot} from '../types' + +/** + * @internal + */ +export type VersionsRecord = Record + +export type VersionTuple = [bundleId: string, snapshot: PreparedSnapshot] export interface PreviewState { isLoading?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + version?: PreviewValue | Partial | null + versions: VersionsRecord } const isLiveEditEnabled = (schemaType: SchemaType) => schemaType.liveEdit === true @@ -24,22 +41,93 @@ export function getPreviewStateObservable( schemaType: SchemaType, documentId: string, title: ReactNode, + perspective: { + /** + * An array of all existing bundle ids. + */ + bundleIds: string[] + + /** + * An array of release ids ordered chronologically to represent the state of documents at the + * given point in time. + */ + bundleStack: string[] + + /** + * Perspective to use when fetching versions. + * Sometimes we want to fetch versions from a perspective not bound by the bundleStack + * (e.g. raw). + */ + isRaw?: boolean + } = { + bundleIds: [], + bundleStack: [], + isRaw: false, + }, ): Observable { const draft$ = isLiveEditEnabled(schemaType) ? of({snapshot: null}) : documentPreviewStore.observeForPreview({_id: getDraftId(documentId)}, schemaType) + const versions$ = from(perspective.bundleIds).pipe( + mergeMap>((bundleId) => + documentPreviewStore + .observeForPreview({_id: getVersionId(documentId, bundleId)}, schemaType) + .pipe(map((storeValue) => [bundleId, storeValue])), + ), + scan((byBundleId, [bundleId, value]) => { + if (value.snapshot === null) { + return omit({...byBundleId}, [bundleId]) + } + + return { + ...byBundleId, + [bundleId]: value, + } + }, {}), + startWith({}), + ) + + const list = perspective.isRaw ? perspective.bundleIds : perspective.bundleStack + // Iterate the release stack in descending precedence, returning the highest precedence existing + // version document. + const version$ = versions$.pipe( + map((versions) => { + if (perspective.isRaw && versions && isVersionId(documentId)) { + const versionId = getVersionFromId(documentId) ?? '' + if (versionId in versions) { + return versions[versionId] + } + } + for (const bundleId of list) { + if (bundleId in versions) { + return versions[bundleId] + } + } + return {snapshot: null} + }), + startWith({snapshot: null}), + ) + const published$ = documentPreviewStore.observeForPreview( {_id: getPublishedId(documentId)}, schemaType, ) - return combineLatest([draft$, published$]).pipe( - map(([draft, published]) => ({ + return combineLatest([draft$, published$, version$, versions$]).pipe( + map(([draft, published, version, versions]) => ({ draft: draft.snapshot ? {title, ...(draft.snapshot || {})} : null, isLoading: false, published: published.snapshot ? {title, ...(published.snapshot || {})} : null, + version: version.snapshot ? {title, ...(version.snapshot || {})} : null, + versions, })), - startWith({draft: null, isLoading: true, published: null}), + startWith({ + draft: null, + isLoading: true, + published: null, + version: null, + versions: {}, + }), ) } diff --git a/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx b/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx index 7f41bbbd800..0627b1d40f3 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx +++ b/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx @@ -2,6 +2,9 @@ import {WarningOutlineIcon} from '@sanity/icons' import {type PreviewValue, type SanityDocument} from '@sanity/types' import {assignWith} from 'lodash' +import {isPerspectiveRaw} from '../../search/common/isPerspectiveRaw' +import {isPublishedId, isVersionId} 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 +21,42 @@ 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 + + // check if it's searching globally + // if it is then use the value directly + if (isPerspectiveRaw(perspective)) { + switch (true) { + case isVersionId(value._id): + snapshot = version + break + case isPublishedId(value._id): + snapshot = published + break + default: + snapshot = draft + } + } else { + switch (true) { + case perspective === 'published': + snapshot = published || draft + break + case typeof perspective !== 'undefined' || isVersionId(value._id): + snapshot = version || draft || published + break + default: + snapshot = draft || published + } + } if (!snapshot) { return getMissingDocumentFallback(value) 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..5b0b52f3f21 --- /dev/null +++ b/packages/sanity/src/core/preview/utils/replayLatest.test.ts @@ -0,0 +1,37 @@ +import {concat, from, lastValueFrom, of, share, timer} from 'rxjs' +import {concatMap, delay, mergeMap, take, toArray} from 'rxjs/operators' +import {expect, test} from 'vitest' + +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/__fixtures__/release.fixture.ts b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts new file mode 100644 index 00000000000..74f64fd6559 --- /dev/null +++ b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts @@ -0,0 +1,89 @@ +import {type ReleaseDocument} from '../store/types' + +export const activeScheduledRelease: ReleaseDocument = { + _rev: 'activeRev', + _id: '_.releases.rActive', + _type: 'system.release', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'active', + metadata: { + title: 'active Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-10T10:00:00Z', + description: 'active Release description', + }, +} + +export const scheduledRelease: ReleaseDocument = { + _rev: 'scheduledRev', + _id: '_.releases.rScheduled', + _type: 'system.release', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'scheduled', + publishAt: '2023-10-10T10:00:00Z', + metadata: { + title: 'scheduled Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-10T10:00:00Z', + description: 'scheduled Release description', + }, +} + +export const activeASAPRelease: ReleaseDocument = { + _rev: 'activeASAPRev', + _id: '_.releases.rASAP', + _type: 'system.release', + _createdAt: '2023-10-01T08:00:00Z', + _updatedAt: '2023-10-01T09:00:00Z', + state: 'active', + metadata: { + title: 'active asap Release', + releaseType: 'asap', + description: 'active Release description', + }, +} + +export const archivedScheduledRelease: ReleaseDocument = { + _rev: 'archivedRev', + _id: '_.releases.rArchived', + _type: 'system.release', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'archived', + metadata: { + title: 'archived Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-10T10:00:00Z', + description: 'archived Release description', + }, +} + +export const publishedASAPRelease: ReleaseDocument = { + _rev: 'publishedRev', + _id: '_.releases.rPublished', + _type: 'system.release', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'published', + metadata: { + title: 'published Release', + releaseType: 'asap', + description: 'archived Release description', + }, +} + +export const activeUndecidedRelease: ReleaseDocument = { + _rev: 'undecidedRev', + _id: '_.releases.rUndecided', + _type: 'system.release', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'active', + metadata: { + title: 'undecided Release', + releaseType: 'undecided', + description: 'undecided Release description', + }, +} 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..2cb287e184a --- /dev/null +++ b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts @@ -0,0 +1,119 @@ +import {defineEvent} from '@sanity/telemetry' + +import {type DocumentVariantType} from '../../util/getDocumentVariantType' + +interface VersionInfo { + /** + * document type that was added + */ + + /** + * the origin of the version created (from a draft or from a version) + */ + documentOrigin: DocumentVariantType +} + +export interface OriginInfo { + /** + * determines where the release was created, either from the structure view or the release plugin + */ + origin: 'structure' | 'release-plugin' +} + +export interface RevertInfo { + /** + * determined whether reverting a release created a new staged release, or immediately reverted + */ + revertType: 'immediate' | 'staged' +} + +/** + * When a document (version) is successfully added to a release + * @internal + */ +export const AddedVersion = defineEvent({ + name: 'Version Document Added to Release ', + version: 1, + description: 'User added a document to a release', +}) + +/** When a release is successfully created + * @internal + */ +export const CreatedRelease = defineEvent({ + name: 'Release Created', + version: 1, + description: 'User created a release', +}) + +/** When a release is successfully updated + * @internal + */ +export const UpdatedRelease = defineEvent({ + name: 'Release Updated', + version: 1, + description: 'User updated a release', +}) + +/** When a release is successfully deleted + * @internal + */ +export const DeletedRelease = defineEvent({ + name: 'Release Deleted', + version: 1, + description: 'User deleted a release', +}) + +/** When a release is successfully published + * @internal + */ +export const PublishedRelease = defineEvent({ + name: 'Release Published', + version: 1, + description: 'User published a release', +}) + +/** When a release is successfully scheduled + * @internal + */ +export const ScheduledRelease = defineEvent({ + name: 'Release Scheduled', + version: 1, + description: 'User scheduled a release', +}) + +/** When a release is successfully scheduled + * @internal + */ +export const UnscheduledRelease = defineEvent({ + name: 'Release Unscheduled', + version: 1, + description: 'User unscheduled a release', +}) + +/** When a release is successfully archived + * @internal + */ +export const ArchivedRelease = defineEvent({ + name: 'Release Archived', + version: 1, + description: 'User archived a release', +}) + +/** When a release is successfully unarchived + * @internal + */ +export const UnarchivedRelease = defineEvent({ + name: 'Release Unarchived', + version: 1, + description: 'User unarchived a release', +}) + +/** When a release is successfully reverted + * @internal + */ +export const RevertRelease = defineEvent({ + name: 'Release Reverted', + version: 1, + description: 'User reverted a release', +}) diff --git a/packages/sanity/src/core/releases/components/ReleaseAvatar.tsx b/packages/sanity/src/core/releases/components/ReleaseAvatar.tsx new file mode 100644 index 00000000000..65b1f811ef7 --- /dev/null +++ b/packages/sanity/src/core/releases/components/ReleaseAvatar.tsx @@ -0,0 +1,29 @@ +import {DotIcon} from '@sanity/icons' +import {type BadgeTone, Box, Text} from '@sanity/ui' +import {type CSSProperties} from 'react' + +/** @internal */ +export function ReleaseAvatar({ + fontSize = 1, + padding = 3, + tone, +}: { + fontSize?: number + padding?: number + tone: BadgeTone +}): React.JSX.Element { + return ( + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/components/VersionInlineBadge.tsx b/packages/sanity/src/core/releases/components/VersionInlineBadge.tsx new file mode 100644 index 00000000000..1a48e57dabe --- /dev/null +++ b/packages/sanity/src/core/releases/components/VersionInlineBadge.tsx @@ -0,0 +1,17 @@ +import {type BadgeTone} from '@sanity/ui' +import {css, styled} from 'styled-components' + +/** + * @internal + */ +export const VersionInlineBadge = styled.span<{$tone?: BadgeTone}>((props) => { + const {$tone} = props + return css` + color: var(--card-badge-${$tone ?? 'default'}-fg-color); + background-color: var(--card-badge-${$tone ?? 'default'}-bg-color); + border-radius: 3px; + text-decoration: none; + padding: 0px 2px; + font-weight: 500; + ` +}) diff --git a/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx new file mode 100644 index 00000000000..727680f07f9 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx @@ -0,0 +1,104 @@ +import {ArrowRightIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Flex, useToast} from '@sanity/ui' +import {type FormEvent, useCallback, useState} from 'react' + +import {Button, Dialog} from '../../../../ui-components' +import {useTranslation} from '../../../i18n' +import {CreatedRelease, type OriginInfo} from '../../__telemetry__/releases.telemetry' +import {type EditableReleaseDocument} from '../../store/types' +import {useReleaseOperations} from '../../store/useReleaseOperations' +import {DEFAULT_RELEASE_TYPE} from '../../util/const' +import {createReleaseId} from '../../util/createReleaseId' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {ReleaseForm} from './ReleaseForm' + +interface CreateReleaseDialogProps { + onCancel: () => void + onSubmit: (createdReleaseId: string) => void + origin?: OriginInfo['origin'] +} + +export function CreateReleaseDialog(props: CreateReleaseDialogProps): React.JSX.Element { + const {onCancel, onSubmit, origin} = props + const toast = useToast() + const {createRelease} = useReleaseOperations() + const {t} = useTranslation() + const telemetry = useTelemetry() + + const [value, setValue] = useState((): EditableReleaseDocument => { + return { + _id: createReleaseId(), + metadata: { + title: '', + description: '', + releaseType: DEFAULT_RELEASE_TYPE, + }, + } as const + }) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleOnSubmit = useCallback( + async (event: FormEvent) => { + try { + event.preventDefault() + setIsSubmitting(true) + + const submitValue = { + ...value, + metadata: {...value.metadata, title: value.metadata?.title?.trim()}, + } + await createRelease(submitValue) + telemetry.log(CreatedRelease, {origin}) + } catch (err) { + console.error(err) + toast.push({ + closable: true, + status: 'error', + title: `Failed to create release`, + }) + } finally { + // TODO: Remove this! temporary fix to give some time for the release to be created and the releases store state updated before closing the dialog. + await new Promise((resolve) => setTimeout(resolve, 1000)) + // TODO: Remove the upper part + + setIsSubmitting(false) + onSubmit(getReleaseIdFromReleaseDocumentId(value._id)) + } + }, + [value, createRelease, telemetry, origin, toast, onSubmit], + ) + + const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => { + setValue(changedValue) + }, []) + + const dialogTitle = t('release.dialog.create.title') + + return ( + +
+ + + + +
+ ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx new file mode 100644 index 00000000000..140abddb2be --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx @@ -0,0 +1,81 @@ +import {Box} from '@sanity/ui' +import {useCallback, useState} from 'react' + +import {Dialog} from '../../../../ui-components' +import {LoadingBlock} from '../../../components' +import {useDocumentOperation, useSchema} from '../../../hooks' +import {useTranslation} from '../../../i18n' +import {Preview} from '../../../preview' +import {getPublishedId, getVersionFromId, isVersionId} from '../../../util/draftUtils' +import {usePerspective, useVersionOperations} from '../../hooks' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' + +/** + * @internal + */ +export function DiscardVersionDialog(props: { + onClose: () => void + documentId: string + documentType: string +}): React.JSX.Element { + const {onClose, documentId, documentType} = props + const {t} = useTranslation(releasesLocaleNamespace) + const {discardChanges} = useDocumentOperation(getPublishedId(documentId), documentType) + + const {selectedPerspective} = usePerspective() + const {discardVersion} = useVersionOperations() + const schema = useSchema() + const [isDiscarding, setIsDiscarding] = useState(false) + + const schemaType = schema.get(documentType) + + const handleDiscardVersion = useCallback(async () => { + setIsDiscarding(true) + + if (isVersionId(documentId)) { + await discardVersion( + getVersionFromId(documentId) || + getReleaseIdFromReleaseDocumentId((selectedPerspective as ReleaseDocument)._id), + documentId, + ) + } else { + // on the document header you can also discard the draft + discardChanges.execute() + } + + setIsDiscarding(false) + + onClose() + }, [selectedPerspective, discardChanges, discardVersion, documentId, onClose]) + + return ( + + + {schemaType ? ( + + ) : ( + + )} + + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx b/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx new file mode 100644 index 00000000000..b00bed41f4b --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx @@ -0,0 +1,164 @@ +import {EarthGlobeIcon, InfoOutlineIcon} from '@sanity/icons' +import {Card, Flex, Stack, TabList, TabPanel, Text} from '@sanity/ui' +import {format, isValid} from 'date-fns' +import {useCallback, useEffect, useMemo, useState} from 'react' + +import {Button, Tab, Tooltip} from '../../../../ui-components' +import {MONTH_PICKER_VARIANT} from '../../../../ui-components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../../ui-components/inputs/DateInputs/calendar/types' +import {DateTimeInput} from '../../../../ui-components/inputs/DateInputs/DateTimeInput' +import {getCalendarLabels} from '../../../form/inputs/DateInputs/utils' +import {useTranslation} from '../../../i18n' +import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone' +import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' +import {type EditableReleaseDocument, type ReleaseType} from '../../store/types' +import {TitleDescriptionForm} from './TitleDescriptionForm' + +const RELEASE_TYPES: ReleaseType[] = ['asap', 'scheduled', 'undecided'] + +/** @internal */ +export function ReleaseForm(props: { + onChange: (params: EditableReleaseDocument) => void + value: EditableReleaseDocument +}): React.JSX.Element { + const {onChange, value} = props + const {releaseType} = value.metadata || {} + const publishAt = value.metadata.intendedPublishAt + const {t} = useTranslation() + + const {DialogTimeZone, dialogProps, dialogTimeZoneShow} = useDialogTimeZone() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const [currentTimezone, setCurrentTimezone] = useState(timeZone.name) + + const [buttonReleaseType, setButtonReleaseType] = useState(releaseType ?? 'asap') + + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(t), [t]) + const [inputValue, setInputValue] = useState(publishAt ? new Date(publishAt) : new Date()) + + const handleBundlePublishAtCalendarChange = useCallback( + (date: Date | null) => { + if (!date) return + + setInputValue(date) + onChange({...value, metadata: {...value.metadata, intendedPublishAt: date.toISOString()}}) + }, + [onChange, value], + ) + + const handleButtonReleaseTypeChange = useCallback( + (pickedReleaseType: ReleaseType) => { + setButtonReleaseType(pickedReleaseType) + onChange({ + ...value, + metadata: {...value.metadata, releaseType: pickedReleaseType, intendedPublishAt: undefined}, + }) + }, + [onChange, value], + ) + + const handleTitleDescriptionChange = useCallback( + (updatedRelease: EditableReleaseDocument) => { + onChange({ + ...value, + metadata: { + ...value.metadata, + title: updatedRelease.metadata.title, + description: updatedRelease.metadata.description, + }, + }) + }, + [onChange, value], + ) + + useEffect(() => { + /** makes sure to wait for the useTimezone has enough time to update + * and based on that it will update the input value to the current timezone + */ + if (timeZone.name !== currentTimezone) { + setCurrentTimezone(timeZone.name) + if (isValid(inputValue)) { + const currentZoneDate = utcToCurrentZoneDate(inputValue) + setInputValue(currentZoneDate) + } + } + }, [currentTimezone, inputValue, timeZone, utcToCurrentZoneDate]) + + return ( + + + + {t('release.dialog.tooltip.title')} + + + {t('release.dialog.tooltip.description')} + + {t('release.dialog.tooltip.note')} + + + } + delay={0} + placement="right-start" + portal + > + +
+ + + + + + + {RELEASE_TYPES.map((type) => ( + handleButtonReleaseTypeChange(type)} + selected={buttonReleaseType === type} + label={t(`release.type.${type}`)} + /> + ))} + + + + {buttonReleaseType === 'scheduled' && ( + + + + + + ) + } + + return {visibleLabelChildren()} + }, [selectedPerspective, t]) + + return ( + + + {releasesToolLink} + {currentGlobalPerspectiveLabel} + + {!isDraftPerspective(selectedPerspective) && ( +
+
+ )} +
+
+ ) +} diff --git a/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx b/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx new file mode 100644 index 00000000000..103cd2babdb --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx @@ -0,0 +1,29 @@ +import {useMemo} from 'react' + +import {type NavbarProps} from '../../config' +import {ReleasesNav} from './ReleasesNav' + +export const ReleasesStudioNavbar = (props: NavbarProps) => { + const actions = useMemo( + (): NavbarProps['__internal_actions'] => [ + { + location: 'topbar', + name: 'releases-topbar', + render: ReleasesNav, + }, + { + location: 'sidebar', + name: 'releases-sidebar', + render: ReleasesNav, + }, + ...(props?.__internal_actions || []), + ], + [props?.__internal_actions], + ) + + return props.renderDefault({ + ...props, + // eslint-disable-next-line camelcase + __internal_actions: actions, + }) +} diff --git a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx new file mode 100644 index 00000000000..c9a16c45717 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx @@ -0,0 +1,288 @@ +import {fireEvent, render, type RenderResult, screen, waitFor, within} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import { + activeASAPRelease, + activeScheduledRelease, + scheduledRelease, +} from '../../__fixtures__/release.fixture' +import {usePerspectiveMockReturn} from '../../hooks/__tests__/__mocks__/usePerspective.mock' +import {useReleasesMockReturn} from '../../store/__tests__/__mocks/useReleases.mock' +import {LATEST} from '../../util/const' +import {ReleasesNav} from '../ReleasesNav' + +vi.mock('../../hooks/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +vi.mock('../../store/useReleases', () => ({ + useReleases: vi.fn(() => useReleasesMockReturn), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: vi.fn().mockImplementation((props) => ), + useRouterState: vi.fn().mockReturnValue(undefined), +})) + +let currentRenderedInstance: RenderResult | undefined + +const renderTest = async () => { + const wrapper = await createTestProvider({ + resources: [], + }) + currentRenderedInstance = render(, {wrapper}) + + return currentRenderedInstance +} + +describe('ReleasesNav', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('should have link to releases tool', async () => { + await renderTest() + + const releasesLink = screen.getByRole('link') + + expect(releasesLink).toHaveAttribute('href', '/') + expect(releasesLink).not.toHaveAttribute('data-selected') + }) + + it('should have dropdown menu for global perspectives', async () => { + await renderTest() + + screen.getByTestId('global-perspective-menu-button') + }) + + it('should not have clear button when no perspective is chosen', async () => { + await renderTest() + + expect(screen.queryByTestId('clear-perspective-button')).toBeNull() + }) + + it('should have clear button to unset perspective when a perspective is chosen', async () => { + usePerspectiveMockReturn.selectedPerspective = activeScheduledRelease + + await renderTest() + + fireEvent.click(screen.getByTestId('clear-perspective-button')) + + expect(usePerspectiveMockReturn.setPerspective).toHaveBeenCalledWith(LATEST) + }) + + it('should list the title of the chosen perspective', async () => { + usePerspectiveMockReturn.selectedPerspective = activeScheduledRelease + + await renderTest() + + screen.getByText('active Release') + }) + + it('should show release avatar for chosen perspective', async () => { + usePerspectiveMockReturn.selectedPerspective = activeASAPRelease + + await renderTest() + + screen.getByTestId('release-avatar-critical') + }) + + describe('global perspective menu', () => { + const renderAndWaitForStableMenu = async () => { + await renderTest() + + fireEvent.click(screen.getByTestId('global-perspective-menu-button')) + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).toBeNull() + }) + } + + beforeEach(async () => { + useReleasesMockReturn.data = [ + activeScheduledRelease, + { + ...activeScheduledRelease, + _id: '_.releases.rScheduled2', + metadata: {...activeScheduledRelease.metadata, title: 'active Scheduled 2'}, + }, + activeASAPRelease, + + {...scheduledRelease, publishAt: '2023-10-10T09:00:00Z'}, + ] + }) + + describe('when menu is ready', () => { + beforeEach(renderAndWaitForStableMenu) + + it('should show published perspective item', async () => { + within(screen.getByTestId('release-menu')).getByText('Published') + + fireEvent.click(screen.getByText('Published')) + + expect(usePerspectiveMockReturn.setPerspective).toHaveBeenCalledWith('published') + }) + + it('should list all the releases', async () => { + const releaseMenu = within(screen.getByTestId('release-menu')) + + // section titles + releaseMenu.getByText('ASAP') + releaseMenu.getByText('At time') + expect(releaseMenu.queryByText('Undecided')).toBeNull() + + // releases + releaseMenu.getByText('active Release') + releaseMenu.getByText('active Scheduled 2') + releaseMenu.getByText('active asap Release') + releaseMenu.getByText('scheduled Release') + }) + + it('should show the intended release date for intended schedule releases', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('should show the actual release date for a scheduled release', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-lock-icon') + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('allows for new release to be created', async () => { + fireEvent.click(screen.getByText('New release')) + + expect(screen.getByRole('dialog')).toHaveAttribute('id', 'create-release-dialog') + }) + }) + + describe('release layering', () => { + beforeEach(() => { + // since usePerspective is mocked, and the layering exclude toggle is + // controlled by currentGlobalBundleId, we need to manually set it + // to the release that will be selected in below tests + usePerspectiveMockReturn.selectedReleaseId = 'rScheduled2' + // add an undecided release to expand testing + useReleasesMockReturn.data = [ + ...useReleasesMockReturn.data, + { + ...activeASAPRelease, + _id: '_.releases.rUndecided', + metadata: { + ...activeASAPRelease.metadata, + title: 'undecided Release', + releaseType: 'undecided', + }, + }, + ] + }) + + describe('when a release is clicked', () => { + beforeEach(async () => { + await renderAndWaitForStableMenu() + + // select a release that has some other nested layer releases + fireEvent.click(screen.getByText('active Scheduled 2')) + }) + + it('should set a given perspective from the menu', async () => { + expect(usePerspectiveMockReturn.setPerspective).toHaveBeenCalledWith('rScheduled2') + }) + + it('should allow for hiding of any deeper layered releases', async () => { + const deepLayerRelease = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + // toggle to hide + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith('rActive') + + // toggle to include + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith('rActive') + }) + + it('should not allow for hiding of published perspective', async () => { + const publishedRelease = within(screen.getByTestId('release-menu')) + .getByText('Published') + .closest('button')! + + expect( + within(publishedRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should allow for hiding of draft perspective', async () => { + const drafts = within(screen.getByTestId('release-menu')) + .getByText('Drafts') + .closest('button')! + + expect(within(drafts).queryByTestId('release-toggle-visibility')).toBeInTheDocument() + // toggle to hide + fireEvent.click(within(drafts).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith('drafts') + + // toggle to include + fireEvent.click(within(drafts).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith('drafts') + }) + + it('should not allow hiding of the current perspective', async () => { + const currentRelease = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + expect( + within(currentRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of un-nested releases', async () => { + const unNestedRelease = within(screen.getByTestId('release-menu')) + .getByText('undecided Release') + .closest('button')! + + expect( + within(unNestedRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of locked in scheduled releases', async () => { + const scheduledReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + expect( + within(scheduledReleaseMenuItem).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + }) + + it('applies existing layering when opened', async () => { + usePerspectiveMockReturn.isPerspectiveExcluded.mockImplementation((id) => { + return id === 'rActive' + }) + + await renderAndWaitForStableMenu() + + const activeReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + expect( + within(activeReleaseMenuItem).queryByTestId('release-avatar-primary'), + ).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts b/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts new file mode 100644 index 00000000000..93bada9ef55 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts @@ -0,0 +1,47 @@ +import {useCallback, useMemo, useRef, useState} from 'react' + +export type ScrollElement = HTMLDivElement | null + +function isElementVisibleInContainer(container: ScrollElement, element: ScrollElement) { + if (!container || !element) return true + + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + + // 32.5px is padding on published/draft element + padding of perspective/draft menu item + const isVisible = elementRect.top >= containerRect.top + 32.5 * 2 + + return isVisible +} + +export const useScrollIndicatorVisibility = () => { + const scrollContainerRef = useRef(null) + const scrollElementRef = useRef(null) + + const [isRangeVisible, setIsRangeVisible] = useState(true) + + const handleScroll = useCallback( + () => + setIsRangeVisible( + isElementVisibleInContainer(scrollContainerRef.current, scrollElementRef.current), + ), + [], + ) + + const setScrollContainer = useCallback((container: HTMLDivElement) => { + scrollContainerRef.current = container + }, []) + + const resetRangeVisibility = useCallback(() => setIsRangeVisible(true), []) + + return useMemo( + () => ({ + resetRangeVisibility, + onScroll: handleScroll, + isRangeVisible, + setScrollContainer, + scrollElementRef, + }), + [handleScroll, isRangeVisible, resetRangeVisibility, setScrollContainer], + ) +} diff --git a/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx new file mode 100644 index 00000000000..5dab17dfd36 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx @@ -0,0 +1,18 @@ +import {type LayoutProps} from '../../config' +import {AddonDatasetProvider} from '../../studio' +import {ReleasesMetadataProvider} from '../contexts/ReleasesMetadataProvider' + +export function ReleasesStudioLayout(props: LayoutProps) { + // TODO: Replace for useReleasesEnabled + const {enabled} = {enabled: true} + + if (!enabled) { + return props.renderDefault(props) + } + + return ( + + {props.renderDefault(props)} + + ) +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx new file mode 100644 index 00000000000..d329d2e1855 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx @@ -0,0 +1,66 @@ +import {TrashIcon} from '@sanity/icons' +import {useCallback, useState} from 'react' + +import {InsufficientPermissionsMessage} from '../../../components/InsufficientPermissionsMessage' +import { + type DocumentActionDescription, + type DocumentActionProps, +} from '../../../config/document/actions' +import {useDocumentPairPermissions} from '../../../store/_legacy/grants/documentPairPermissions' +import {useCurrentUser} from '../../../store/user/hooks' +import {DiscardVersionDialog} from '../../components/dialog/DiscardVersionDialog' + +/** + * @internal + */ +export const DiscardVersionAction = ( + props: DocumentActionProps, +): DocumentActionDescription | null => { + const {id, type, release, version} = props + const currentUser = useCurrentUser() + + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + version: release, + permission: 'publish', + }) + + const [dialogOpen, setDialogOpen] = useState(false) + + // Callbacks + const handleDialogOpen = useCallback(() => { + setDialogOpen(true) + }, []) + + const insufficientPermissions = !isPermissionsLoading && !permissions?.granted + + if (insufficientPermissions) { + return { + disabled: true, + icon: TrashIcon, + label: 'no permissions', + title: , + } + } + + return { + dialog: dialogOpen && + version && { + type: 'custom', + component: ( + setDialogOpen(false)} + /> + ), + }, + /** @todo translate */ + label: 'Discard version', + icon: TrashIcon, + onHandle: handleDialogOpen, + /** @todo translate */ + title: 'Discard version', + } +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx b/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx new file mode 100644 index 00000000000..6b82af119e8 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx @@ -0,0 +1,74 @@ +import {TrashIcon, UnpublishIcon} from '@sanity/icons' +import {useCallback, useState} from 'react' +import {useTranslation} from 'react-i18next' + +import {InsufficientPermissionsMessage} from '../../../components/InsufficientPermissionsMessage' +import { + type DocumentActionDescription, + type DocumentActionProps, +} from '../../../config/document/actions' +import {useDocumentPairPermissions} from '../../../store/_legacy/grants/documentPairPermissions' +import {useCurrentUser} from '../../../store/user/hooks' +import {UnpublishVersionDialog} from '../../components/dialog/UnpublishVersionDialog' +import {releasesLocaleNamespace} from '../../i18n' +import {isGoingToUnpublish} from '../../util/isGoingToUnpublish' + +/** + * @internal + */ +export const UnpublishVersionAction = ( + props: DocumentActionProps, +): DocumentActionDescription | null => { + const {id, type, release, published, version} = props + const currentUser = useCurrentUser() + const isPublished = published !== null + const {t} = useTranslation(releasesLocaleNamespace) + const isAlreadyUnpublished = version ? isGoingToUnpublish(version) : false + + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + version: release, + permission: 'unpublish', + }) + + const [dialogOpen, setDialogOpen] = useState(false) + + const handleDialogOpen = useCallback(() => { + setDialogOpen(true) + }, []) + + const insufficientPermissions = !isPermissionsLoading && !permissions?.granted + + if (insufficientPermissions) { + return { + disabled: true, + icon: TrashIcon, + label: 'no permissions', + title: ( + + ), + } + } + + return { + dialog: dialogOpen && + version && { + type: 'custom', + component: ( + setDialogOpen(false)} + /> + ), + }, + /** @todo should be switched once we have the document actions updated */ + label: t('action.unpublish-doc-actions'), + icon: UnpublishIcon, + onHandle: handleDialogOpen, + disabled: !isPublished || isAlreadyUnpublished, + /** @todo should be switched once we have the document actions updated */ + title: t('action.unpublish-doc-actions'), + } +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/index.ts b/packages/sanity/src/core/releases/plugin/documentActions/index.ts new file mode 100644 index 00000000000..fb981ed5008 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/index.ts @@ -0,0 +1,17 @@ +import {type DocumentActionComponent} from '../../../config/document/actions' +import {type DocumentActionsContext} from '../../../config/types' +import {DiscardVersionAction} from './DiscardVersionAction' +import {UnpublishVersionAction} from './UnpublishVersionAction' + +type Action = DocumentActionComponent + +export default function resolveDocumentActions( + existingActions: Action[], + context: DocumentActionsContext, +): Action[] { + const duplicateAction = existingActions.filter(({name}) => name === 'DuplicateAction') + + return context.versionType === 'version' + ? duplicateAction.concat(DiscardVersionAction).concat(UnpublishVersionAction) + : existingActions +} diff --git a/packages/sanity/src/core/releases/plugin/index.ts b/packages/sanity/src/core/releases/plugin/index.ts new file mode 100644 index 00000000000..417e6a0d164 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/index.ts @@ -0,0 +1,64 @@ +import {route} from 'sanity/router' + +import {definePlugin} from '../../config/definePlugin' +import {releasesUsEnglishLocaleBundle} from '../i18n' +import {ReleasesStudioNavbar} from '../navbar/ReleasesStudioNavbar' +import {ReleasesTool} from '../tool/ReleasesTool' +import resolveDocumentActions from './documentActions' +import {ReleasesStudioLayout} from './ReleasesStudioLayout' + +/** + * @internal + */ +export const RELEASES_NAME = 'sanity/releases' + +/** + * @internal + */ +export const RELEASES_TOOL_NAME = 'releases' + +/** + * @internal + */ +export const RELEASES_INTENT = 'release' + +/** + * @internal + */ +export const releases = definePlugin({ + name: RELEASES_NAME, + studio: { + components: { + layout: ReleasesStudioLayout, + navbar: ReleasesStudioNavbar, + }, + }, + tools: [ + { + name: RELEASES_TOOL_NAME, + title: 'Releases', + component: ReleasesTool, + router: route.create('/', [route.create('/:releaseId')]), + canHandleIntent: (intent) => { + // If intent is release, open the releases tool. + return Boolean(intent === RELEASES_INTENT) + }, + getIntentState(intent, params) { + if (intent === RELEASES_INTENT) { + return {releaseId: params.id} + } + return null + }, + }, + ], + i18n: { + bundles: [releasesUsEnglishLocaleBundle], + }, + document: { + actions: (actions, context) => resolveDocumentActions(actions, context), + }, + // eslint-disable-next-line camelcase + __internal_serverDocumentActions: { + enabled: false, + }, +}) diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts new file mode 100644 index 00000000000..dcb169cf213 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts @@ -0,0 +1,25 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import { + createReleaseOperationsStore, + type ReleaseOperationsStore, +} from '../../createReleaseOperationStore' + +export const createReleaseOperationsStoreReturn: Mocked = { + archive: vi.fn(), + unarchive: vi.fn(), + createRelease: vi.fn(), + createVersion: vi.fn(), + discardVersion: vi.fn(), + publishRelease: vi.fn(), + schedule: vi.fn(), + unschedule: vi.fn(), + updateRelease: vi.fn(), + deleteRelease: vi.fn(), + revertRelease: vi.fn(), + unpublishVersion: vi.fn(), +} + +export const mockCreateReleaseOperationsStore = createReleaseOperationsStore as Mock< + typeof createReleaseOperationsStore +> diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts new file mode 100644 index 00000000000..9b7ce14aeca --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts @@ -0,0 +1,21 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {type ReleaseOperationsStore} from '../../createReleaseOperationStore' +import {useReleaseOperations} from '../../useReleaseOperations' + +export const useReleaseOperationsMockReturn: Mocked = { + archive: vi.fn(), + unarchive: vi.fn(), + createRelease: vi.fn(), + createVersion: vi.fn(), + discardVersion: vi.fn(), + publishRelease: vi.fn(), + schedule: vi.fn(), + unschedule: vi.fn(), + updateRelease: vi.fn(), + deleteRelease: vi.fn(), + revertRelease: vi.fn(), + unpublishVersion: vi.fn(), +} + +export const mockUseReleaseOperations = useReleaseOperations as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts new file mode 100644 index 00000000000..19b1c6874f5 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts @@ -0,0 +1,14 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {useReleases} from '../../useReleases' + +export const useReleasesMockReturn: Mocked> = { + archivedReleases: [], + data: [], + dispatch: vi.fn(), + error: undefined, + loading: false, + releasesIds: [], +} + +export const mockUseReleases = useReleases as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts new file mode 100644 index 00000000000..dd384c78de1 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked} from 'vitest' + +import {useReleasesMetadata} from '../../useReleasesMetadata' + +export const useReleasesMetadataMockReturn: Mocked> = { + data: null, + error: null, + loading: false, +} + +export const mockUseReleasesMetadata = useReleasesMetadata as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts new file mode 100644 index 00000000000..68a9145425c --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts @@ -0,0 +1,355 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {type RevertDocument} from '../../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' +import {createReleaseOperationsStore} from '../createReleaseOperationStore' +import {type ReleaseDocument} from '../types' + +describe('createReleaseOperationsStore', () => { + let mockClient: any + + beforeEach(() => { + mockClient = { + config: vi.fn().mockReturnValue({dataset: 'test-dataset'}), + request: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + getDocument: vi.fn(), + } + }) + + const createStore = () => createReleaseOperationsStore({client: mockClient}) + + it('should create a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Test Release'}} + await store.createRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: 'release-id', + metadata: release.metadata, + }, + ], + }, + }) + }) + + it('should update a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Updated Title'}} + await store.updateRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.edit', + releaseId: 'release-id', + patch: { + set: {metadata: release.metadata}, + unset: [], + }, + }, + ], + }, + }) + }) + + it('should publish a release using new publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', true) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish2', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should publish a release using stable publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', false) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should schedule a release', async () => { + const store = createStore() + const date = new Date('2024-01-01T00:00:00Z') + await store.schedule('_.releases.release-id', date) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.schedule', + releaseId: 'release-id', + publishAt: date.toISOString(), + }, + ], + }, + }) + }) + + it('should unschedule a release', async () => { + const store = createStore() + await store.unschedule('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should archive a release', async () => { + const store = createStore() + await store.archive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.archive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should unarchive a release', async () => { + const store = createStore() + await store.unarchive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should delete a release', async () => { + const store = createStore() + await store.deleteRelease('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.delete', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + describe('revertRelease', () => { + let store: ReturnType + const revertReleaseId: string = 'revert-release-id' + const revertReleaseDocumentId: string = '_.releases.revert-release-id' + let releaseDocuments: RevertDocument[] + let releaseMetadata: ReleaseDocument['metadata'] + + beforeEach(() => { + store = createStore() + releaseDocuments = [{_id: 'doc1'}, {_id: 'doc2'}] as RevertDocument[] + releaseMetadata = { + title: 'Revert Release', + description: 'A reverted release', + } as ReleaseDocument['metadata'] + }) + + it('should create a new release and publish immediately when revertType is "immediate"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'immediate', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'revert-release-id', + }, + ], + }, + }) + }) + + it('should create a new release without publishing when revertType is "staged"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.request).toHaveBeenCalledTimes(1) + }) + + it('should fail if a document does not exist and no initial value is provided', async () => { + mockClient.getDocument.mockResolvedValueOnce(null) // Simulate a missing document + + await expect( + store.revertRelease( + revertReleaseDocumentId, + [{_id: 'missing-doc'}] as RevertDocument[], + releaseMetadata, + 'staged', + ), + ).resolves.toBeUndefined() + }) + + it('should handle partial failure gracefully when creating versions', async () => { + mockClient.create.mockRejectedValueOnce(new Error('Failed to create version')) + + const result = await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(result).toBeUndefined() + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + }) + + it('should throw an error if creating the release fails', async () => { + mockClient.request.mockRejectedValueOnce(new Error('Failed to create release')) + + await expect( + store.revertRelease(revertReleaseDocumentId, releaseDocuments, releaseMetadata, 'staged'), + ).rejects.toThrow('Failed to create release') + }) + }) + + it('should create a version of a document', async () => { + const store = createStore() + mockClient.getDocument.mockResolvedValue({_id: 'doc-id', data: 'example'}) + await store.createVersion('release-id', 'doc-id', {newData: 'value'}) + expect(mockClient.create).toHaveBeenCalledWith({ + _id: `versions.release-id.doc-id`, + data: 'example', + newData: 'value', + }) + }) + + it('should discard a version of a document', async () => { + const store = createStore() + await store.discardVersion('release-id', 'doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.discard', + draftId: 'versions.release-id.doc-id', + }, + ], + }, + }) + }) + + it('should unpublish a version of a document', async () => { + const store = createStore() + await store.unpublishVersion('doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.version.unpublish', + draftId: 'doc-id', + publishedId: `doc-id`, + }, + ], + }, + }) + }) +}) diff --git a/packages/sanity/src/core/releases/store/constants.ts b/packages/sanity/src/core/releases/store/constants.ts new file mode 100644 index 00000000000..02d29f8a6ec --- /dev/null +++ b/packages/sanity/src/core/releases/store/constants.ts @@ -0,0 +1,4 @@ +// api extractor take issues with 'as const' for literals +// eslint-disable-next-line @typescript-eslint/prefer-as-const +export const RELEASE_DOCUMENT_TYPE: 'system.release' = 'system.release' +export const RELEASE_DOCUMENTS_PATH = '_.releases' diff --git a/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts b/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts new file mode 100644 index 00000000000..8bc84b1f19d --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts @@ -0,0 +1,152 @@ +import {type SanityClient} from '@sanity/client' +import { + bufferTime, + catchError, + EMPTY, + filter, + iif, + merge, + type Observable, + of, + startWith, + switchMap, +} from 'rxjs' + +import {getReleaseIdFromReleaseDocumentId} from '../util/getReleaseIdFromReleaseDocumentId' +import {type ReleasesMetadata} from './useReleasesMetadata' + +export type ReleasesMetadataMap = Record + +export type MetadataWrapper = {data: ReleasesMetadataMap | null; error: null; loading: boolean} + +const getFetchQuery = (releaseIds: string[]) => { + // projection key must be string - cover the case that a bundle has a number as first char + const getSafeKey = (id: string) => `release_${id.replaceAll('-', '_')}` + + return releaseIds.reduce( + ({subquery: accSubquery, projection: accProjection}, releaseId) => { + const bundleId = getReleaseIdFromReleaseDocumentId(releaseId) + // get a version of the id that is safe to use as key in objects + const safeId = getSafeKey(bundleId) + + const subquery = `${accSubquery}"${safeId}": *[_id in path("versions.${bundleId}.*")]{_updatedAt, "docId": string::split(_id, ".")[2] } | order(_updatedAt desc),` + + const projection = `${accProjection}"${releaseId}": { + "updatedAt": ${safeId}[0]._updatedAt, + "documentIds": ${safeId}[].docId, + },` + + return {subquery, projection} + }, + {subquery: '', projection: ''}, + ) +} + +/** + * @internal + * + * An initial fetch is made. This fetch is polled whenever a listener even is emitted + * Only releases that have been mutated are re-fetched + * + * @returns an Observable that accepts a list of release slugs and returns a stream of metadata + */ +export const createReleaseMetadataAggregator = (client: SanityClient | null) => { + const aggregatorFetch$ = ( + releaseIds: string[], + isInitialLoad: boolean = false, + ): Observable => { + if (!releaseIds?.length || !client) return of({data: null, error: null, loading: false}) + + const {subquery: queryAllDocumentsInReleases, projection: projectionToBundleMetadata} = + getFetchQuery(releaseIds) + + const fetchData$ = client.observable + .fetch< + Record< + string, + Omit & { + documentIds: string[] + } + > + >( + `{${queryAllDocumentsInReleases}}{${projectionToBundleMetadata}}`, + {}, + {tag: 'release-docs.fetch'}, + ) + .pipe( + switchMap((releaseDocumentIdResponse) => + of({ + data: Object.entries(releaseDocumentIdResponse).reduce((existing, el) => { + const [releaseId, metadata] = el + return { + ...existing, + [releaseId]: {...metadata, documentCount: metadata.documentIds?.length || 0}, + } + }, {}), + error: null, + loading: false, + }), + ), + catchError((error) => { + console.error('Failed to fetch release metadata', error) + return of({data: null, error, loading: false}) + }), + ) + + // initially emit loading empty state if first fetch + return iif( + () => isInitialLoad, + fetchData$.pipe(startWith({loading: true, data: null, error: null})), + fetchData$, + ) + } + + const aggregatorListener$ = (releaseIds: string[]) => { + if (!releaseIds?.length || !client) return EMPTY + + return client.observable + .listen( + `*[(${releaseIds.reduce( + (accQuery, releaseId, index) => + `${accQuery}${index === 0 ? '' : ' ||'} _id in path("versions.${releaseId}.*")`, + '', + )})]`, + {}, + { + includeResult: true, + visibility: 'query', + events: ['mutation'], + tag: 'release-docs.listen', + }, + ) + .pipe( + catchError((error) => { + console.error('Failed to listen for release metadata', error) + return EMPTY + }), + bufferTime(1_000), + filter((entriesArray) => entriesArray.length > 0), + switchMap((entriesArray) => { + const mutatedReleaseIds = entriesArray.reduce((accReleaseIds, event) => { + if ('type' in event && event.type === 'mutation') { + const releaseId = event.documentId.split('.')[1] + // de-dup mutated bundle slugs + if (accReleaseIds.includes(releaseId)) return accReleaseIds + + return [...accReleaseIds, releaseId] + } + return accReleaseIds + }, []) + + if (mutatedReleaseIds.length) { + return aggregatorFetch$(mutatedReleaseIds) + } + + return EMPTY + }), + ) + } + + return (releaseIds: string[]) => + merge(aggregatorFetch$(releaseIds, true), aggregatorListener$(releaseIds)) +} diff --git a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts new file mode 100644 index 00000000000..cec8509b5a5 --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts @@ -0,0 +1,292 @@ +import { + type Action, + type EditAction, + type IdentifiedSanityDocumentStub, + type SanityClient, +} from '@sanity/client' + +import {getPublishedId, getVersionId} from '../../util' +import {getReleaseIdFromReleaseDocumentId, type ReleaseDocument} from '../index' +import {type RevertDocument} from '../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' +import {type EditableReleaseDocument} from './types' + +export interface ReleaseOperationsStore { + publishRelease: (releaseId: string, useUnstableAction?: boolean) => Promise + schedule: (releaseId: string, date: Date) => Promise + //todo: reschedule: (releaseId: string, newDate: Date) => Promise + unschedule: (releaseId: string) => Promise + archive: (releaseId: string) => Promise + unarchive: (releaseId: string) => Promise + updateRelease: (release: EditableReleaseDocument) => Promise + createRelease: (release: EditableReleaseDocument) => Promise + deleteRelease: (releaseId: string) => Promise + revertRelease: ( + revertReleaseId: string, + documents: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => Promise + createVersion: ( + releaseId: string, + documentId: string, + initialvalue?: Record, + ) => Promise + discardVersion: (releaseId: string, documentId: string) => Promise + unpublishVersion: (documentId: string) => Promise +} + +const IS_CREATE_VERSION_ACTION_SUPPORTED = false +const METADATA_PROPERTY_NAME = 'metadata' + +export function createReleaseOperationsStore(options: { + client: SanityClient +}): ReleaseOperationsStore { + const {client} = options + const handleCreateRelease = (release: EditableReleaseDocument) => + requestAction(client, { + actionType: 'sanity.action.release.create', + releaseId: getReleaseIdFromReleaseDocumentId(release._id), + [METADATA_PROPERTY_NAME]: release.metadata, + }) + + const handleUpdateRelease = async (release: EditableReleaseDocument) => { + const bundleId = getReleaseIdFromReleaseDocumentId(release._id) + + const unsetKeys = Object.entries(release) + .filter(([_, value]) => value === undefined) + .map(([key]) => `${METADATA_PROPERTY_NAME}.${key}`) + + await requestAction(client, { + actionType: 'sanity.action.release.edit', + releaseId: bundleId, + patch: { + // todo: consider more granular updates here + set: {[METADATA_PROPERTY_NAME]: release.metadata}, + unset: unsetKeys, + }, + }) + } + + const handlePublishRelease = (releaseId: string, useUnstableAction?: boolean) => + requestAction(client, [ + { + actionType: useUnstableAction + ? 'sanity.action.release.publish2' + : 'sanity.action.release.publish', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleScheduleRelease = (releaseId: string, publishAt: Date) => + requestAction(client, [ + { + actionType: 'sanity.action.release.schedule', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + publishAt: publishAt.toISOString(), + }, + ]) + + const handleUnscheduleRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleArchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.archive', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleUnarchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleDeleteRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.delete', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleCreateVersion = async ( + releaseId: string, + documentId: string, + initialValue?: Record, + ) => { + // the documentId will show you where the document is coming from and which + // document should it copy from + + // fetch original document + const document = await client.getDocument(documentId) + + if (!document && !initialValue) { + throw new Error(`Document with id ${documentId} not found and no initial value provided`) + } + + const versionDocument = { + ...(document || {}), + ...(initialValue || {}), + _id: getVersionId(documentId, releaseId), + } as IdentifiedSanityDocumentStub + + await (IS_CREATE_VERSION_ACTION_SUPPORTED + ? requestAction(client, [ + { + actionType: 'sanity.action.document.createVersion', + releaseId, + attributes: versionDocument, + }, + ]) + : client.create(versionDocument)) + } + + const handleDiscardVersion = (releaseId: string, documentId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.document.discard', + draftId: getVersionId(documentId, releaseId), + }, + ]) + + const handleUnpublishVersion = (documentId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.document.version.unpublish', + draftId: documentId, + publishedId: getPublishedId(documentId), + }, + ]) + + const handleRevertRelease = async ( + revertReleaseId: string, + releaseDocuments: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => { + await handleCreateRelease({ + _id: revertReleaseId, + metadata: { + title: releaseMetadata.title, + description: releaseMetadata.description, + releaseType: 'asap', + }, + }) + await Promise.allSettled( + releaseDocuments.map((document) => + handleCreateVersion( + getReleaseIdFromReleaseDocumentId(revertReleaseId), + document._id, + document, + ), + ), + ) + + if (revertType === 'immediate') { + await handlePublishRelease(revertReleaseId) + } + } + + return { + archive: handleArchiveRelease, + unarchive: handleUnarchiveRelease, + schedule: handleScheduleRelease, + unschedule: handleUnscheduleRelease, + createRelease: handleCreateRelease, + updateRelease: handleUpdateRelease, + publishRelease: handlePublishRelease, + deleteRelease: handleDeleteRelease, + revertRelease: handleRevertRelease, + createVersion: handleCreateVersion, + discardVersion: handleDiscardVersion, + unpublishVersion: handleUnpublishVersion, + } +} + +interface ScheduleApiAction { + actionType: 'sanity.action.release.schedule' + releaseId: string + publishAt: string +} + +interface PublishApiAction { + actionType: 'sanity.action.release.publish' | 'sanity.action.release.publish2' + releaseId: string +} + +interface ArchiveApiAction { + actionType: 'sanity.action.release.archive' + releaseId: string +} + +interface UnarchiveApiAction { + actionType: 'sanity.action.release.unarchive' + releaseId: string +} + +interface UnscheduleApiAction { + actionType: 'sanity.action.release.unschedule' + releaseId: string +} + +interface CreateReleaseApiAction { + actionType: 'sanity.action.release.create' + releaseId: string + [METADATA_PROPERTY_NAME]?: Partial +} + +interface CreateVersionReleaseApiAction { + actionType: 'sanity.action.document.createVersion' + releaseId: string + attributes: IdentifiedSanityDocumentStub +} + +interface UnpublishVersionReleaseApiAction { + actionType: 'sanity.action.document.version.unpublish' + draftId: string + publishedId: string +} + +interface EditReleaseApiAction { + actionType: 'sanity.action.release.edit' + releaseId: string + patch: EditAction['patch'] +} + +interface DeleteApiAction { + actionType: 'sanity.action.release.delete' + releaseId: string +} + +type ReleaseAction = + | Action + | ScheduleApiAction + | PublishApiAction + | CreateReleaseApiAction + | EditReleaseApiAction + | UnscheduleApiAction + | ArchiveApiAction + | UnarchiveApiAction + | DeleteApiAction + | CreateVersionReleaseApiAction + | UnpublishVersionReleaseApiAction + +export function requestAction(client: SanityClient, actions: ReleaseAction | ReleaseAction[]) { + const {dataset} = client.config() + return client.request({ + uri: `/data/actions/${dataset}`, + method: 'POST', + body: { + actions: Array.isArray(actions) ? actions : [actions], + }, + }) +} diff --git a/packages/sanity/src/core/releases/store/createReleaseStore.ts b/packages/sanity/src/core/releases/store/createReleaseStore.ts new file mode 100644 index 00000000000..66a77e4e69b --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseStore.ts @@ -0,0 +1,188 @@ +import {ClientError, type SanityClient} from '@sanity/client' +import { + BehaviorSubject, + catchError, + concat, + concatWith, + EMPTY, + filter, + from, + merge, + type Observable, + of, + scan, + shareReplay, + Subject, + switchMap, + tap, +} from 'rxjs' +import {map, mergeMap, startWith, toArray} from 'rxjs/operators' + +import {type DocumentPreviewStore} from '../../preview' +import {listenQuery} from '../../store/_legacy' +import {RELEASE_DOCUMENT_TYPE, RELEASE_DOCUMENTS_PATH} from './constants' +import {createReleaseMetadataAggregator} from './createReleaseMetadataAggregator' +import {requestAction} from './createReleaseOperationStore' +import {releasesReducer, type ReleasesReducerAction, type ReleasesReducerState} from './reducer' +import {type ReleaseDocument, type ReleaseStore} from './types' + +type ActionWrapper = {action: ReleasesReducerAction} +type ResponseWrapper = {response: ReleaseDocument[]} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTER = `_type=="${RELEASE_DOCUMENT_TYPE}" && _id in path("${RELEASE_DOCUMENTS_PATH}.*")` + +// TODO: Extend the projection with the fields needed +const QUERY_PROJECTION = `{ + ..., +}` + +// Newest releases first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTER}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +const INITIAL_STATE: ReleasesReducerState = { + releases: new Map(), + state: 'initialising' as const, +} + +const RELEASE_METADATA_TMP_DOC_PATH = 'system-tmp-releases' +// todo: remove this after first tagged release +function migrateWith(client: SanityClient) { + return client.observable.fetch(`*[_id in path('${RELEASE_METADATA_TMP_DOC_PATH}.**')]`).pipe( + tap((tmpDocs: ReleaseDocument[]) => { + // eslint-disable-next-line + console.log('Migrating %d release documents', tmpDocs.length) + }), + mergeMap((tmpDocs: ReleaseDocument[]) => { + if (tmpDocs.length === 0) { + return EMPTY + } + return from(tmpDocs).pipe( + mergeMap(async (tmpDoc) => { + const releaseId = tmpDoc._id.slice(RELEASE_METADATA_TMP_DOC_PATH.length + 1) + await requestAction(client, { + actionType: 'sanity.action.release.edit', + releaseId, + patch: { + set: {metadata: tmpDoc.metadata}, + }, + }).catch((err) => { + if (err instanceof ClientError) { + if (err.details.description == `Release "${releaseId}" was not found`) { + // ignore + return + } + } + throw err + }) + await client.delete(tmpDoc._id) + }, 2), + ) + }), + toArray(), + tap((migrated) => { + // eslint-disable-next-line + console.log('Migrated %d releases', migrated.length) + }), + mergeMap(() => EMPTY), + ) +} +/** + * The releases store is initialised lazily when first subscribed to. Upon subscription, it will + * fetch a list of releases and create a listener to keep the locally held state fresh. + * + * The store is not disposed of when all subscriptions are closed. After it has been initialised, + * it will keep listening for the duration of the app's lifecycle. Subsequent subscriptions will be + * given the latest state upon subscription. + */ +export function createReleaseStore(context: { + previewStore: DocumentPreviewStore + client: SanityClient +}): ReleaseStore { + const {client} = context + + const dispatch$ = new Subject() + const fetchPending$ = new BehaviorSubject(false) + + function dispatch(action: ReleasesReducerAction): void { + dispatch$.next(action) + } + + const listFetch$ = of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: true, + error: undefined, + }, + }, + }).pipe( + // Ignore invocations while the list fetch is pending. + filter(() => !fetchPending$.value), + tap(() => fetchPending$.next(true)), + concatWith( + listenQuery(client, QUERY, {}, {tag: 'releases.listen'}).pipe( + tap(() => fetchPending$.next(false)), + map((releases) => + releases.map( + (releaseDoc: ReleaseDocument): ReleaseDocument => ({ + ...releaseDoc, + metadata: {...(releaseDoc as any).userMetadata, ...releaseDoc.metadata}, + }), + ), + ), + map((releases) => ({response: releases})), + ), + ), + + catchError((error) => + of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error, + }, + }, + }), + ), + switchMap>( + (entry) => { + if ('action' in entry) { + return of(entry.action) + } + + return of( + {type: 'RELEASES_SET', payload: entry.response}, + { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error: undefined, + }, + }, + ) + }, + ), + ) + + const migrateTmpReleases = process.env.NODE_ENV === 'development' ? migrateWith(client) : EMPTY + const state$ = concat(migrateTmpReleases, merge(listFetch$, dispatch$)).pipe( + filter((action): action is ReleasesReducerAction => typeof action !== 'undefined'), + scan((state, action) => releasesReducer(state, action), INITIAL_STATE), + startWith(INITIAL_STATE), + shareReplay(1), + ) + + const getMetadataStateForSlugs$ = createReleaseMetadataAggregator(client) + + return { + state$, + getMetadataStateForSlugs$, + dispatch, + } +} diff --git a/packages/sanity/src/core/releases/store/index.ts b/packages/sanity/src/core/releases/store/index.ts new file mode 100644 index 00000000000..f8dc7aaebca --- /dev/null +++ b/packages/sanity/src/core/releases/store/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './useReleaseOperations' +export * from './useReleases' diff --git a/packages/sanity/src/core/releases/store/reducer.ts b/packages/sanity/src/core/releases/store/reducer.ts new file mode 100644 index 00000000000..2590dc7bf39 --- /dev/null +++ b/packages/sanity/src/core/releases/store/reducer.ts @@ -0,0 +1,102 @@ +import {type ReleaseDocument} from './types' + +interface BundleDeletedAction { + id: string + currentUserId?: string + deletedByUserId: string + type: 'BUNDLE_DELETED' +} + +interface BundleUpdatedAction { + payload: ReleaseDocument + type: 'BUNDLE_UPDATED' +} + +interface ReleasesSetAction { + payload: ReleaseDocument[] | null + type: 'RELEASES_SET' +} + +interface BundleReceivedAction { + payload: ReleaseDocument + type: 'BUNDLE_RECEIVED' +} + +interface LoadingStateChangedAction { + payload: { + loading: boolean + error: Error | undefined + } + type: 'LOADING_STATE_CHANGED' +} + +export type ReleasesReducerAction = + | BundleDeletedAction + | BundleUpdatedAction + | ReleasesSetAction + | BundleReceivedAction + | LoadingStateChangedAction + +export interface ReleasesReducerState { + releases: Map + state: 'initialising' | 'loading' | 'loaded' | 'error' + error?: Error +} + +function createReleasesSet(releases: ReleaseDocument[] | null) { + return (releases ?? []).reduce((acc, bundle) => { + acc.set(bundle._id, bundle) + return acc + }, new Map()) +} + +export function releasesReducer( + state: ReleasesReducerState, + action: ReleasesReducerAction, +): ReleasesReducerState { + switch (action.type) { + case 'LOADING_STATE_CHANGED': { + return { + ...state, + state: action.payload.loading ? 'loading' : 'loaded', + error: action.payload.error, + } + } + + case 'RELEASES_SET': { + // Create an object with the BUNDLE id as key + const releasesById = createReleasesSet(action.payload) + + return { + ...state, + releases: releasesById, + } + } + + case 'BUNDLE_RECEIVED': { + const receivedBundle = action.payload as ReleaseDocument + const currentReleases = new Map(state.releases) + currentReleases.set(receivedBundle._id, receivedBundle) + + return { + ...state, + releases: currentReleases, + } + } + + case 'BUNDLE_UPDATED': { + const updatedBundle = action.payload + const id = updatedBundle._id as string + const currentReleases = new Map(state.releases) + currentReleases.set(id, updatedBundle) + + return { + ...state, + releases: currentReleases, + } + } + + default: + return state + } +} diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts new file mode 100644 index 00000000000..36dcdc21e1c --- /dev/null +++ b/packages/sanity/src/core/releases/store/types.ts @@ -0,0 +1,83 @@ +import {type SanityDocument} from '@sanity/types' +import {type Dispatch} from 'react' +import {type Observable} from 'rxjs' + +import {type PartialExcept} from '../../util' +import {RELEASE_DOCUMENT_TYPE} from './constants' +import {type MetadataWrapper} from './createReleaseMetadataAggregator' +import {type ReleasesReducerAction, type ReleasesReducerState} from './reducer' + +/** @internal */ +export type ReleaseType = 'asap' | 'scheduled' | 'undecided' + +/** + *@internal + */ +export type ReleaseState = 'active' | 'archived' | 'published' | 'scheduled' | 'scheduling' +/** + *@internal + */ +export type ReleaseFinalDocumentState = { + /** Document ID */ + id: string + revisionId: string +} + +/** + * TODO: When made `beta`, update the PublishDocumentVersionEvent to use this type + * @internal + */ +export interface ReleaseDocument extends SanityDocument { + /** + * typically + * _.releases. + */ + _id: string + _type: typeof RELEASE_DOCUMENT_TYPE + _createdAt: string + _updatedAt: string + _rev: string + state: ReleaseState + finalDocumentStates?: ReleaseFinalDocumentState[] + /** + * If defined, it takes precedence over the intendedPublishAt, the state should be 'scheduled' + */ + publishAt?: string + metadata: { + title: string + description?: string + + intendedPublishAt?: string + // todo: the below properties should probably live at the system document + releaseType: ReleaseType + } +} + +/** + * @internal + */ +export type EditableReleaseDocument = Omit< + PartialExcept, + 'metadata' | '_type' +> & { + _id: string + metadata: Partial +} + +/** + * @internal + */ +export function isReleaseDocument(doc: unknown): doc is ReleaseDocument { + return ( + typeof doc === 'object' && doc !== null && '_type' in doc && doc._type === RELEASE_DOCUMENT_TYPE + ) +} + +/** + * @internal + */ +export interface ReleaseStore { + state$: Observable + getMetadataStateForSlugs$: (slugs: string[]) => Observable + dispatch: Dispatch +} diff --git a/packages/sanity/src/core/releases/store/useReleaseOperations.ts b/packages/sanity/src/core/releases/store/useReleaseOperations.ts new file mode 100644 index 00000000000..07e92ac66aa --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleaseOperations.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react' + +import {useClient} from '../../hooks' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createReleaseOperationsStore} from './createReleaseOperationStore' + +/** + * @internal + */ +export function useReleaseOperations() { + const studioClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + return useMemo( + () => + createReleaseOperationsStore({ + client: studioClient, + }), + [studioClient], + ) +} diff --git a/packages/sanity/src/core/releases/store/useReleases.ts b/packages/sanity/src/core/releases/store/useReleases.ts new file mode 100644 index 00000000000..ab0716f951f --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleases.ts @@ -0,0 +1,64 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {sortReleases} from '../hooks/utils' +import {getReleaseIdFromReleaseDocumentId} from '../util/getReleaseIdFromReleaseDocumentId' +import {type ReleasesReducerAction} from './reducer' +import {type ReleaseDocument} from './types' +import {useReleasesStore} from './useReleasesStore' + +interface ReleasesState { + /** + * Sorted array of releases, excluding archived releases + */ + data: ReleaseDocument[] + /** + * Sorted array of release IDs, excluding archived releases + */ + releasesIds: string[] + /** + * Array of archived releases + */ + archivedReleases: ReleaseDocument[] + error?: Error + loading: boolean + dispatch: (event: ReleasesReducerAction) => void +} + +const ARCHIVED_RELEASE_STATES = ['archived', 'published'] + +/** + * @internal + */ +export function useReleases(): ReleasesState { + const {state$, dispatch} = useReleasesStore() + const state = useObservable(state$)! + const releasesAsArray = useMemo( + () => + sortReleases( + Array.from(state.releases.values()).filter( + (release) => !ARCHIVED_RELEASE_STATES.includes(release.state), + ), + ).reverse(), + [state.releases], + ) + const archivedReleases = useMemo( + () => + Array.from(state.releases.values()).filter((release) => + ARCHIVED_RELEASE_STATES.includes(release.state), + ), + [state.releases], + ) + const releasesIds = useMemo( + () => releasesAsArray.map((release) => getReleaseIdFromReleaseDocumentId(release._id)), + [releasesAsArray], + ) + return { + data: releasesAsArray, + releasesIds: releasesIds, + archivedReleases, + dispatch, + error: state.error, + loading: ['loading', 'initialising'].includes(state.state), + } +} diff --git a/packages/sanity/src/core/releases/store/useReleasesMetadata.ts b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts new file mode 100644 index 00000000000..10b08eb86f9 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts @@ -0,0 +1,54 @@ +import {useEffect, useState} from 'react' + +import {useReleasesMetadataProvider} from '../contexts/ReleasesMetadataProvider' + +export interface ReleasesMetadata { + /** + * The number of documents with the release version as a prefix + */ + documentCount: number + /** + * The last time a document in the release was edited + */ + updatedAt: string | null +} + +export const useReleasesMetadata = (releaseIds: string[]) => { + const { + addReleaseIdsToListener: addBundleIdsToListener, + removeReleaseIdsFromListener: removeBundleIdsFromListener, + state, + } = useReleasesMetadataProvider() + const [responseData, setResponseData] = useState | null>(null) + + useEffect(() => { + if (releaseIds.length) addBundleIdsToListener([...new Set(releaseIds)]) + + return () => removeBundleIdsFromListener([...new Set(releaseIds)]) + }, [addBundleIdsToListener, releaseIds, removeBundleIdsFromListener]) + + const {data, loading} = state + + useEffect(() => { + if (!data) return + + const hasUpdatedMetadata = + !responseData || Object.entries(responseData).some(([key, value]) => value !== data[key]) + + if (hasUpdatedMetadata) { + const nextResponseData = Object.fromEntries( + releaseIds.map((releaseId) => [releaseId, data[releaseId]]), + ) + + setResponseData(nextResponseData) + } + }, [releaseIds, data, responseData]) + + return { + error: state.error, + // loading is only for initial load + // changing listened to release IDs will not cause a re-load + loading, + data: responseData, + } +} diff --git a/packages/sanity/src/core/releases/store/useReleasesStore.ts b/packages/sanity/src/core/releases/store/useReleasesStore.ts new file mode 100644 index 00000000000..04a37cedcb0 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesStore.ts @@ -0,0 +1,36 @@ +import {useMemo} from 'react' + +import {useClient} from '../../hooks' +import {useDocumentPreviewStore, useResourceCache} from '../../store' +import {useWorkspace} from '../../studio' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createReleaseStore} from './createReleaseStore' +import {type ReleaseStore} from './types' + +/** @internal */ +export function useReleasesStore(): ReleaseStore { + const resourceCache = useResourceCache() + const workspace = useWorkspace() + const previewStore = useDocumentPreviewStore() + const studioClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + + return useMemo(() => { + const releaseStore = + resourceCache.get({ + dependencies: [workspace, previewStore], + namespace: 'ReleasesStore', + }) || + createReleaseStore({ + client: studioClient, + previewStore, + }) + + resourceCache.set({ + dependencies: [workspace, previewStore], + namespace: 'ReleasesStore', + value: releaseStore, + }) + + return releaseStore + }, [resourceCache, workspace, studioClient, previewStore]) +} diff --git a/packages/sanity/src/core/releases/tool/ReleasesTool.tsx b/packages/sanity/src/core/releases/tool/ReleasesTool.tsx new file mode 100644 index 00000000000..0017e91bf5c --- /dev/null +++ b/packages/sanity/src/core/releases/tool/ReleasesTool.tsx @@ -0,0 +1,13 @@ +import {useRouter} from 'sanity/router' + +import {ReleaseDetail} from './detail/ReleaseDetail' +import {ReleasesOverview} from './overview/ReleasesOverview' + +export function ReleasesTool() { + const router = useRouter() + + const {releaseId} = router.state + if (releaseId) return + + return +} diff --git a/packages/sanity/src/core/releases/tool/components/Chip.tsx b/packages/sanity/src/core/releases/tool/components/Chip.tsx new file mode 100644 index 00000000000..a8f718e5aae --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Chip.tsx @@ -0,0 +1,29 @@ +import {Box, Card, Flex, Text} from '@sanity/ui' +import {type ReactNode} from 'react' + +export function Chip(props: {avatar?: ReactNode; text: ReactNode; icon?: ReactNode}) { + const {avatar, text, icon} = props + + return ( + + + {icon && ( + + {icon} + + )} + {avatar && ( + +
{avatar}
+
+ )} + + + + {text} + + +
+
+ ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx new file mode 100644 index 00000000000..f87a4157deb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx @@ -0,0 +1,94 @@ +import {type PreviewValue} from '@sanity/types' +import {Card} from '@sanity/ui' +import {type ForwardedRef, forwardRef, useMemo} from 'react' +import {IntentLink} from 'sanity/router' + +import {type PreviewLayoutKey} from '../../../components/previews/types' +import {DocumentPreviewPresence} from '../../../presence' +import {SanityDefaultPreview} from '../../../preview/components/SanityDefaultPreview' +import {getPublishedId} from '../../../util/draftUtils' +import {type ReleaseState, useDocumentPresence} from '../../index' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' + +interface ReleaseDocumentPreviewProps { + documentId: string + documentTypeName: string + releaseId: string + previewValues: PreviewValue + isLoading: boolean + releaseState?: ReleaseState + documentRevision?: string + hasValidationError?: boolean + layout?: PreviewLayoutKey +} + +export function ReleaseDocumentPreview({ + documentId, + documentTypeName, + releaseId, + previewValues, + isLoading, + releaseState, + documentRevision, + layout, + hasValidationError, +}: ReleaseDocumentPreviewProps) { + const documentPresence = useDocumentPresence(documentId) + + const intentParams = useMemo(() => { + if (releaseState !== 'published' && releaseState !== 'archived') return {} + + const rev = releaseState === 'archived' ? '@lastEdited' : '@lastPublished' + + return { + rev, + inspect: 'sanity/structure/history', + historyEvent: documentRevision, + historyVersion: getReleaseIdFromReleaseDocumentId(releaseId), + } + }, [documentRevision, releaseId, releaseState]) + + const LinkComponent = useMemo( + () => + // eslint-disable-next-line @typescript-eslint/no-shadow + forwardRef(function LinkComponent(linkProps, ref: ForwardedRef) { + return ( + + ) + }), + [documentId, documentTypeName, intentParams, releaseId, releaseState], + ) + + const previewPresence = useMemo( + () => documentPresence?.length > 0 && , + [documentPresence], + ) + + return ( + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx new file mode 100644 index 00000000000..dcd6e35b6b3 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx @@ -0,0 +1,106 @@ +import {ArchiveIcon, CloseCircleIcon, TrashIcon, UnarchiveIcon} from '@sanity/icons' +import { + type Dispatch, + type MouseEventHandler, + type SetStateAction, + useCallback, + useMemo, +} from 'react' + +import {MenuItem} from '../../../../../ui-components' +import {useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseAction} from './releaseActions' +import {type ReleaseMenuButtonProps} from './ReleaseMenuButton' + +export type ReleaseMenuProps = Omit & { + disabled: boolean + setSelectedAction: Dispatch> +} + +export const ReleaseMenu = ({ + ignoreCTA, + disabled, + release, + setSelectedAction, +}: ReleaseMenuProps) => { + const releaseMenuDisabled = !release || disabled + const {t} = useTranslation(releasesLocaleNamespace) + + const handleOnInitiateAction = useCallback>( + (event) => { + const action = event.currentTarget.getAttribute('data-value') as ReleaseAction + + setSelectedAction(action) + }, + [setSelectedAction], + ) + + const archiveUnarchiveMenuItem = useMemo(() => { + if (release.state === 'published') return null + + if (release.state === 'archived') + return ( + + ) + + return ( + + ) + }, [handleOnInitiateAction, disabled, release.state, t]) + + const deleteMenuItem = useMemo(() => { + if (release.state !== 'archived' && release.state !== 'published') return null + + return ( + + ) + }, [handleOnInitiateAction, release.state, releaseMenuDisabled, t]) + + const unscheduleMenuItem = useMemo(() => { + if (ignoreCTA || (release.state !== 'scheduled' && release.state !== 'scheduling')) return null + + return ( + + ) + }, [handleOnInitiateAction, ignoreCTA, release.state, releaseMenuDisabled, t]) + + return ( + <> + {unscheduleMenuItem} + {archiveUnarchiveMenuItem} + {deleteMenuItem} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx new file mode 100644 index 00000000000..2b046447a44 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx @@ -0,0 +1,194 @@ +import {EllipsisHorizontalIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Menu, Spinner, Text, useToast} from '@sanity/ui' +import {useCallback, useEffect, useMemo, useState} from 'react' +import {useRouter} from 'sanity/router' + +import {Button, Dialog, MenuButton} from '../../../../../ui-components' +import {Translate, useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseDocument} from '../../../store/types' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {RELEASE_ACTION_MAP, type ReleaseAction} from './releaseActions' +import {ReleaseMenu} from './ReleaseMenu' + +export type ReleaseMenuButtonProps = { + /** defaults to false + * set true if release primary CTA options should not + * be shown in the menu eg. unschedule, publish + */ + ignoreCTA?: boolean + release: ReleaseDocument + documentsCount: number +} + +export const ReleaseMenuButton = ({ignoreCTA, release, documentsCount}: ReleaseMenuButtonProps) => { + const toast = useToast() + const router = useRouter() + const {archive, unarchive, deleteRelease, unschedule} = useReleaseOperations() + + const [isPerformingOperation, setIsPerformingOperation] = useState(false) + const [selectedAction, setSelectedAction] = useState() + + const releaseMenuDisabled = !release + const {t} = useTranslation(releasesLocaleNamespace) + const {t: tCore} = useTranslation() + const telemetry = useTelemetry() + const releaseTitle = release.metadata.title || tCore('release.placeholder-untitled-release') + + const handleDelete = useCallback(async () => { + await deleteRelease(release._id) + + // return to release overview list now that release is deleted + router.navigate({}) + }, [deleteRelease, release._id, router]) + + const handleAction = useCallback( + async (action: ReleaseAction) => { + if (releaseMenuDisabled) return + + const actionLookup = { + delete: handleDelete, + archive, + unarchive, + unschedule, + } + const actionValues = RELEASE_ACTION_MAP[action] + + try { + setIsPerformingOperation(true) + await actionLookup[action](release._id) + telemetry.log(actionValues.telemetry) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (actionError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(actionError) + } finally { + setIsPerformingOperation(false) + setSelectedAction(undefined) + } + }, + [ + releaseMenuDisabled, + handleDelete, + archive, + unarchive, + unschedule, + release._id, + telemetry, + toast, + t, + releaseTitle, + ], + ) + + /** in some instanced, immediately execute the action without requiring confirmation */ + useEffect(() => { + if (!selectedAction) return + + if (!RELEASE_ACTION_MAP[selectedAction].confirmDialog || !documentsCount) + handleAction(selectedAction) + }, [documentsCount, handleAction, selectedAction]) + + const confirmActionDialog = useMemo(() => { + if (!selectedAction || !documentsCount) return null + + const {confirmDialog} = RELEASE_ACTION_MAP[selectedAction] + + if (!confirmDialog) return null + + const dialogDescription = + documentsCount === 1 + ? confirmDialog.dialogDescriptionSingularI18nKey + : confirmDialog.dialogDescriptionMultipleI18nKey + + return ( + setSelectedAction(undefined)} + footer={{ + confirmButton: { + text: t(confirmDialog.dialogConfirmButtonI18nKey), + tone: 'positive', + onClick: () => handleAction(selectedAction), + loading: isPerformingOperation, + disabled: isPerformingOperation, + }, + }} + > + + { + + } + + + ) + }, [selectedAction, documentsCount, t, releaseTitle, isPerformingOperation, handleAction]) + + return ( + <> + + } + id="release-menu" + menu={ + + + + } + popover={{ + constrainSize: true, + fallbackPlacements: ['top-end'], + placement: 'bottom', + portal: true, + tone: 'default', + }} + /> + {confirmActionDialog} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx new file mode 100644 index 00000000000..dc51c985b50 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx @@ -0,0 +1,329 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {act} from 'react' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import { + activeScheduledRelease, + archivedScheduledRelease, + publishedASAPRelease, + scheduledRelease, +} from '../../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {type ReleaseDocument, type ReleaseState} from '../../../../index' +import { + mockUseReleaseOperations, + useReleaseOperationsMockReturn, +} from '../../../../store/__tests__/__mocks/useReleaseOperations.mock' +import {useReleaseOperations} from '../../../../store/useReleaseOperations' +import { + mockUseBundleDocuments, + useBundleDocumentsMockReturn, + useBundleDocumentsMockReturnWithResults, +} from '../../../detail/__tests__/__mocks__/useBundleDocuments.mock' +import {ReleaseMenuButton, type ReleaseMenuButtonProps} from '../ReleaseMenuButton' + +vi.mock('../../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../../../detail/useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturnWithResults), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: vi.fn().mockReturnValue({state: {}, navigate: vi.fn()}), +})) + +const renderTest = async ({release, documentsCount, ignoreCTA = false}: ReleaseMenuButtonProps) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return render( + , + {wrapper}, + ) +} + +describe('ReleaseMenuButton', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseBundleDocuments.mockRestore() + }) + + describe('archive release', () => { + const openConfirmArchiveDialog = async () => { + await renderTest({release: activeScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + screen.getByTestId('confirm-archive-dialog') + } + + test('does not allow for archiving of archived releases', async () => { + await renderTest({release: archivedScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('archive-release-menu-item')).not.toBeInTheDocument() + }) + + test('does not allow for published of archived releases', async () => { + await renderTest({release: publishedASAPRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('archive-release-menu-item-menu-item')).not.toBeInTheDocument() + }) + + test('does not require confirmation when no documents in release', async () => { + mockUseBundleDocuments.mockReturnValue(useBundleDocumentsMockReturn) + + await renderTest({release: activeScheduledRelease, documentsCount: 0}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + }) + + test('can reject archiving', async () => { + await openConfirmArchiveDialog() + + await act(() => { + fireEvent.click(screen.getByTestId('cancel-button')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + + describe('when archiving is successful', () => { + beforeEach(async () => { + await openConfirmArchiveDialog() + }) + + test('will archive an active release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + + describe('when archiving fails', () => { + beforeEach(async () => { + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + archive: vi.fn().mockRejectedValue(new Error('some rejection reason')), + }) + + await openConfirmArchiveDialog() + }) + + test('will not archive the release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + }) + + describe('delete release', () => { + const openConfirmDeleteDialog = async () => { + await renderTest({release: archivedScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('delete-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('delete-release-menu-item')) + }) + + screen.getByTestId('confirm-delete-dialog') + } + + test('does not allow for deleting an active release', async () => { + await renderTest({release: activeScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('delete-release-menu-item')).not.toBeInTheDocument() + }) + + test('does not require confirmation when no documents in release', async () => { + mockUseBundleDocuments.mockReturnValue(useBundleDocumentsMockReturn) + + // verifying that delete supported for published releases too + await renderTest({release: publishedASAPRelease, documentsCount: 0}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('delete-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('delete-release-menu-item')) + }) + + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + expect(useReleaseOperations().deleteRelease).toHaveBeenCalledWith(publishedASAPRelease._id) + }) + + test('can reject deleting', async () => { + await openConfirmDeleteDialog() + + await act(() => { + fireEvent.click(screen.getByTestId('cancel-button')) + }) + + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + + describe('when deleting is successful', () => { + beforeEach(async () => { + await openConfirmDeleteDialog() + }) + + test('will delete an active release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().deleteRelease).toHaveBeenCalledWith( + archivedScheduledRelease._id, + ) + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + }) + + describe('when deleting fails', () => { + beforeEach(async () => { + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + deleteRelease: vi.fn().mockRejectedValue(new Error('some rejection reason')), + }) + + await openConfirmDeleteDialog() + }) + + test('will not delete the release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().deleteRelease).toHaveBeenCalledWith( + archivedScheduledRelease._id, + ) + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + }) + }) + + describe('unschedule release', () => { + test.each([ + {state: 'archived', fixture: archivedScheduledRelease}, + {state: 'active', fixture: activeScheduledRelease}, + {state: 'published', fixture: publishedASAPRelease}, + ])('will not allow for unscheduling of $state releases', async ({fixture}) => { + await renderTest({release: fixture, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument() + }) + + test.each([ + {state: 'scheduled', fixture: scheduledRelease}, + {state: 'scheduling', fixture: {...scheduledRelease, state: 'scheduling' as ReleaseState}}, + ])('will unschedule a $state release', async ({fixture}) => { + await renderTest({release: fixture, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + fireEvent.click(screen.getByTestId('unschedule-release-menu-item')) + + // does not require confirmation + expect(useReleaseOperations().unschedule).toHaveBeenCalledWith(fixture._id) + }) + }) + + test.todo('will unarchive an archived release', async () => { + /** @todo update once unarchive has been implemented */ + const archivedRelease: ReleaseDocument = {...activeScheduledRelease, state: 'archived'} + + await renderTest({release: archivedRelease, documentsCount: 1}) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith({ + ...archivedRelease, + archivedAt: undefined, + }) + }) + + test('will hide CTAs when ignoreCTA is true', async () => { + await renderTest({release: scheduledRelease, ignoreCTA: true, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts new file mode 100644 index 00000000000..666bca9231f --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts @@ -0,0 +1,68 @@ +import {type DefinedTelemetryLog} from '@sanity/telemetry/react' + +import { + ArchivedRelease, + DeletedRelease, + UnarchivedRelease, + UnscheduledRelease, +} from '../../../__telemetry__/releases.telemetry' + +export type ReleaseAction = 'archive' | 'unarchive' | 'delete' | 'unschedule' + +interface BaseReleaseActionsMap { + toastSuccessI18nKey: string + toastFailureI18nKey: string + telemetry: DefinedTelemetryLog +} + +interface DialogActionsMap extends BaseReleaseActionsMap { + confirmDialog: { + dialogId: string + dialogHeaderI18nKey: string + dialogDescriptionSingularI18nKey: string + dialogDescriptionMultipleI18nKey: string + dialogConfirmButtonI18nKey: string + } +} + +export const RELEASE_ACTION_MAP: Record< + ReleaseAction, + DialogActionsMap | (BaseReleaseActionsMap & {confirmDialog: false}) +> = { + delete: { + confirmDialog: { + dialogId: 'confirm-delete-dialog', + dialogHeaderI18nKey: 'delete-dialog.confirm-delete.header', + dialogDescriptionSingularI18nKey: 'delete-dialog.confirm-delete-description_one', + dialogDescriptionMultipleI18nKey: 'delete-dialog.confirm-delete-description_other', + dialogConfirmButtonI18nKey: 'delete-dialog.confirm-delete-button', + }, + toastSuccessI18nKey: 'toast.delete.success', + toastFailureI18nKey: 'toast.delete.error', + telemetry: DeletedRelease, + }, + archive: { + confirmDialog: { + dialogId: 'confirm-archive-dialog', + dialogHeaderI18nKey: 'archive-dialog.confirm-archive-header', + dialogDescriptionSingularI18nKey: 'archive-dialog.confirm-archive-description_one', + dialogDescriptionMultipleI18nKey: 'archive-dialog.confirm-archive-description_other', + dialogConfirmButtonI18nKey: 'archive-dialog.confirm-archive-button', + }, + toastSuccessI18nKey: 'toast.archive.success', + toastFailureI18nKey: 'toast.archive.error', + telemetry: ArchivedRelease, + }, + unarchive: { + confirmDialog: false, + toastSuccessI18nKey: 'toast.unarchive.success', + toastFailureI18nKey: 'toast.unarchive.error', + telemetry: UnarchivedRelease, + }, + unschedule: { + confirmDialog: false, + toastSuccessI18nKey: 'toast.unschedule.success', + toastFailureI18nKey: 'toast.unschedule.error', + telemetry: UnscheduledRelease, + }, +} diff --git a/packages/sanity/src/core/releases/tool/components/StatusItem.tsx b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx new file mode 100644 index 00000000000..ebacb13ca36 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx @@ -0,0 +1,23 @@ +import {Box, Card, Flex, Text} from '@sanity/ui' +import {type ReactNode} from 'react' + +export function StatusItem(props: {avatar?: ReactNode; text: ReactNode; testId?: string}) { + const {avatar, text, testId} = props + + return ( + + + {avatar && ( + +
{avatar}
+
+ )} + + + {text} + + +
+
+ ) +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx new file mode 100644 index 00000000000..c02563c0dbd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx @@ -0,0 +1,306 @@ +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 + +import {Box, Card, type CardProps, Flex, rem, Stack, Text, useTheme} from '@sanity/ui' +import { + defaultRangeExtractor, + type Range, + useVirtualizer, + type VirtualItem, +} from '@tanstack/react-virtual' +import {isValid} from 'date-fns' +import {get} from 'lodash' +import { + type CSSProperties, + Fragment, + type HTMLProps, + type MutableRefObject, + type RefAttributes, + type RefObject, + useMemo, + useRef, +} from 'react' + +import {TooltipDelayGroupProvider} from '../../../../../ui-components' +import {LoadingBlock} from '../../../../components' +import {TableHeader} from './TableHeader' +import {TableProvider, type TableSort, useTableContext} from './TableProvider' +import {type Column} from './types' + +type RowDatum = AdditionalRowTableData extends undefined + ? TableData + : TableData & AdditionalRowTableData + +export type TableRowProps = Omit< + CardProps & Omit, 'height' | 'as'>, + 'ref' +> & + RefAttributes + +export interface TableProps { + columnDefs: Column>[] + searchFilter?: (data: TableData[], searchTerm: string) => TableData[] + data: TableData[] + emptyState: (() => React.JSX.Element) | string + loading?: boolean + /** + * Should be the dot separated path to the unique identifier of the row. e.g. document._id + */ + rowId: string + rowActions?: ({ + datum, + }: { + datum: RowDatum | unknown + }) => React.ReactNode + rowProps?: (datum: TableData) => Partial + scrollContainerRef: RefObject + hideTableInlinePadding?: boolean +} + +const ITEM_HEIGHT = 59 + +/** + * This function modifies the rangeExtractor to account for the offset of the virtualizer + * in this case, the parent with overflow (the element over which the scroll happens) and the start of the virtualizer + * don't match, because there are some elements rendered on top of the virtualizer. + * This, will take care of adding more elements to the start of the virtualizer to account for the offset. + */ +const withVirtualizerOffset = ({ + scrollContainerRef, + virtualizerContainerRef, + range, +}: { + scrollContainerRef: MutableRefObject + virtualizerContainerRef: MutableRefObject + range: Range +}) => { + const parentOffset = scrollContainerRef.current?.offsetTop ?? 0 + const virtualizerOffset = virtualizerContainerRef.current?.offsetTop ?? 0 + const virtualizerScrollMargin = virtualizerOffset - parentOffset + const topItemsOffset = Math.ceil(virtualizerScrollMargin / ITEM_HEIGHT) + const startIndexWithOffset = range.startIndex - topItemsOffset + const result = defaultRangeExtractor({ + ...range, + // By modifying the startIndex, we are adding more elements to the start of the virtualizer + startIndex: startIndexWithOffset > 0 ? startIndexWithOffset : 0, + }) + return result +} +const TableInner = ({ + columnDefs, + data, + emptyState, + searchFilter, + rowId, + rowActions, + loading = false, + rowProps = () => ({}), + scrollContainerRef, + hideTableInlinePadding = false, +}: TableProps) => { + const {searchTerm, sort} = useTableContext() + const virtualizerContainerRef = useRef(null) + const filteredData = useMemo(() => { + const filteredResult = searchTerm && searchFilter ? searchFilter(data, searchTerm) : data + if (!sort) return filteredResult + + const sortColumn = columnDefs.find((column) => column.id === sort.column) + return [...filteredResult].sort((a, b) => { + let order: number + + const [aValue, bValue]: (number | string)[] = [a, b].map( + (sortValue) => + sortColumn?.sortTransform?.(sortValue as RowDatum) ?? + get(sortValue, sort.column), + ) + if ( + typeof aValue === 'string' && + typeof bValue === 'string' && + !isValid(aValue) && + !isValid(bValue) + ) { + order = aValue.toLowerCase().localeCompare(bValue.toLowerCase()) + } else { + const parseDate = (datum: number | string) => { + if (sortColumn?.sortTransform && typeof datum === 'number') return datum + + return typeof datum === 'string' ? Date.parse(datum) : 0 + } + + const [aDate, bDate] = [aValue, bValue].map(parseDate) + + order = aDate - bDate + } + + if (sort.direction === 'asc') return order + return -order + }) + }, [columnDefs, data, searchFilter, searchTerm, sort]) + + const rowVirtualizer = useVirtualizer({ + count: filteredData.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + rangeExtractor: (range) => + withVirtualizerOffset({scrollContainerRef, virtualizerContainerRef, range}), + }) + + const rowActionColumnDef: Column = useMemo( + () => ({ + id: 'actions', + sorting: false, + width: 50, + header: ({headerProps: {id}}) => ( + + +   + + + ), + cell: ({datum, cellProps: {id}}) => ( + + {rowActions?.({datum}) || } + + ), + }), + [rowActions], + ) + + const amalgamatedColumnDefs = useMemo( + () => (rowActions ? [...columnDefs, rowActionColumnDef] : columnDefs), + [columnDefs, rowActionColumnDef, rowActions], + ) + + const renderRow = useMemo( + () => + function TableRow( + datum: (TableData | (TableData & AdditionalRowTableData)) & { + virtualRow: VirtualItem + index: number + isFirst: boolean + isLast: boolean + }, + ) { + const cardRowProps = rowProps(datum as TableData) + + return ( + + {amalgamatedColumnDefs.map(({cell: Cell, style, width, id, sorting = false}) => ( + + } + cellProps={{ + as: 'td', + id: String(id), + style: {...style, width: width || undefined}, + }} + sorting={sorting} + /> + + ))} + + ) + }, + [amalgamatedColumnDefs, rowId, rowProps], + ) + + const emptyContent = useMemo(() => { + if (typeof emptyState === 'string') { + return ( + + + {emptyState} + + + ) + } + return emptyState() + }, [emptyState]) + + const headers = useMemo( + () => + amalgamatedColumnDefs.map(({cell, sortTransform, ...header}) => ({ + ...header, + id: String(header.id), + })), + [amalgamatedColumnDefs], + ) + + const theme = useTheme() + + if (loading) { + return + } + + const maxInlineSize = (!hideTableInlinePadding && theme.sanity.v2?.container[3]) || 0 + + return ( +
+
+ + + + {filteredData.length === 0 + ? emptyContent + : rowVirtualizer.getVirtualItems().map((virtualRow, index) => { + const datum = filteredData[virtualRow.index] + return renderRow({ + ...datum, + virtualRow, + index, + isFirst: virtualRow.index === 0, + isLast: virtualRow.index === filteredData.length - 1, + }) + })} + + +
+
+ ) +} + +export const Table = ({ + defaultSort, + ...props +}: TableProps & {defaultSort?: TableSort}) => { + return ( + + + {...props} /> + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx new file mode 100644 index 00000000000..56ce3a754f0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx @@ -0,0 +1,112 @@ +import {ArrowUpIcon, SearchIcon} from '@sanity/icons' +import {Box, Card, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {motion} from 'framer-motion' +import {useMemo} from 'react' + +import {Button, type ButtonProps} from '../../../../../ui-components' +import {useTableContext} from './TableProvider' +import {type HeaderProps, type TableHeaderProps} from './types' + +const MotionIcon = motion.create(ArrowUpIcon) + +const BasicHeader = ({text}: {text: string}) => ( + + + {text} + + +) + +const SortHeaderButton = ({ + header, + text, +}: Omit & + HeaderProps & { + text: string + }) => { + const {sort, setSortColumn} = useTableContext() + const sortIcon = useMemo( + () => ( + + ), + [sort?.direction], + ) + + return ( + + )} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx new file mode 100644 index 00000000000..927cc4be801 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx @@ -0,0 +1,82 @@ +import {render, screen, waitFor} from '@testing-library/react' +import {describe, expect, test} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import { + activeASAPRelease, + activeScheduledRelease, + archivedScheduledRelease, + publishedASAPRelease, + scheduledRelease, +} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {ReleaseDashboardFooter} from '../ReleaseDashboardFooter' + +const renderTest = async (props?: Partial>) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const rendered = render( + , + { + wrapper, + }, + ) + + await waitFor( + () => { + expect(screen.queryByTestId('loading-block')).not.toBeInTheDocument() + }, + {timeout: 5000, interval: 1000}, + ) + + return rendered +} + +describe('ReleaseDashboardFooter', () => { + describe('for an active asap release', () => { + test('shows publish all button', async () => { + await renderTest() + + expect(screen.getByTestId('publish-all-button')).toBeInTheDocument() + }) + }) + + describe('for an active scheduled release', () => { + test('shows unschedule button', async () => { + await renderTest({release: activeScheduledRelease}) + + expect(screen.getByText('Schedule for publishing...')).toBeInTheDocument() + }) + }) + + describe('for a published release', () => { + test('shows revert button', async () => { + await renderTest({release: publishedASAPRelease}) + + expect(screen.getByText('Revert release')).toBeInTheDocument() + }) + }) + + describe('for a scheduled release', () => { + test('shows unschedule button', async () => { + await renderTest({release: scheduledRelease}) + + expect(screen.getByText('Unschedule for publishing')).toBeInTheDocument() + }) + }) + + describe('for an archived release', () => { + test('shows the unarchive button', async () => { + await renderTest({release: archivedScheduledRelease}) + + expect(screen.getByTestId('release-dashboard-footer-actions').children.length).toEqual(1) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx new file mode 100644 index 00000000000..1f6ac051bf6 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx @@ -0,0 +1,335 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react' +import {route, RouterProvider} from 'sanity/router' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {mockUseRouterReturn} from '../../../../../../test/mocks/useRouter.mock' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {activeASAPRelease, publishedASAPRelease} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {useReleaseOperationsMockReturn} from '../../../store/__tests__/__mocks/useReleaseOperations.mock' +import { + mockUseReleases, + useReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useReleases.mock' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {ReleaseDetail} from '../ReleaseDetail' +import { + documentsInRelease, + mockUseBundleDocuments, + useBundleDocumentsMockReturn, +} from './__mocks__/useBundleDocuments.mock' +import {useReleaseEventsMockReturn} from './__mocks__/useReleaseEvents.mock' + +vi.mock('sanity/router', async (importOriginal) => { + return { + ...(await importOriginal()), + useRouter: vi.fn(() => mockUseRouterReturn), + route: { + create: vi.fn(), + }, + IntentLink: vi.fn(), + } +}) + +vi.mock('../../../store/useReleases', () => ({ + useReleases: vi.fn(() => useReleasesMockReturn), +})) + +vi.mock('../../../index', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), + isReleaseScheduledOrScheduling: vi.fn(), +})) + +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturn), +})) + +vi.mock('../events/useReleaseEvents', () => ({ + useReleaseEvents: vi.fn(() => useReleaseEventsMockReturn), +})) + +vi.mock('../../components/ReleasePublishAllButton/useObserveDocumentRevisions', () => ({ + useObserveDocumentRevisions: vi.fn().mockReturnValue({ + '123': 'mock revision id', + }), +})) + +vi.mock('../ReleaseSummary', () => ({ + ReleaseSummary: () =>
, +})) + +vi.mock('../documentTable/useReleaseHistory', () => ({ + useReleaseHistory: vi.fn().mockReturnValue({ + documentsHistory: new Map(), + }), +})) + +const mockRouterNavigate = vi.fn() + +const renderTest = async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return render( + + + , + {wrapper}, + ) +} + +const publishAgnosticTests = (title: string) => { + it('should allow for navigating back to releases overview', () => { + screen.getByTestId('back-to-releases-button').click() + }) + + it('should show the release title', () => { + screen.getAllByText(title) + }) +} + +describe('ReleaseDetail', () => { + describe('when loading releases', () => { + beforeEach(async () => { + vi.clearAllMocks() + mockUseReleases.mockClear() + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + loading: true, + }) + + await renderTest() + }) + + it('should show a loading spinner', () => { + screen.getByTestId('loading-block') + }) + + it('does not show the rest of the screen ui', () => { + expect(screen.queryByText('Publish all')).toBeNull() + expect(screen.queryByText('Summary')).toBeNull() + expect(screen.queryByText('Review changes')).toBeNull() + expect(screen.queryByLabelText('Release menu')).toBeNull() + }) + }) + + describe('when loaded releases but still loading release documents', () => { + beforeEach(async () => { + vi.clearAllMocks() + + mockUseReleases.mockClear() + mockUseBundleDocuments.mockClear() + + mockUseBundleDocuments.mockReturnValue({...useBundleDocumentsMockReturn, loading: true}) + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [activeASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getReleaseIdFromReleaseDocumentId(activeASAPRelease._id), + } + await renderTest() + }) + + it('should show loading spinner', () => { + screen.getByTestId('loading-block') + }) + + it('should show the header', () => { + screen.getByText(activeASAPRelease.metadata.title) + screen.getByTestId('release-menu-button') + expect(screen.getByTestId('publish-all-button').closest('button')).toBeDisabled() + }) + }) +}) + +describe('after releases have loaded', () => { + describe('with unpublished release', () => { + beforeEach(async () => { + vi.clearAllMocks() + }) + + const loadedReleaseAndDocumentsTests = () => { + it('should allow for the release to be archived', () => { + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release-menu-item') + }) + } + + describe('with pending document validation', () => { + beforeEach(async () => { + vi.clearAllMocks() + + mockUseBundleDocuments.mockReturnValue({ + loading: false, + results: [ + { + ...documentsInRelease, + validation: {...documentsInRelease.validation, isValidating: true}, + }, + ], + }) + await renderTest() + }) + + publishAgnosticTests(activeASAPRelease.metadata.title) + loadedReleaseAndDocumentsTests() + + it('should disable publish all button', () => { + act(() => { + expect(screen.getByTestId('publish-all-button').closest('button')).toBeDisabled() + }) + }) + }) + + describe('with passing document validation', () => { + beforeEach(async () => { + mockUseBundleDocuments.mockReturnValue({ + loading: false, + results: [documentsInRelease], + }) + await renderTest() + }) + + publishAgnosticTests(activeASAPRelease.metadata.title) + loadedReleaseAndDocumentsTests() + + it('should show publish all button when release not published', () => { + expect(screen.getByTestId('publish-all-button').closest('button')).not.toBeDisabled() + }) + + it('should require confirmation to publish', () => { + act(() => { + expect(screen.getByTestId('publish-all-button')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('publish-all-button')) + waitFor(() => { + screen.getByText( + 'Are you sure you want to publish the release and all document versions?', + ) + }) + }) + + expect(screen.getByTestId('confirm-button')).not.toBeDisabled() + }) + + it('should perform publish', () => { + act(() => { + expect(screen.getByTestId('publish-all-button')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('publish-all-button')) + }) + + screen.getByText('Are you sure you want to publish the release and all document versions?') + + fireEvent.click(screen.getByTestId('confirm-button')) + + expect(useReleaseOperationsMockReturn.publishRelease).toHaveBeenCalledWith( + activeASAPRelease._id, + false, + ) + }) + }) + + describe('with failing document validation', () => { + beforeEach(async () => { + mockUseBundleDocuments.mockReturnValue({ + loading: false, + results: [ + { + ...documentsInRelease, + validation: { + hasError: true, + isValidating: false, + validation: [ + { + message: 'title validation message', + level: 'error', + path: ['title'], + }, + ], + }, + }, + ], + }) + await renderTest() + }) + + publishAgnosticTests(activeASAPRelease.metadata.title) + loadedReleaseAndDocumentsTests() + + it('should disable publish all button', () => { + expect(screen.getByTestId('publish-all-button')).toBeDisabled() + fireEvent.mouseOver(screen.getByTestId('publish-all-button')) + }) + }) + }) + + describe('with published release', () => { + beforeEach(async () => { + mockUseReleases.mockReset() + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [publishedASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getReleaseIdFromReleaseDocumentId(publishedASAPRelease._id), + } + + await renderTest() + }) + + publishAgnosticTests(publishedASAPRelease.metadata.title) + + it('should not show the publish button', () => { + expect(screen.queryByText('Publish all')).toBeNull() + }) + + it('should not allow for the release to be unarchived', () => { + fireEvent.click(screen.getByTestId('release-menu-button')) + expect(screen.queryByTestId('unarchive-release-menu-item')).not.toBeInTheDocument() + }) + + it('should not allow for the release to be archived', () => { + fireEvent.click(screen.getByTestId('release-menu-button')) + expect(screen.queryByTestId('archive-release-menu-item')).not.toBeInTheDocument() + }) + + it('should not show the review changes button', () => { + expect(screen.queryByText('Review changes')).toBeNull() + }) + }) + + describe('with missing release', () => { + beforeEach(async () => { + mockUseReleases.mockReset() + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [activeASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getReleaseIdFromReleaseDocumentId(activeASAPRelease._id), + } + + await renderTest() + }) + + it('should show missing release message', () => { + screen.getByText(activeASAPRelease.metadata.title) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx new file mode 100644 index 00000000000..46982123cbd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx @@ -0,0 +1,73 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {ReleaseDetailsEditor} from '../ReleaseDetailsEditor' +// Mock the dependencies +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn().mockReturnValue({ + updateRelease: vi.fn(), + }), +})) + +describe('ReleaseDetailsEditor', () => { + beforeEach(async () => { + const initialRelease = { + _id: 'release1', + metadata: { + title: 'Initial Title', + description: '', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + const wrapper = await createTestProvider() + render(, {wrapper}) + }) + + it('should call updateRelease after title change', () => { + const release = { + _id: 'release1', + metadata: { + title: 'New Title', + description: '', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + + const input = screen.getByTestId('release-form-title') + fireEvent.change(input, {target: {value: release.metadata.title}}) + + waitFor( + () => { + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith(release) + }, + {timeout: 250}, + ) + }) + + it('should call updateRelease after description change', () => { + const release = { + _id: 'release1', + metadata: { + title: 'Initial Title', + description: 'woo hoo', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + + const input = screen.getByTestId('release-form-description') + fireEvent.change(input, {target: {value: release.metadata.description}}) + + waitFor( + () => { + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith(release) + }, + {timeout: 250}, + ) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx new file mode 100644 index 00000000000..8e3d9cc701e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx @@ -0,0 +1,333 @@ +import {act, fireEvent, render, screen, within} from '@testing-library/react' +import {type ReactNode} from 'react' +import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {queryByDataUi} from '../../../../../../test/setup/customQueries' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {useObserveDocument} from '../../../../preview/useObserveDocument' +import {ColorSchemeProvider} from '../../../../studio' +import {UserColorManagerProvider} from '../../../../user-color' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {ReleaseReview} from '../ReleaseReview' +import {type DocumentInRelease} from '../useBundleDocuments' + +const BASE_DOCUMENTS_MOCKS = { + doc1: { + name: 'William Faulkner', + role: 'developer', + _id: 'doc1', + _rev: 'FvEfB9CaLlljeKWNkRBaf5', + _type: 'author', + _createdAt: '', + _updatedAt: '', + }, + doc2: { + name: 'Virginia Woolf', + role: 'developer', + _id: 'doc2', + _rev: 'FvEfB9CaLlljeKWNkRBaf5', + _type: 'author', + _createdAt: '', + _updatedAt: '', + }, +} as const + +const MOCKED_DOCUMENTS: DocumentInRelease[] = [ + { + memoKey: 'key123', + document: { + _rev: 'FvEfB9CaLlljeKWNkQgpz9', + _type: 'author', + role: 'designer', + _createdAt: '2024-07-10T12:10:38Z', + name: 'William Faulkner added', + _id: 'versions.differences.doc1', + _updatedAt: '2024-07-15T10:46:02Z', + }, + previewValues: { + isLoading: false, + values: { + _createdAt: '2024-07-10T12:10:38Z', + _updatedAt: '2024-07-15T10:46:02Z', + _version: {}, + title: 'William Faulkner added', + subtitle: 'Designer', + }, + }, + validation: { + isValidating: false, + validation: [], + revision: 'FvEfB9CaLlljeKWNk8Mh0N', + hasError: false, + }, + }, + { + memoKey: 'key123', + document: { + _rev: 'FvEfB9CaLlljeKWNkQg1232', + _type: 'author', + role: 'developer', + _createdAt: '2024-07-10T12:10:38Z', + name: 'Virginia Woolf test', + _id: 'versions.differences.doc2', + _updatedAt: '2024-07-15T10:46:02Z', + }, + previewValues: { + isLoading: false, + values: { + _createdAt: '2024-07-10T12:10:38Z', + _updatedAt: '2024-07-15T10:46:02Z', + _version: {}, + title: 'Virginia Woolf test', + subtitle: 'Developer', + }, + }, + validation: { + isValidating: false, + validation: [], + revision: 'FvEfB9CaLlljeKWNk8Mh0N', + hasError: false, + }, + }, +] +const MOCKED_PROPS = { + scrollContainerRef: {current: null}, + documents: MOCKED_DOCUMENTS, + release: { + _updatedAt: '2024-07-12T10:39:32Z', + authorId: 'p8xDvUMxC', + _type: 'release', + description: 'To test differences in documents', + hue: 'gray', + title: 'Differences', + _createdAt: '2024-07-10T12:09:56Z', + icon: 'cube', + slug: 'differences', + _id: 'd3137faf-ece6-44b5-a2b1-1090967f868e', + _rev: 'j9BPWHem9m3oUugvhMXEGV', + } as const, + documentsHistory: { + 'differences.doc1': { + history: [], + createdBy: 'p8xDvUMxC', + lastEditedBy: 'p8xDvUMxC', + editors: ['p8xDvUMxC'], + }, + + 'differences.doc2': { + history: [], + createdBy: 'p8xDvUMxC', + lastEditedBy: 'p8xDvUMxC', + editors: ['p8xDvUMxC'], + }, + }, +} + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: vi.fn().mockImplementation((props: any) => {props.children}), + useRouter: vi.fn().mockReturnValue({ + state: {releaseId: 'differences'}, + navigate: vi.fn(), + }), +})) + +vi.mock('../../../../preview/useObserveDocument', () => { + return { + useObserveDocument: vi.fn(), + } +}) + +const mockedUseObserveDocument = useObserveDocument as Mock + +async function createReleaseReviewWrapper() { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return ({children}: {children: ReactNode}) => + wrapper({ + children: ( + + {children} + + ), + }) +} + +describe.skip('ReleaseReview', () => { + describe('when loading baseDocument', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: null, + loading: true, + }) + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it("should show the loader when the base document hasn't loaded", () => { + queryByDataUi(document.body, 'Spinner') + }) + }) + describe('when there is no base document', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: null, + loading: false, + }) + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it('should render the new document ui, showing the complete values as added', async () => { + const firstDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[0].document._id}`, + ) + const secondDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[1].document._id}`, + ) + + expect( + within(firstDocumentDiff).getByText( + (content, el) => + el?.tagName.toLowerCase() === 'ins' && content === 'William Faulkner added', + ), + ).toBeInTheDocument() + expect(within(firstDocumentDiff).getByText('Designer')).toBeInTheDocument() + + expect( + within(secondDocumentDiff).getByText( + (content, el) => el?.tagName.toLowerCase() === 'ins' && content === 'Virginia Woolf test', + ), + ).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('Developer')).toBeInTheDocument() + }) + }) + + describe('when the base document is loaded and there are no changes', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: MOCKED_DOCUMENTS[0].document, + loading: false, + }) + + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it('should show that there are no changes', async () => { + expect(screen.getByText('No changes')).toBeInTheDocument() + }) + }) + + describe('when the base document is loaded and has changes', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockImplementation((docId: string) => { + return { + // @ts-expect-error - key is valid, ts won't infer it + document: BASE_DOCUMENTS_MOCKS[docId], + loading: false, + } + }) + + const wrapper = await createReleaseReviewWrapper() + render(, {wrapper}) + }) + it('should should show the changes', async () => { + // Find an ins tag with the text "added" + const firstDocumentChange = screen.getByText((content, el) => { + return el?.tagName.toLowerCase() === 'ins' && content === 'added' + }) + + expect(firstDocumentChange).toBeInTheDocument() + + const secondDocumentChange = screen.getByText((content, el) => { + return el?.tagName.toLowerCase() === 'ins' && content === 'test' + }) + + expect(secondDocumentChange).toBeInTheDocument() + }) + it('should collapse documents', () => { + const firstDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[0].document._id}`, + ) + const secondDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[1].document._id}`, + ) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + // get the toggle button with id 'document-review-header-toggle' inside the first document diff + const firstDocToggle = within(firstDocumentDiff).getByTestId('document-review-header-toggle') + act(() => { + fireEvent.click(firstDocToggle) + }) + expect(within(firstDocumentDiff).queryByText('added')).not.toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + act(() => { + fireEvent.click(firstDocToggle) + }) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + + const secondDocToggle = within(secondDocumentDiff).getByTestId( + 'document-review-header-toggle', + ) + act(() => { + fireEvent.click(secondDocToggle) + }) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).queryByText('test')).not.toBeInTheDocument() + }) + }) + describe('filtering documents', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockImplementation((docId: string) => { + return { + // @ts-expect-error - key is valid, ts won't infer it + document: BASE_DOCUMENTS_MOCKS[docId], + loading: false, + } + }) + + const wrapper = await createReleaseReviewWrapper() + + render(, {wrapper}) + }) + + it('should show all the documents when no filter is applied', () => { + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).toBeInTheDocument() + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + }) + it('should show support filtering by title', async () => { + const searchInput = screen.getByPlaceholderText('Search documents') + act(() => { + fireEvent.change(searchInput, {target: {value: 'Virginia'}}) + }) + + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).not.toBeInTheDocument() + + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + + act(() => { + fireEvent.change(searchInput, {target: {value: ''}}) + }) + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).toBeInTheDocument() + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx new file mode 100644 index 00000000000..3ab0aebc9e0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx @@ -0,0 +1,103 @@ +import {render, within} from '@testing-library/react' +import {describe, expect, it} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {activeASAPRelease} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import { + archivedReleaseEvents, + publishedReleaseEvents, + unarchivedReleaseEvents, +} from '../events/__fixtures__/release-events' +import {ReleaseStatusItems} from '../ReleaseStatusItems' + +describe('ReleaseStatusItems', () => { + it('renders fallback status item when no footer event is found', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render(, { + wrapper, + }) + const text = await component.findByText('Created') + expect(text).toBeInTheDocument() + }) + it('renders the creation event, when no any other relevant event is present', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const timeElement = await component.findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-03T00:00:00.000Z') + const text = await component.findByText('Created') + expect(text).toBeInTheDocument() + }) + it('renders a status item for a PublishRelease event and the create event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const publishEvent = await component.findByTestId('status-publishRelease') + const timeElement = await within(publishEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-05T00:00:00.000Z') + const text = await within(publishEvent).findByText('Published') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) + it('renders a status item for an ArchiveRelease event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const archivedEvent = await component.findByTestId('status-archiveRelease') + + const timeElement = await within(archivedEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-05T00:00:00.000Z') + const text = await within(archivedEvent).findByText('Archived') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) + it('renders a status item for an UnarchiveRelease event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + const component = render( + , + { + wrapper, + }, + ) + const unarchiveEvent = await component.findByTestId('status-unarchiveRelease') + + const timeElement = await within(unarchiveEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-06T00:00:00.000Z') + const text = await within(unarchiveEvent).findByText('Unarchived') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx new file mode 100644 index 00000000000..cb949bb1d66 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx @@ -0,0 +1,343 @@ +import {act, fireEvent, render, screen, within} from '@testing-library/react' +import {cloneElement, type FC, type PropsWithChildren, type ReactElement, useState} from 'react' +import {route, RouterProvider} from 'sanity/router' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getByDataUi} from '../../../../../../test/setup/customQueries' +import {setupVirtualListEnv} from '../../../../../../test/testUtils/setupVirtualListEnv' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {DefaultPreview} from '../../../../components/previews/general/DefaultPreview' +import { + activeASAPRelease, + archivedScheduledRelease, + scheduledRelease, +} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {ReleaseSummary, type ReleaseSummaryProps} from '../ReleaseSummary' +import {type DocumentInRelease} from '../useBundleDocuments' +import { + documentsInRelease, + useBundleDocumentsMockReturnWithResults, +} from './__mocks__/useBundleDocuments.mock' + +vi.mock('../../../index', () => ({ + useDocumentPresence: vi.fn().mockReturnValue({ + user: '', + path: '', + sessionId: '', + lastActiveAt: '', + }), + useDocumentPreviewStore: vi.fn().mockReturnValue({ + unstable_observeDocumentIdSet: vi.fn(() => ({ + pipe: vi.fn(), + })), + }), +})) + +vi.mock('../useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturnWithResults), +})) + +vi.mock('../../../../studio/components/navbar/search/components/SearchPopover') + +vi.mock('../../../../preview/components/_previewComponents', async () => { + return { + _previewComponents: { + default: vi.fn((arg) => ), + }, + } +}) + +const releaseDocuments: DocumentInRelease[] = [ + { + ...documentsInRelease, + memoKey: '123', + document: { + ...documentsInRelease.document, + title: 'First document', + _id: '123', + _rev: 'abc', + }, + previewValues: { + ...documentsInRelease.previewValues, + values: {title: 'First document'}, + }, + }, + { + ...documentsInRelease, + memoKey: '456', + document: { + ...documentsInRelease.document, + _updatedAt: new Date().toISOString(), + _id: '456', + _rev: 'abc', + title: 'Second document', + }, + previewValues: { + ...documentsInRelease.previewValues, + values: {title: 'Second document'}, + }, + }, +] + +const ScrollContainer: FC = ({children}) => { + const [ref, setRef] = useState(null) + + return ( +
+ {cloneElement(children as ReactElement, {scrollContainerRef: {current: ref}})} +
+ ) +} + +const renderTest = async (props: Partial) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + return render( + + + + + , + { + wrapper, + }, + ) +} + +describe('ReleaseSummary', () => { + setupVirtualListEnv() + + describe('for an active release', () => { + beforeEach(async () => { + await renderTest({}) + await vi.waitFor(() => screen.getByTestId('document-table-card'), { + timeout: 5000, + interval: 500, + }) + }) + + it('shows list of all documents in release', async () => { + const documents = screen.getAllByTestId('table-row') + + expect(documents).toHaveLength(2) + }) + + it('allows for document to be discarded', () => { + const [firstDocumentRow] = screen.getAllByTestId('table-row') + + fireEvent.click(getByDataUi(firstDocumentRow, 'MenuButton')) + fireEvent.click(screen.getByText('Discard version')) + }) + + it('allows for sorting of documents', () => { + const [initialFirstDocument, initialSecondDocument] = screen.getAllByTestId('table-row') + + within(initialFirstDocument).getByText('First document') + within(initialSecondDocument).getByText('Second document') + + fireEvent.click(within(screen.getByRole('table')).getByText('Edited')) + + const [sortedCreatedAscFirstDocument, sortedCreatedAscSecondDocument] = + screen.getAllByTestId('table-row') + + within(sortedCreatedAscFirstDocument).getByText('Second document') + within(sortedCreatedAscSecondDocument).getByText('First document') + + fireEvent.click(within(screen.getByRole('table')).getByText('Edited')) + + const [sortedEditedDescFirstDocument, sortedEditedDescSecondDocument] = + screen.getAllByTestId('table-row') + + within(sortedEditedDescFirstDocument).getByText('First document') + within(sortedEditedDescSecondDocument).getByText('Second document') + }) + + it('allows for searching documents', async () => { + await act(() => { + fireEvent.change(screen.getByPlaceholderText('Search documents'), { + target: {value: 'Second'}, + }) + }) + + const [searchedFirstDocument] = screen.getAllByTestId('table-row') + + within(searchedFirstDocument).getByText('Second document') + }) + + it('Allows for adding a document to an active release', () => { + screen.getByText('Add document') + }) + }) + + describe('for an archived release', () => { + beforeEach(async () => { + await renderTest({release: archivedScheduledRelease}) + await vi.waitFor(() => screen.getByTestId('document-table-card')) + }) + + it('does not allow for adding documents', () => { + expect(screen.queryByText('Add document')).toBeNull() + }) + }) + + describe('for a scheduled release', () => { + beforeEach(async () => { + await renderTest({release: scheduledRelease}) + await vi.waitFor(() => screen.getByTestId('document-table-card')) + }) + + it('does not allow for adding documents', () => { + expect(screen.queryByText('Add document')).toBeNull() + }) + }) + + describe('Release Badges in the Table component', () => { + beforeEach(async () => { + vi.clearAllMocks() + }) + + it('should show `unpublish` if a document is scheduled for unpublishing', async () => { + await renderTest({ + release: scheduledRelease, + documents: [ + { + ...releaseDocuments[0], + document: {...releaseDocuments[0].document, willBeUnpublished: true}, + }, + ], + }) + await vi.waitFor(() => screen.getByTestId('document-table-card')) + }) + + it('should show `change` if a document is published', async () => { + await renderTest({ + release: scheduledRelease, + documents: [ + { + ...releaseDocuments[0], + document: { + ...releaseDocuments[0].document, + publishedDocumentExists: true, + }, + }, + ], + }) + await vi.waitFor(() => screen.getByTestId('document-table-card')) + + const [firstDocumentRow] = screen.getAllByTestId('table-row') + + expect(within(firstDocumentRow).getByTestId('change-badge-123')).toBeInTheDocument() + }) + + it('should show `add` if a document is not published and is not scheduled for unpublishing', async () => { + await renderTest({ + release: scheduledRelease, + documents: [ + { + ...releaseDocuments[0], + document: { + ...releaseDocuments[0].document, + publishedDocumentExists: false, // enforce these as false for the test purpose + willBeUnpublished: false, // enforce these as false for the test purpose + }, + }, + ], + }) + await vi.waitFor(() => screen.getByTestId('document-table-card')) + + const [firstDocumentRow] = screen.getAllByTestId('table-row') + + expect(within(firstDocumentRow).getByTestId('add-badge-123')).toBeInTheDocument() + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseTypePicker.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseTypePicker.test.tsx new file mode 100644 index 00000000000..4d6c1ac769c --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseTypePicker.test.tsx @@ -0,0 +1,224 @@ +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getByDataUi, queryByDataUi} from '../../../../../../test/setup/customQueries' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {useTimeZoneMockReturn} from '../../../../scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock' +import { + activeASAPRelease, + activeScheduledRelease, + activeUndecidedRelease, + publishedASAPRelease, + scheduledRelease, +} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import { + mockUseReleaseOperations, + useReleaseOperationsMockReturn, +} from '../../../store/__tests__/__mocks/useReleaseOperations.mock' +import {ReleaseTypePicker} from '../ReleaseTypePicker' + +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../../../../scheduledPublishing/hooks/useTimeZone', async (importOriginal) => ({ + ...(await importOriginal()), + useTimeZone: vi.fn(() => useTimeZoneMockReturn), +})) + +const renderComponent = async (release = activeASAPRelease) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + render(, {wrapper}) + + await waitFor(() => { + expect(screen.getByTestId('release-type-label')).toBeInTheDocument() + }) +} + +const mockUpdateRelease = vi.fn() + +describe('ReleaseTypePicker', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + updateRelease: mockUpdateRelease.mockResolvedValue({}), + }) + }) + + describe('renders the label for different release types', () => { + it('renders the button and displays for ASAP release', async () => { + await renderComponent() + + expect(screen.getByText('ASAP')).toBeInTheDocument() + }) + + it('renders the button and displays for undecided release', async () => { + await renderComponent(activeUndecidedRelease) + + expect(screen.getByText('Undecided')).toBeInTheDocument() + }) + + it('renders the button and displays the date for scheduled release', async () => { + await renderComponent(activeScheduledRelease) + + expect(screen.getByText('Oct 10, 2023', {exact: false})).toBeInTheDocument() + }) + + it('renders the label with a published text when release was asap published', async () => { + await renderComponent(publishedASAPRelease) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + + expect(screen.getByTestId('published-release-type-label')).toBeInTheDocument() + + expect(screen.getByText('Published')).toBeInTheDocument() + }) + + it('renders the label with a published text when release was schedule published', async () => { + await renderComponent({...scheduledRelease, state: 'published'}) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + + expect(screen.getByTestId('published-release-type-label')).toBeInTheDocument() + + expect(screen.getByText('Published on Oct 10, 2023, 3:00:00 AM')).toBeInTheDocument() + }) + }) + + describe('interacting with the popup content', () => { + it('opens the popover when the button is clicked', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + getByDataUi(document.body, 'Popover') + }) + + it('does not show calendar for ASAP and undecided releases', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + expect(screen.queryByTestId('date-input')).not.toBeInTheDocument() + expect(queryByDataUi(document.body, 'Calendar')).not.toBeInTheDocument() + + const scheduledTab = screen.getByText('Undecided') + fireEvent.click(scheduledTab) + expect(screen.queryByTestId('date-input')).not.toBeInTheDocument() + expect(queryByDataUi(document.body, 'Calendar')).not.toBeInTheDocument() + }) + + it('switches to "Scheduled" release type and displays the date input', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + const scheduledTab = screen.getByText('At time') + fireEvent.click(scheduledTab) + expect(screen.getByTestId('date-input')).toBeInTheDocument() + expect(getByDataUi(document.body, 'Calendar')).toBeInTheDocument() + }) + + it('hides calendar when moving back from scheduled option', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + const scheduledTab = screen.getByText('At time') + fireEvent.click(scheduledTab) + const asapTab = screen.getByText('ASAP') + fireEvent.click(asapTab) + + expect(screen.queryByTestId('date-input')).not.toBeInTheDocument() + expect(queryByDataUi(document.body, 'Calendar')).not.toBeInTheDocument() + }) + + it('sets the selected scheduled time when popup closed', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + const scheduledTab = screen.getByText('At time') + fireEvent.click(scheduledTab) + + const Calendar = getByDataUi(document.body, 'CalendarMonth') + + // Select the 10th day in the calendar month + fireEvent.click(within(Calendar).getByText('10')) + fireEvent.change(screen.getByLabelText('Select hour'), {target: {value: 10}}) + fireEvent.change(screen.getByLabelText('Select minute'), {target: {value: 55}}) + expect(mockUpdateRelease).not.toHaveBeenCalled() + + // Close the popup and check if the release is updated + fireEvent.click(screen.getByTestId('release-type-picker')) + expect(mockUpdateRelease).toHaveBeenCalledTimes(1) + expect(mockUpdateRelease).toHaveBeenCalledWith({ + ...activeASAPRelease, + metadata: expect.objectContaining({ + ...activeASAPRelease.metadata, + releaseType: 'scheduled', + /** @todo improve the assertion on the dateTime */ + intendedPublishAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:55:\d{2}\.\d{3}Z$/), + }), + }) + }) + + it('sets the release type to undecided when undecided is selected', async () => { + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + const undecidedTab = screen.getByText('Undecided') + fireEvent.click(undecidedTab) + fireEvent.click(screen.getByTestId('release-type-picker')) + expect(mockUpdateRelease).toHaveBeenCalledTimes(1) + expect(mockUpdateRelease).toHaveBeenCalledWith({ + ...activeASAPRelease, + metadata: expect.objectContaining({ + ...activeASAPRelease.metadata, + intendedPublishAt: undefined, + releaseType: 'undecided', + }), + }) + }) + }) + + describe('picker behavior based on release state', () => { + it('disables the picker for archived releases', async () => { + await renderComponent({...activeASAPRelease, state: 'archived'}) + + const pickerButton = screen.getByRole('button') + expect(pickerButton).toBeDisabled() + }) + + it('does not show button for picker when release is published state', async () => { + await renderComponent(publishedASAPRelease) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('shows a spinner when updating the release', async () => { + // keep promise pending + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + updateRelease: vi.fn().mockImplementation(() => { + return new Promise(() => {}) + }), + }) + await renderComponent() + + const pickerButton = screen.getByRole('button') + fireEvent.click(pickerButton) + fireEvent.click(screen.getByText('Undecided')) + fireEvent.click(screen.getByTestId('release-type-picker')) + + // Check if the spinner is displayed while updating + screen.getByTestId('updating-release-spinner') + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useBundleDocuments.mock.ts b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useBundleDocuments.mock.ts new file mode 100644 index 00000000000..f46053e3901 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useBundleDocuments.mock.ts @@ -0,0 +1,38 @@ +import {type Mock, type Mocked} from 'vitest' + +import {type DocumentInRelease, useBundleDocuments} from '../../useBundleDocuments' + +export const documentsInRelease: DocumentInRelease = { + memoKey: 'a', + document: { + _id: 'a', + _createdAt: '2023-10-01T08:00:00Z', + _updatedAt: '2023-10-01T09:00:00Z', + _rev: 'a', + _type: 'document', + publishedDocumentExists: true, + }, + validation: { + hasError: false, + validation: [], + isValidating: false, + }, + previewValues: { + isLoading: false, + values: {}, + }, +} + +export const useBundleDocumentsMockReturn: Mocked> = { + loading: false, + results: [], +} + +export const useBundleDocumentsMockReturnWithResults: Mocked< + ReturnType +> = { + loading: false, + results: [documentsInRelease], +} + +export const mockUseBundleDocuments = useBundleDocuments as Mock diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts new file mode 100644 index 00000000000..5034767401e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts @@ -0,0 +1,12 @@ +import {type Mocked, vitest} from 'vitest' + +import {publishedReleaseEvents} from '../../events/__fixtures__/release-events' +import {type useReleaseEvents} from '../../events/useReleaseEvents' + +export const useReleaseEventsMockReturn: Mocked> = { + loading: false, + events: publishedReleaseEvents, + hasMore: false, + error: null, + loadMore: vitest.fn(), +} diff --git a/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentActions.tsx b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentActions.tsx new file mode 100644 index 00000000000..f510c30a128 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentActions.tsx @@ -0,0 +1,74 @@ +import {CloseIcon, UnpublishIcon} from '@sanity/icons' +import {Box, Card, Label, Menu, MenuDivider} from '@sanity/ui' +import {memo, useState} from 'react' + +import {MenuButton, MenuItem} from '../../../../../ui-components' +import {ContextMenuButton} from '../../../../components/contextMenuButton' +import {useTranslation} from '../../../../i18n' +import {DiscardVersionDialog} from '../../../components' +import {UnpublishVersionDialog} from '../../../components/dialog/UnpublishVersionDialog' +import {releasesLocaleNamespace} from '../../../i18n' +import {isGoingToUnpublish} from '../../../util/isGoingToUnpublish' +import {type BundleDocumentRow} from '../ReleaseSummary' + +export const DocumentActions = memo( + function DocumentActions({ + document, + releaseTitle, + }: { + document: BundleDocumentRow + releaseTitle: string + }) { + const [showDiscardDialog, setShowDiscardDialog] = useState(false) + const [showUnpublishDialog, setShowUnpublishDialog] = useState(false) + const {t: coreT} = useTranslation() + const {t} = useTranslation(releasesLocaleNamespace) + const isAlreadyUnpublished = isGoingToUnpublish(document.document) + + return ( + <> + + } + menu={ + + setShowDiscardDialog(true)} + /> + + + + + setShowUnpublishDialog(true)} + /> + + } + /> + + {showDiscardDialog && ( + setShowDiscardDialog(false)} + documentId={document.document._id} + documentType={document.document._type} + /> + )} + {showUnpublishDialog && ( + setShowUnpublishDialog(false)} + documentVersionId={document.document._id} + documentType={document.document._type} + /> + )} + + ) + }, + (prev, next) => + prev.document.memoKey === next.document.memoKey && prev.releaseTitle === next.releaseTitle, +) diff --git a/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentTableColumnDefs.tsx b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentTableColumnDefs.tsx new file mode 100644 index 00000000000..c365d9e06b4 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentTableColumnDefs.tsx @@ -0,0 +1,209 @@ +import {ErrorOutlineIcon} from '@sanity/icons' +import {Badge, Box, Flex, Text} from '@sanity/ui' +import {type TFunction} from 'i18next' +import {memo} from 'react' + +import {ToneIcon} from '../../../../../ui-components/toneIcon/ToneIcon' +import {Tooltip} from '../../../../../ui-components/tooltip' +import {UserAvatar} from '../../../../components' +import {RelativeTime} from '../../../../components/RelativeTime' +import {useSchema} from '../../../../hooks' +import {type ReleaseState} from '../../../store' +import {isGoingToUnpublish} from '../../../util/isGoingToUnpublish' +import {ReleaseDocumentPreview} from '../../components/ReleaseDocumentPreview' +import {Headers} from '../../components/Table/TableHeader' +import {type Column} from '../../components/Table/types' +import {type BundleDocumentRow} from '../ReleaseSummary' +import {type DocumentInRelease} from '../useBundleDocuments' + +const MemoReleaseDocumentPreview = memo( + function MemoReleaseDocumentPreview({ + item, + releaseId, + releaseState, + documentRevision, + }: { + item: DocumentInRelease + releaseId: string + releaseState?: ReleaseState + documentRevision?: string + }) { + return ( + + ) + }, + (prev, next) => prev.item.memoKey === next.item.memoKey && prev.releaseId === next.releaseId, +) + +const MemoDocumentType = memo( + function DocumentType({type}: {type: string}) { + const schema = useSchema() + const schemaType = schema.get(type) + return {schemaType?.title || 'Not found'} + }, + (prev, next) => prev.type === next.type, +) + +const documentActionColumn: (t: TFunction<'releases', undefined>) => Column = ( + t, +) => ({ + id: 'action', + width: 100, + header: (props) => ( + + + + ), + cell: ({cellProps, datum}) => { + const willBeUnpublished = isGoingToUnpublish(datum.document) + const actionBadge = () => { + if (willBeUnpublished) { + return ( + + {t('table-body.action.unpublish')} + + ) + } + if (datum.document.publishedDocumentExists) { + return ( + + {t('table-body.action.change')} + + ) + } + + return ( + + {t('table-body.action.add')} + + ) + } + + return ( + + {actionBadge()} + + ) + }, +}) + +export const getDocumentTableColumnDefs: ( + releaseId: string, + releaseState: ReleaseState, + t: TFunction<'releases', undefined>, +) => Column[] = (releaseId, releaseState, t) => [ + /** + * Hiding action for archived and published releases of v1.0 + * This will be added once Events API has reverse order lookup supported + */ + ...(releaseState === 'archived' || releaseState === 'published' ? [] : [documentActionColumn(t)]), + { + id: 'document._type', + width: 100, + sorting: true, + header: (props) => ( + + + + ), + cell: ({cellProps, datum}) => ( + + + + + + ), + }, + { + id: 'search', + width: null, + style: {minWidth: '50%', maxWidth: '50%'}, + sortTransform(value) { + return value.previewValues.values.title?.toLowerCase() || 0 + }, + header: (props) => ( + + ), + cell: ({cellProps, datum}) => ( + + + + ), + }, + { + id: 'document._updatedAt', + sorting: true, + width: 130, + header: (props) => ( + + + + ), + cell: ({cellProps, datum: {document, history}}) => ( + + {document._updatedAt && ( + + {history?.lastEditedBy && } + + + + + )} + + ), + }, + { + id: 'validation', + sorting: false, + width: 50, + header: ({headerProps}) => ( + + + + ), + cell: ({cellProps, datum}) => { + const validationErrorCount = datum.validation.validation.length + + return ( + + {datum.validation.hasError && ( + + + + {t( + validationErrorCount === 1 + ? 'document-validation.error_one' + : 'document-validation.error_other', + {count: validationErrorCount}, + )} + + + } + > + + + + + )} + + ) + }, + }, +] diff --git a/packages/sanity/src/core/releases/tool/detail/documentTable/types.ts b/packages/sanity/src/core/releases/tool/detail/documentTable/types.ts new file mode 100644 index 00000000000..a351b8a5d43 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/types.ts @@ -0,0 +1,4 @@ +export interface DocumentSort { + property: '_updatedAt' | '_createdAt' | '_publishedAt' + order: 'asc' | 'desc' +} diff --git a/packages/sanity/src/core/releases/tool/detail/documentTable/useReleaseHistory.ts b/packages/sanity/src/core/releases/tool/detail/documentTable/useReleaseHistory.ts new file mode 100644 index 00000000000..0f038c56910 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/useReleaseHistory.ts @@ -0,0 +1,96 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {useCallback, useEffect, useMemo, useState} from 'react' + +import {useClient} from '../../../../hooks' +import {getJsonStream} from '../../../../store/_legacy/history/history/getJsonStream' +import {API_VERSION} from '../../../../tasks/constants' +import {getVersionId} from '../../../../util' + +export type DocumentHistory = { + history: TransactionLogEventWithEffects[] + createdBy: string + lastEditedBy: string + editors: string[] +} + +// TODO: Update this to contemplate the _revision change on any of the internal release documents, and fetch only the history of that document if changes. +export function useReleaseHistory( + releaseDocumentsIds: string[], + releaseId: string, +): { + documentsHistory: Record + collaborators: string[] + loading: boolean +} { + const client = useClient({apiVersion: API_VERSION}) + const {dataset, token} = client.config() + const [history, setHistory] = useState([]) + const queryParams = `tag=sanity.studio.tasks.history&effectFormat=mendoza&excludeContent=true&includeIdentifiedDocumentsOnly=true` + const versionIds = releaseDocumentsIds.map((id) => getVersionId(id, releaseId)).join(',') + const transactionsUrl = client.getUrl( + `/data/history/${dataset}/transactions/${versionIds}?${queryParams}`, + ) + + const fetchAndParseAll = useCallback(async () => { + if (!versionIds) return + if (!releaseId) return + const transactions: TransactionLogEventWithEffects[] = [] + const stream = await getJsonStream(transactionsUrl, token) + const reader = stream.getReader() + let result + for (;;) { + result = await reader.read() + if (result.done) { + break + } + if ('error' in result.value) { + throw new Error(result.value.error.description || result.value.error.type) + } + transactions.push(result.value) + } + setHistory(transactions) + }, [versionIds, transactionsUrl, token, releaseId]) + + useEffect(() => { + fetchAndParseAll() + // When revision changes, update the history. + }, [fetchAndParseAll]) + + return useMemo(() => { + const collaborators: string[] = [] + const documentsHistory: Record = {} + if (!history.length) { + return {documentsHistory, collaborators, loading: true} + } + history.forEach((item) => { + const documentId = item.documentIDs[0] + let documentHistory = documentsHistory[documentId] + if (!collaborators.includes(item.author)) { + collaborators.push(item.author) + } + // eslint-disable-next-line no-negated-condition + if (!documentHistory) { + documentHistory = { + history: [item], + createdBy: item.author, + lastEditedBy: item.author, + editors: [item.author], + } + documentsHistory[documentId] = documentHistory + } else { + // @ts-expect-error TransactionLogEventWithEffects has no property 'mutations' but it's returned from the API + const isCreate = item.mutations.some((mutation) => 'create' in mutation) + if (isCreate) documentHistory.createdBy = item.author + if (!documentHistory.editors.includes(item.author)) { + documentHistory.editors.push(item.author) + } + // The last item in the history is the last edited by, transaction log is ordered by timestamp + documentHistory.lastEditedBy = item.author + // always add history item + documentHistory.history.push(item) + } + }) + + return {documentsHistory, collaborators, loading: false} + }, [history]) +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts b/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts new file mode 100644 index 00000000000..4852458a481 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts @@ -0,0 +1,60 @@ +import {type ReleaseEvent} from '../types' + +const author = 'author1' +const releaseName = 'release1' + +export const publishedReleaseEvents: ReleaseEvent[] = [ + { + id: '3', + type: 'publishRelease', + author, + timestamp: '2024-12-05T00:00:00Z', + releaseName, + origin: 'events', + }, + { + id: '2', + type: 'addDocumentToRelease', + author, + timestamp: '2024-12-04T00:00:00Z', + releaseName, + documentId: 'foo', + documentType: 'author', + versionId: 'versions.release1.foo', + revisionId: 'rev1', + versionRevisionId: 'versions.release1.foo.rev1', + origin: 'events', + }, + { + id: '1', + type: 'createRelease', + author, + timestamp: '2024-12-03T00:00:00Z', + origin: 'events', + releaseName, + }, +] + +export const archivedReleaseEvents: ReleaseEvent[] = [ + { + id: '3', + type: 'archiveRelease', + author, + timestamp: '2024-12-05T00:00:00Z', + releaseName, + origin: 'events', + }, + ...publishedReleaseEvents.slice(1), +] + +export const unarchivedReleaseEvents: ReleaseEvent[] = [ + { + id: '4', + type: 'unarchiveRelease', + origin: 'events', + author, + timestamp: '2024-12-06T00:00:00Z', + releaseName, + }, + ...archivedReleaseEvents, +] diff --git a/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts new file mode 100644 index 00000000000..16e87a31688 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts @@ -0,0 +1,577 @@ +import {describe, expect, it} from 'vitest' + +import {type ReleaseDocument} from '../../../store/types' +import {buildReleaseEditEvents} from './buildReleaseEditEvents' + +describe('buildReleaseEditEvents()', () => { + it('should identify a metadata.releaseType change', () => { + const release = { + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + _rev: '27IdYXOVe1PEc0ZOADFAhQ', + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:09:28Z', + metadata: { + releaseType: 'undecided', + description: '', + title: 'winter drop', + }, + publishAt: null, + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, + ], + release, + ) + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should identify a intededPublishDate change', () => { + const release = { + publishAt: null, + _rev: 'zGoOhrVQZLzwh7QVfgQryJ', + _id: '_.releases.rWBfpXZVj', + _createdAt: '2024-12-05T16:34:59Z', + userId: '', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-13T17:12:00.000Z', + }, + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:12:56Z', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '56', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '3', + 23, + 10, + 24, + 15, + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 17, + 22, + '48', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '2', + 23, + 10, + 24, + 15, + 15, + ], + }, + }, + }, + ], + release, + ) + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {intendedPublishDate: '2024-12-13T17:12:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should identify a metadata.releaseType and intendedPublishDate change', () => { + const releaseDocument = { + publishAt: null, + finalDocumentStates: null, + _id: '_.releases.rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T16:35:11Z', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-20T16:35:00.000Z', + }, + _rev: 'zGoOhrVQZLzwh7QVfgIGWK', + _type: 'system.release', + name: 'rWBfpXZVj', + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + } as unknown as ReleaseDocument + + const releaseEditEvents = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 15, + 22, + '5:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [10, 0, 14, '_updatedAt', 10, 5, 19, 1, 17, 'asap', 'releaseType', 15], + }, + }, + }, + ], + releaseDocument, + ) + expect(releaseEditEvents).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: { + releaseType: 'scheduled', + intendedPublishDate: '2024-12-20T16:35:00.000Z', + }, + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should handle multiple changes correctly', () => { + const release = { + publishAt: null, + _rev: 'zGoOhrVQZLzwh7QVfgQryJ', + _id: '_.releases.rWBfpXZVj', + _createdAt: '2024-12-05T16:34:59Z', + userId: '', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-13T17:12:00.000Z', + }, + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:12:56Z', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '56', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '3', + 23, + 10, + 24, + 15, + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 17, + 22, + '48', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '2', + 23, + 10, + 24, + 15, + 15, + ], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOADFjtK', + timestamp: '2024-12-05T17:12:48.742846Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 14, + 22, + '12:4', + 23, + 18, + 20, + 15, + 10, + 5, + 17, + '2024-12-12T17:12:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 14, + 22, + '09:2', + 23, + 18, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, + { + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 15, + 22, + '5:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [10, 0, 14, '_updatedAt', 10, 5, 19, 1, 17, 'asap', 'releaseType', 15], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOAD1jvY', + timestamp: '2024-12-05T16:34:59.512774Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 0, + { + _createdAt: '2024-12-05T16:34:59Z', + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + _updatedAt: '2024-12-05T16:34:59Z', + finalDocumentStates: null, + metadata: { + description: '', + releaseType: 'asap', + title: 'winter drop', + }, + name: 'rWBfpXZVj', + publishAt: null, + state: 'active', + userId: '', + }, + ], + revert: [0, null], + }, + }, + }, + ], + release, + ) + + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {intendedPublishDate: '2024-12-13T17:12:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'scheduled', intendedPublishDate: '2024-12-12T17:12:00.000Z'}, + id: '27IdYXOVe1PEc0ZOADFjtK', + timestamp: '2024-12-05T17:12:48.742846Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'scheduled', intendedPublishDate: '2024-12-20T16:35:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'createRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'asap'}, + id: '27IdYXOVe1PEc0ZOAD1jvY', + timestamp: '2024-12-05T16:34:59.512774Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts new file mode 100644 index 00000000000..750512c24c6 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts @@ -0,0 +1,55 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' + +import {applyMendozaPatch} from '../../../../preview/utils/applyMendozaPatch' +import {type ReleaseDocument, type ReleaseType} from '../../../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {type CreateReleaseEvent, type EditReleaseEvent} from './types' + +export function buildReleaseEditEvents( + transactions: TransactionLogEventWithEffects[], + release: ReleaseDocument, +): (EditReleaseEvent | CreateReleaseEvent)[] { + // Confirm we have all the events by checking the first transaction id and the release._rev, the should match. + if (release._rev !== transactions[0]?.id) { + console.error('Some transactions are missing, cannot calculate the edit events') + return [] + } + + const releaseEditEvents: (EditReleaseEvent | CreateReleaseEvent)[] = [] + // We start from the last release document and apply changes in reverse order + // Compare for each transaction what changed, if metadata.releaseType or metadata.intendedPublishAt changed build an event. + let currentDocument = release + for (const transaction of transactions) { + const effect = transaction.effects[release._id] + if (!effect) continue + // This will apply the revert effect to the document, so we will get the document from before this change. + const before = applyMendozaPatch(currentDocument, effect.revert, currentDocument._rev) + const changed: { + releaseType?: ReleaseType + intendedPublishDate?: string + } = {} + + if (before?.metadata.releaseType !== currentDocument.metadata.releaseType) { + changed.releaseType = currentDocument.metadata.releaseType + } + if (before?.metadata.intendedPublishAt !== currentDocument.metadata.intendedPublishAt) { + changed.intendedPublishDate = currentDocument.metadata.intendedPublishAt + } + // If the "changed" object has more than one key identify it as a change event + if (Object.values(changed).length >= 1) { + releaseEditEvents.push({ + type: before ? 'editRelease' : 'createRelease', + origin: 'translog', + author: transaction.author, + change: changed, + id: transaction.id, + timestamp: transaction.timestamp, + releaseName: getReleaseIdFromReleaseDocumentId(release._id), + }) + if (before) { + currentDocument = before + } + } + } + return releaseEditEvents +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts new file mode 100644 index 00000000000..bdce055b700 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts @@ -0,0 +1,176 @@ +import {type SanityClient} from '@sanity/client' +import {of} from 'rxjs' +import {TestScheduler} from 'rxjs/testing' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {addEventData, getReleaseActivityEvents, INITIAL_VALUE} from './getReleaseActivityEvents' +import {type ReleaseEvent} from './types' + +const mockObservableRequest = vi.fn() + +const mockClient = { + observable: { + request: mockObservableRequest, + }, + config: vi.fn().mockReturnValue({dataset: 'testDataset'}), +} as unknown as SanityClient + +const creationEvent: Omit = { + timestamp: '2024-12-03T00:00:00Z', + type: 'createRelease', + releaseName: 'r123', + author: 'user-1', +} +const addFirstDocumentEvent: Omit = { + timestamp: '2024-12-03T01:00:00Z', + type: 'addDocumentToRelease', + releaseName: 'r123', + author: 'user-1', +} +const addSecondDocumentEvent: Omit = { + timestamp: '2024-12-03T02:00:00Z', + type: 'addDocumentToRelease', + releaseName: 'r123', + author: 'user-2', +} + +const releaseId = '_.releases.r123' +describe('getReleaseActivityEvents', () => { + let testScheduler: TestScheduler + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + it('should fetch initial events from the API', () => { + mockObservableRequest.mockReturnValueOnce( + of({ + events: [creationEvent, addFirstDocumentEvent], + nextCursor: 'cursor1', + }), + ) + + const {events$} = getReleaseActivityEvents({client: mockClient, releaseId}) + testScheduler.run(({expectObservable}) => { + expectObservable(events$).toBe('(ab)', { + a: INITIAL_VALUE, + b: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + loading: false, + error: null, + }, + }) + }) + }) + + it('should reload events when reloadEvents is called', () => { + mockObservableRequest + .mockReturnValueOnce( + of({ + events: [creationEvent, addFirstDocumentEvent], + nextCursor: 'cursor1', + }), + ) + .mockReturnValueOnce( + of({ + events: [addFirstDocumentEvent, addSecondDocumentEvent], + // This cursor won't be added, is a reload action we need to keep the previous. Reloads usually load less elements + nextCursor: 'cursor2', + }), + ) + + const {events$, reloadEvents} = getReleaseActivityEvents({client: mockClient, releaseId}) + + testScheduler.run(({expectObservable, cold}) => { + const actions = cold('5ms a', { + a: reloadEvents, + }) + + actions.subscribe((action) => action()) + + expectObservable(events$).toBe('(ab)-(cd)', { + a: INITIAL_VALUE, + b: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + loading: false, + error: null, + }, + c: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + // Emits a loading state + loading: true, + error: null, + }, + d: { + events: [ + addEventData(addSecondDocumentEvent), + addEventData(addFirstDocumentEvent), + addEventData(creationEvent), + ], + // Preserves previous cursor + nextCursor: 'cursor1', + loading: false, + error: null, + }, + }) + }) + }) + + it('should fetch additional events when loadMore is called', () => { + // It returns the first two events and then it loads an older one + mockObservableRequest + .mockReturnValueOnce( + of({ + events: [addFirstDocumentEvent, addSecondDocumentEvent], + nextCursor: 'cursor2', + }), + ) + .mockReturnValueOnce( + of({ + events: [creationEvent], + nextCursor: '', + }), + ) + + const {events$, loadMore} = getReleaseActivityEvents({client: mockClient, releaseId}) + + testScheduler.run(({expectObservable, cold}) => { + const actions = cold('5ms a', { + a: loadMore, + }) + + actions.subscribe((action) => action()) + expectObservable(events$).toBe('(ab)-(cd)', { + a: INITIAL_VALUE, + b: { + loading: false, + nextCursor: 'cursor2', + error: null, + events: [addEventData(addSecondDocumentEvent), addEventData(addFirstDocumentEvent)], + }, + c: { + loading: true, + // Given it's a loadMore action, we don't need to keep the previous cursor + nextCursor: '', + error: null, + events: [addEventData(addSecondDocumentEvent), addEventData(addFirstDocumentEvent)], + }, + d: { + loading: false, + nextCursor: '', + error: null, + events: [ + addEventData(addSecondDocumentEvent), + addEventData(addFirstDocumentEvent), + addEventData(creationEvent), + ], + }, + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts new file mode 100644 index 00000000000..cd2807b7f6f --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts @@ -0,0 +1,126 @@ +import {type SanityClient} from '@sanity/client' +import {BehaviorSubject, type Observable} from 'rxjs' +import {catchError, map, scan, shareReplay, startWith, switchMap, tap} from 'rxjs/operators' + +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {type ReleaseEvent} from './types' + +export interface ReleaseEventsObservableValue { + events: ReleaseEvent[] + nextCursor: string + loading: boolean + error: null | Error +} +export const INITIAL_VALUE: ReleaseEventsObservableValue = { + events: [], + nextCursor: '', + loading: true, + error: null, +} + +function removeDupes(prev: ReleaseEvent[], next: ReleaseEvent[]): ReleaseEvent[] { + const noDupes = [...prev, ...next].reduce((acc, event) => { + if (acc.has(event.id)) { + return acc + } + return acc.set(event.id, event) + }, new Map()) + return Array.from(noDupes.values()) +} + +export function addEventData(event: Omit): ReleaseEvent { + return {...event, id: `${event.timestamp}-${event.type}`, origin: 'events'} as ReleaseEvent +} + +interface InitialFetchEventsOptions { + client: SanityClient + releaseId: string +} +export function getReleaseActivityEvents({client, releaseId}: InitialFetchEventsOptions): { + events$: Observable + reloadEvents: () => void + loadMore: () => void +} { + const refetchEventsTrigger$ = new BehaviorSubject<{ + cursor: string | null + origin: 'loadMore' | 'reload' | 'initial' + }>({ + cursor: null, + origin: 'initial', + }) + + const fetchEvents = ({limit, nextCursor}: {limit: number; nextCursor: string | null}) => { + const params = new URLSearchParams({limit: limit.toString()}) + if (nextCursor) { + params.append('nextCursor', nextCursor) + } + return client.observable + .request<{ + events: Omit[] + nextCursor: string + }>({ + url: `/data/events/${client.config().dataset}/releases/${getReleaseIdFromReleaseDocumentId(releaseId)}?${params.toString()}`, + tag: 'get-release-events', + }) + .pipe( + map((response) => { + return { + events: response.events.map(addEventData), + nextCursor: response.nextCursor, + loading: false, + error: null, + } + }), + catchError((error) => { + console.error(error) + return [{events: [], nextCursor: '', loading: false, error}] + }), + ) + } + + let nextCursor: string = '' + return { + events$: refetchEventsTrigger$.pipe( + switchMap(({cursor, origin}) => { + return fetchEvents({ + nextCursor: cursor, + limit: origin === 'reload' ? 10 : 100, + }).pipe( + map((response) => { + return {...response, origin} + }), + startWith({events: [], nextCursor: '', loading: true, error: null, origin}), + ) + }), + scan((prev, next) => { + const events = removeDupes(prev.events, next.events).sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ) + return { + events: events, + // If we are reloading, we should keep the cursor as it was before. + nextCursor: next.origin === 'reload' ? prev.nextCursor : next.nextCursor, + loading: next.loading, + error: next.error, + } + }, INITIAL_VALUE), + tap((response) => { + nextCursor = response.nextCursor + }), + shareReplay(1), + ), + /** + * Loads new events for the release, fetching the latest events from the API. + */ + reloadEvents: () => refetchEventsTrigger$.next({cursor: null, origin: 'reload'}), + /** + * Loads more events for the release, fetching the next batch of events from the API. + */ + loadMore: () => { + const lastCursorUsed = refetchEventsTrigger$.getValue().cursor + if (nextCursor && lastCursorUsed !== nextCursor) { + refetchEventsTrigger$.next({origin: 'loadMore', cursor: nextCursor}) + } + }, + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts new file mode 100644 index 00000000000..6e09874bf86 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts @@ -0,0 +1,314 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {TestScheduler} from 'rxjs/testing' +import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {getTransactionsLogs} from '../../../../store/translog/getTransactionsLogs' +import {type ReleaseDocument} from '../../../store/types' +import { + type getReleaseEditEvents as getReleaseEditEventsFunction, + INITIAL_VALUE, +} from './getReleaseEditEvents' + +const mockClient = { + config: vi.fn().mockReturnValue({dataset: 'testDataset'}), +} as unknown as SanityClient + +vi.mock('../../../../store/translog/getTransactionsLogs', () => { + return { + getTransactionsLogs: vi.fn(), + } +}) +const MOCKED_RELEASE = { + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + _rev: 'mocked-rev', + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:09:28Z', + metadata: { + releaseType: 'undecided', + description: '', + title: 'winter drop', + }, + publishAt: null, + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + finalDocumentStates: null, +} as unknown as ReleaseDocument + +const MOCKED_TRANSACTION_LOGS: TransactionLogEventWithEffects[] = [ + { + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, +] + +const MOCKED_EVENT = { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', +} + +const mockGetTransactionsLogs = getTransactionsLogs as Mock +const BASE_GET_TRANSACTION_LOGS_PARAMS = { + effectFormat: 'mendoza', + fromTransaction: undefined, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + toTransaction: MOCKED_RELEASE._rev, +} as const + +const MOCKED_RELEASES_STATE = { + state: 'loaded' as const, + releaseStack: [], + releases: new Map([[MOCKED_RELEASE._id, MOCKED_RELEASE]]), +} + +describe('getReleaseEditEvents()', () => { + let testScheduler: TestScheduler + let getReleaseEditEvents: typeof getReleaseEditEventsFunction + beforeEach(async () => { + // We need to reset the module and reassign it because it has an internal cache that we need to evict + vi.resetModules() + const testModule = await import('./getReleaseEditEvents') + getReleaseEditEvents = testModule.getReleaseEditEvents + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + afterEach(() => { + vi.resetAllMocks() + }) + it('should not get the events if release is undefined', () => { + testScheduler.run(({expectObservable, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: 'not-existing-release', + releasesState$, + }) + + expectObservable(editEvents$).toBe('(a)', {a: INITIAL_VALUE}) + }) + }) + it('should get and build the release edit events', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + expectObservable(editEvents$).toBe('a-b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + }) + it('should expand the release edit events transactions if received max', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$, + }) + const mockFirstResponse$ = cold('-a|', { + a: Array.from({length: 100}).map((_, index) => { + return { + ...MOCKED_TRANSACTION_LOGS[0], + id: + index === 0 + ? MOCKED_TRANSACTION_LOGS[0].id + : `${MOCKED_TRANSACTION_LOGS[0].id}-${index + 1}`, + } + }), + }) + const mockSecondResponse$ = cold('-a|', { + a: Array.from({length: 100}).map((_, index) => { + return { + ...MOCKED_TRANSACTION_LOGS[0], + id: `${MOCKED_TRANSACTION_LOGS[0].id}-${index + 101}`, + } + }), + }) + const mockFinalResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs + .mockReturnValueOnce(mockFirstResponse$) + .mockReturnValueOnce(mockSecondResponse$) + .mockReturnValueOnce(mockFinalResponse$) + expectObservable(editEvents$).toBe('a---b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledTimes(3) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: MOCKED_RELEASE._rev, + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: `${MOCKED_TRANSACTION_LOGS[0].id}-100`, + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: `${MOCKED_TRANSACTION_LOGS[0].id}-200`, + }) + }) + it('should not refetch the edit events if rev has not changed', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Simulate the release states changing over time, but the _rev is the same + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: MOCKED_RELEASES_STATE, + }) + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + // Even though the state changes, the editEvents$ should not emit again + expectObservable(editEvents$).toBe('a-b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + }) + it('should refetch the edit events if release._rev changes', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Define the initial and updated release state + const updatedReleaseState = { + ...MOCKED_RELEASES_STATE, + releases: new Map([[MOCKED_RELEASE._id, {...MOCKED_RELEASE, _rev: 'changed-rev'}]]), + } + // Simulate the release states changing over time + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: updatedReleaseState, + }) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + const newTransaction = { + id: 'changed-rev', + timestamp: '2024-12-05T17:10:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: {}, + } + // It only returns the new transactions, the rest are from the cache, so they will be persisted. + const mockResponse2$ = cold('-a|', {a: [newTransaction]}) + + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$).mockReturnValueOnce(mockResponse2$) + + expectObservable(editEvents$).toBe('a-b---c', { + a: {editEvents: [], loading: true, error: null}, + b: { + editEvents: [MOCKED_EVENT], + loading: false, + error: null, + }, + c: { + editEvents: [MOCKED_EVENT], + loading: false, + error: null, + }, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + // Uses the previous release._rev as the fromTransaction + fromTransaction: MOCKED_RELEASE._rev, + // Uses the new release._rev as the toTransaction + toTransaction: 'changed-rev', + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts new file mode 100644 index 00000000000..7a9682c361d --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts @@ -0,0 +1,166 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' +import { + catchError, + distinctUntilChanged, + expand, + filter, + from, + map, + type Observable, + of, + reduce, + scan, + shareReplay, + startWith, + switchMap, + tap, +} from 'rxjs' + +import {getTransactionsLogs} from '../../../../store/translog/getTransactionsLogs' +import {type ReleasesReducerState} from '../../../store/reducer' +import {buildReleaseEditEvents} from './buildReleaseEditEvents' +import {type CreateReleaseEvent, type EditReleaseEvent} from './types' + +const TRANSLOG_ENTRY_LIMIT = 100 + +const documentTransactionsCache: Record = + Object.create(null) + +function removeDupes( + newTransactions: TransactionLogEventWithEffects[], + oldTransactions: TransactionLogEventWithEffects[], +) { + const seen = new Set() + return newTransactions.concat(oldTransactions).filter((transaction) => { + if (seen.has(transaction.id)) { + return false + } + seen.add(transaction.id) + return true + }) +} + +/** + * This will fetch all the transactions for a given release. + * I anticipate this would be a rather small number of transactions, given the release document is "small" and shouldn't change much. + * + * We need to fetch all of them to create the correct pagination of events in the activity feed, given we need to combine this with the + * releaseActivityEvents that will be fetched from the events api. + */ +function getReleaseTransactions({ + documentId, + client, + toTransaction, +}: { + documentId: string + client: SanityClient + toTransaction: string +}): Observable { + const cacheKey = `${documentId}` + const cachedTransactions = documentTransactionsCache[cacheKey] || [] + if (cachedTransactions.length > 0 && cachedTransactions[0].id === toTransaction) { + return of(cachedTransactions) + } + + function fetchLogs(options: { + fromTransaction?: string + toTransaction: string + }): Observable { + return from( + getTransactionsLogs(client, documentId, { + tag: 'sanity.studio.release.history', + effectFormat: 'mendoza', + limit: TRANSLOG_ENTRY_LIMIT, + reverse: true, + fromTransaction: options.fromTransaction, + toTransaction: options.toTransaction, + }), + ) + } + + return fetchLogs({fromTransaction: cachedTransactions[0]?.id, toTransaction: toTransaction}) + .pipe( + expand((response) => { + // Fetch more if the transactions length is equal to the limit + if (response.length === TRANSLOG_ENTRY_LIMIT) { + // Continue fetching if nextCursor exists, we use the last transaction received as the cursor. + return fetchLogs({ + fromTransaction: undefined, + toTransaction: response[response.length - 1].id, + }) + } + // End recursion by emitting an empty observable + return of() + }), + // Combine all batches of transactions into a single array + reduce( + (allTransactions, batch) => allTransactions.concat(batch), + [] as TransactionLogEventWithEffects[], + ), + ) + .pipe( + map((transactions) => removeDupes(transactions, cachedTransactions)), + tap((transactions) => { + documentTransactionsCache[cacheKey] = transactions + }), + ) +} + +interface EditEventsObservableValue { + editEvents: (EditReleaseEvent | CreateReleaseEvent)[] + loading: boolean + error: null | Error +} +export const INITIAL_VALUE: EditEventsObservableValue = { + editEvents: [], + loading: true, + error: null, +} + +interface getReleaseActivityEventsOpts { + client: SanityClient + releaseId: string + releasesState$: Observable +} +export function getReleaseEditEvents({ + client, + releaseId, + releasesState$, +}: getReleaseActivityEventsOpts): Observable { + return releasesState$.pipe( + map((releasesState) => releasesState.releases.get(releaseId)), + // Don't emit if the release is not found + filter(Boolean), + distinctUntilChanged((prev, next) => prev._rev === next._rev), + switchMap((release) => { + return getReleaseTransactions({ + client, + documentId: releaseId, + toTransaction: release._rev, + }).pipe( + map((transactions) => { + return { + editEvents: buildReleaseEditEvents(transactions, release), + loading: false, + error: null, + } + }), + catchError((error) => { + console.error(error) + return of({editEvents: [], loading: false, error}) + }), + ) + }), + startWith(INITIAL_VALUE), + scan((acc, current) => { + // Accumulate edit events from previous state + const editEvents = current.loading + ? acc.editEvents // Preserve previous events while loading + : current.editEvents // Update with new events when available + + return {...current, editEvents} + }, INITIAL_VALUE), + shareReplay(1), + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts new file mode 100644 index 00000000000..03ea64a5a7e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts @@ -0,0 +1,117 @@ +import {type SanityClient} from '@sanity/client' +import { + combineLatest, + distinctUntilChanged, + filter, + map, + merge, + type Observable, + of, + skip, + startWith, + tap, +} from 'rxjs' + +import {type DocumentPreviewStore} from '../../../../preview/documentPreviewStore' +import {type ReleasesReducerState} from '../../../store/reducer' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {getReleaseActivityEvents} from './getReleaseActivityEvents' +import {getReleaseEditEvents} from './getReleaseEditEvents' +import {isCreateReleaseEvent, isEventsAPIEvent, isTranslogEvent, type ReleaseEvent} from './types' + +interface getReleaseEventsOpts { + client: SanityClient + releaseId: string + releasesState$: Observable + documentPreviewStore: DocumentPreviewStore + eventsAPIEnabled: boolean +} + +export const EVENTS_INITIAL_VALUE = { + events: [], + hasMore: false, + error: null, + loading: true, +} + +const notEnabledActivityEvents: ReturnType = { + events$: of({ + events: [], + nextCursor: '', + loading: false, + error: null, + }), + reloadEvents: () => {}, + loadMore: () => {}, +} + +/** + * Combines activity and edit events for a release, and adds side effects for reloading events when the release or the document changes. + */ +export function getReleaseEvents({ + client, + releaseId, + releasesState$, + documentPreviewStore, + eventsAPIEnabled, +}: getReleaseEventsOpts) { + const activityEvents = eventsAPIEnabled + ? getReleaseActivityEvents({client, releaseId}) + : notEnabledActivityEvents + + const editEvents$ = getReleaseEditEvents({client, releaseId, releasesState$}) + + const releaseRev$ = releasesState$.pipe( + map((state) => state.releases.get(releaseId)?._rev), + filter(Boolean), + distinctUntilChanged(), + // Emit only when rev changes, after first non null value. + skip(1), + ) + + const groqFilter = `_id in path("versions.${getReleaseIdFromReleaseDocumentId(releaseId)}.*")` + const documentsCount$ = documentPreviewStore.unstable_observeDocumentIdSet(groqFilter).pipe( + filter(({status}) => status === 'connected'), + map(({documentIds}) => documentIds.length), + distinctUntilChanged(), + // Emit only when count changes, after first non null value. + skip(1), + ) + + const sideEffects$ = merge(releaseRev$, documentsCount$).pipe( + tap(() => { + activityEvents.reloadEvents() + }), + startWith(null), + ) + + const events$ = combineLatest([activityEvents.events$, editEvents$, sideEffects$]).pipe( + map(([activity, edit]) => { + const events = [...activity.events, ...edit.editEvents] + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + .reduce((acc: ReleaseEvent[], event) => { + if (isCreateReleaseEvent(event)) { + const creationEvent = acc.find(isCreateReleaseEvent) + if (!creationEvent) acc.push(event) + // Prefer the translog event for the creation given it has extra information. + else if (isEventsAPIEvent(creationEvent) && isTranslogEvent(event)) { + acc[acc.indexOf(creationEvent)] = event + } + } else acc.push(event) + return acc + }, []) + + return { + events, + hasMore: Boolean(activity.nextCursor), + error: activity.error || edit.error, + loading: activity.loading || edit.loading, + } + }), + ) + + return { + events$, + loadMore: activityEvents.loadMore, + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/types.ts b/packages/sanity/src/core/releases/tool/detail/events/types.ts new file mode 100644 index 00000000000..8656be6d9dd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/types.ts @@ -0,0 +1,105 @@ +import {type ReleaseType} from '../../../store' + +export type ReleaseEvent = + | CreateReleaseEvent + | ScheduleReleaseEvent + | UnscheduleReleaseEvent + | PublishReleaseEvent + | ArchiveReleaseEvent + | UnarchiveReleaseEvent + | AddDocumentToReleaseEvent + | DiscardDocumentFromReleaseEvent + | EditReleaseEvent + +export type EventType = ReleaseEvent['type'] + +export interface BaseEvent { + timestamp: string + author: string + releaseName: string + id: string // Added client side ${event.timestamp}-${event.type} + origin: 'translog' | 'events' // Added client side to identify from where the event was received +} + +export interface CreateReleaseEvent extends BaseEvent { + type: 'createRelease' + change?: Change +} + +export interface ScheduleReleaseEvent extends BaseEvent { + type: 'scheduleRelease' + publishAt: string +} + +export interface UnscheduleReleaseEvent extends BaseEvent { + type: 'unscheduleRelease' +} + +export interface PublishReleaseEvent extends BaseEvent { + type: 'publishRelease' +} + +export interface ArchiveReleaseEvent extends BaseEvent { + type: 'archiveRelease' +} + +export interface UnarchiveReleaseEvent extends BaseEvent { + type: 'unarchiveRelease' +} + +export interface AddDocumentToReleaseEvent extends BaseEvent { + type: 'addDocumentToRelease' + documentId: string + documentType: string + versionId: string + revisionId: string + versionRevisionId: string +} + +export interface DiscardDocumentFromReleaseEvent extends BaseEvent { + type: 'discardDocumentFromRelease' + documentId: string + documentType: string + versionId: string + versionRevisionId: string +} + +interface Change { + intendedPublishDate?: string + releaseType?: ReleaseType +} +export interface EditReleaseEvent extends BaseEvent { + type: 'editRelease' + isCreationEvent?: boolean + change: Change +} + +// Type guards +export const isCreateReleaseEvent = (event: ReleaseEvent): event is CreateReleaseEvent => + event.type === 'createRelease' +export const isScheduleReleaseEvent = (event: ReleaseEvent): event is ScheduleReleaseEvent => + event.type === 'scheduleRelease' +export const isUnscheduleReleaseEvent = (event: ReleaseEvent): event is UnscheduleReleaseEvent => + event.type === 'unscheduleRelease' +export const isPublishReleaseEvent = (event: ReleaseEvent): event is PublishReleaseEvent => + event.type === 'publishRelease' +export const isArchiveReleaseEvent = (event: ReleaseEvent): event is ArchiveReleaseEvent => + event.type === 'archiveRelease' +export const isUnarchiveReleaseEvent = (event: ReleaseEvent): event is UnarchiveReleaseEvent => + event.type === 'unarchiveRelease' +export const isAddDocumentToReleaseEvent = ( + event: ReleaseEvent, +): event is AddDocumentToReleaseEvent => event.type === 'addDocumentToRelease' +export const isDiscardDocumentFromReleaseEvent = ( + event: ReleaseEvent, +): event is DiscardDocumentFromReleaseEvent => event.type === 'discardDocumentFromRelease' +export const isEditReleaseEvent = (event: ReleaseEvent): event is EditReleaseEvent => + event.type === 'editRelease' + +export const isTranslogEvent = ( + event: ReleaseEvent, +): event is EditReleaseEvent | CreateReleaseEvent => event.origin === 'translog' + +export const isEventsAPIEvent = ( + event: ReleaseEvent, +): event is Exclude => event.origin === 'events' diff --git a/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts new file mode 100644 index 00000000000..597b82f2b04 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts @@ -0,0 +1,48 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {useClient} from '../../../../hooks/useClient' +import {useDocumentPreviewStore} from '../../../../store/_legacy/datastores' +import {useSource} from '../../../../studio/source' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../../studioClient' +import {useReleasesStore} from '../../../store/useReleasesStore' +import {getReleaseDocumentIdFromReleaseId} from '../../../util/getReleaseDocumentIdFromReleaseId' +import {EVENTS_INITIAL_VALUE, getReleaseEvents} from './getReleaseEvents' +import {type ReleaseEvent} from './types' + +export interface ReleaseEvents { + events: ReleaseEvent[] + loading: boolean + error: null | Error + loadMore: () => void + hasMore: boolean +} + +export function useReleaseEvents(releaseId: string): ReleaseEvents { + const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const documentPreviewStore = useDocumentPreviewStore() + const {state$: releasesState$} = useReleasesStore() + const source = useSource() + const eventsAPIEnabled = Boolean(source.beta?.eventsAPI?.enabled) + + const releaseEvents = useMemo( + () => + getReleaseEvents({ + client, + releaseId: getReleaseDocumentIdFromReleaseId(releaseId), + releasesState$, + documentPreviewStore, + eventsAPIEnabled, + }), + [releaseId, client, releasesState$, documentPreviewStore, eventsAPIEnabled], + ) + const events = useObservable(releaseEvents.events$, EVENTS_INITIAL_VALUE) + + return { + events: events.events, + hasMore: events.hasMore, + loading: events.loading, + error: events.error, + loadMore: releaseEvents.loadMore, + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.styled.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.styled.tsx new file mode 100644 index 00000000000..5b449781477 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.styled.tsx @@ -0,0 +1,94 @@ +import {Container} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {css, styled} from 'styled-components' + +export const ChangesWrapper = styled(Container)((props) => { + const theme = getTheme_v2(props.theme) + return css` + [data-ui='group-change-content'] { + // Hide the first grouping border border + &::before { + display: none; + } + [data-ui='group-change-list'] { + grid-gap: ${theme.space[6]}px; + } + + [data-ui='group-change-content'] { + // For inner groupings, show the border and reduce the gap + &::before { + display: block; + } + [data-ui='group-change-list'] { + grid-gap: ${theme.space[4]}px; + } + } + } + + [data-ui='field-diff-inspect-wrapper'] { + // Hide the border of the field diff wrapper + padding: 0; + padding-top: ${theme.space[2]}px; + &::before { + display: none; + } + } + ` +}) + +export const FieldWrapper = styled.div` + [data-changed] { + cursor: default; + } + + [data-diff-action='removed'] { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + [data-diff-action='added'] { + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + } + + [data-ui='diff-card'] { + cursor: default; + + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + &:has(del) { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + &[data-hover] { + &::after { + // Remove the hover effect for the cards + display: none; + } + } + } + + del[data-ui='diff-card'] { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + + ins[data-ui='diff-card'] { + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + } + + del { + text-decoration: none; + &:hover { + // Hides the border bottom added to the text differences when hovering + background-image: none; + } + } + ins { + &:hover { + // Hides the border bottom added to the text differences when hovering + background-image: none; + } + } +` diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx new file mode 100644 index 00000000000..3dc58e6e392 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx @@ -0,0 +1,77 @@ +import {diffInput, wrap} from '@sanity/diff' +import {type ObjectSchemaType, type SanityDocument} from '@sanity/types' +import {Text} from '@sanity/ui' +import {useMemo} from 'react' +import {DocumentChangeContext} from 'sanity/_singletons' + +import {buildChangeList} from '../../../../field/diff/changes/buildChangeList' +import {ChangeResolver} from '../../../../field/diff/components/ChangeResolver' +import {type ObjectDiff} from '../../../../field/types' +import {useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {ChangesWrapper, FieldWrapper} from './DocumentDiff.styled' + +const buildDocumentForDiffInput = (document: Partial) => { + // Remove internal fields and undefined values + const {_id, _rev, _createdAt, _updatedAt, _type, _version, ...rest} = JSON.parse( + JSON.stringify(document), + ) + + return rest +} + +/** + * Compares two documents with the same schema type. + * Showing the changes introduced by the document compared to the base document. + */ +export function DocumentDiff({ + baseDocument, + document, + schemaType, +}: { + baseDocument: SanityDocument | null + document: SanityDocument + schemaType: ObjectSchemaType +}) { + const {changesList, rootDiff} = useMemo(() => { + const diff = diffInput( + wrap(buildDocumentForDiffInput(baseDocument ?? {}), null), + wrap(buildDocumentForDiffInput(document), null), + ) as ObjectDiff + + if (!diff.isChanged) return {changesList: [], rootDiff: null} + const changeList = buildChangeList(schemaType, diff, [], [], {}) + return {changesList: changeList, rootDiff: diff} + }, [baseDocument, document, schemaType]) + const {t} = useTranslation(releasesLocaleNamespace) + + const isChanged = !!rootDiff?.isChanged + + if (!isChanged) { + return {t('diff.no-changes')} + } + + return ( + { + return {props.children} + }, + value: document, + showFromValue: !!baseDocument, + }} + > + + {changesList.length ? ( + changesList.map((change) => ) + ) : ( + {t('diff.list-empty')} + )} + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx new file mode 100644 index 00000000000..118cbebd5fa --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx @@ -0,0 +1,74 @@ +import {type ObjectSchemaType} from '@sanity/types' +import {Card, Flex} from '@sanity/ui' +import {memo} from 'react' + +import {LoadingBlock} from '../../../../components/loadingBlock/LoadingBlock' +import {useSchema} from '../../../../hooks/useSchema' +import {useObserveDocument} from '../../../../preview/useObserveDocument' +import {getPublishedId} from '../../../../util/draftUtils' +import {type DocumentHistory} from '../documentTable/useReleaseHistory' +import {DocumentReviewHeader} from '../review/DocumentReviewHeader' +import {type DocumentInRelease} from '../useBundleDocuments' +import {DocumentDiff} from './DocumentDiff' + +const DocumentDiffExpanded = memo( + function DocumentDiffExpanded({document}: {document: DocumentInRelease['document']}) { + const publishedId = getPublishedId(document._id) + + const schema = useSchema() + const schemaType = schema.get(document._type) as ObjectSchemaType + if (!schemaType) { + throw new Error(`Schema type "${document._type}" not found`) + } + + const {document: baseDocument, loading: baseDocumentLoading} = useObserveDocument(publishedId) + + if (baseDocumentLoading) return + + return + }, + (prev, next) => prev.document._rev === next.document._rev, +) + +export const DocumentDiffContainer = memo( + function DocumentDiffContainer({ + item, + history, + releaseSlug, + isExpanded, + toggleIsExpanded, + }: { + history?: DocumentHistory + releaseSlug: string + item: DocumentInRelease + isExpanded: boolean + toggleIsExpanded: () => void + }) { + return ( + + + {isExpanded && ( + + + + )} + + ) + }, + (prev, next) => { + return ( + prev.item.memoKey === next.item.memoKey && + prev.isExpanded === next.isExpanded && + prev.history?.lastEditedBy === next.history?.lastEditedBy + ) + }, +) diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx new file mode 100644 index 00000000000..5fc47d787d8 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx @@ -0,0 +1,106 @@ +import {ChevronDownIcon, ChevronRightIcon} from '@sanity/icons' +import {type PreviewValue, type SanityDocument} from '@sanity/types' +import {AvatarStack, Box, Card, Flex} from '@sanity/ui' + +import {Button} from '../../../../../ui-components' +import {RelativeTime} from '../../../../components/RelativeTime' +import {UserAvatar} from '../../../../components/userAvatar/UserAvatar' +import {Translate, useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {Chip} from '../../components/Chip' +import {ReleaseDocumentPreview} from '../../components/ReleaseDocumentPreview' +import {type DocumentValidationStatus} from '../useBundleDocuments' + +export function DocumentReviewHeader({ + previewValues, + document, + isLoading, + history, + releaseId, + validation, + isExpanded, + toggleIsExpanded, +}: { + document: SanityDocument + previewValues: PreviewValue + isLoading: boolean + releaseId: string + validation?: DocumentValidationStatus + isExpanded: boolean + toggleIsExpanded: () => void + history?: { + createdBy: string + lastEditedBy: string + editors: string[] + } +}) { + const {t} = useTranslation(releasesLocaleNamespace) + return ( + + +