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 de94856fa1f..792021474b2 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -19,7 +19,7 @@ "@portabletext/block-tools": "^1.1.3", "@portabletext/editor": "^1.26.3", "@portabletext/react": "^3.0.0", - "@sanity/assist": "^3.0.2", + "@sanity/assist": "^3.1.0", "@sanity/client": "^6.27.2", "@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.1.4", - "@sanity/react-loader": "^1.8.3", + "@sanity/react-loader": "^1.10.35", "@sanity/tsdoc": "1.0.169", "@sanity/types": "workspace:*", "@sanity/ui": "^2.11.7", diff --git a/dev/test-studio/preview/loader.tsx b/dev/test-studio/preview/loader.tsx index 0645b88a26f..c8c2989b317 100644 --- a/dev/test-studio/preview/loader.tsx +++ b/dev/test-studio/preview/loader.tsx @@ -6,7 +6,7 @@ const client = createClient({ projectId: 'ppsg7ml5', dataset: 'playground', useCdn: true, - apiVersion: '2023-02-06', + apiVersion: 'X', stega: { enabled: true, studioUrl: '/presentation', diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 273b3b42da4..5f9e1121add 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -225,6 +225,11 @@ export default defineConfig([ dataset: 'playground', plugins: [sharedSettings()], basePath: '/playground', + beta: { + eventsAPI: { + releases: true, + }, + }, }, { name: 'listener-events', diff --git a/packages/@sanity/types/src/reference/types.ts b/packages/@sanity/types/src/reference/types.ts index b2fbd1b5045..48fb99c24d0 100644 --- a/packages/@sanity/types/src/reference/types.ts +++ b/packages/@sanity/types/src/reference/types.ts @@ -30,6 +30,7 @@ export type ReferenceFilterSearchOptions = { tag?: string maxFieldDepth?: number strategy?: SearchStrategy + perspective?: string | string[] } /** @public */ 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/vision/package.json b/packages/@sanity/vision/package.json index 99c086f7681..619b195331c 100644 --- a/packages/@sanity/vision/package.json +++ b/packages/@sanity/vision/package.json @@ -70,7 +70,8 @@ "json5": "^2.2.3", "lodash": "^4.17.21", "quick-lru": "^5.1.1", - "react-compiler-runtime": "19.0.0-beta-27714ef-20250124" + "react-compiler-runtime": "19.0.0-beta-27714ef-20250124", + "react-fast-compare": "^3.2.2" }, "devDependencies": { "@repo/package.config": "workspace:*", diff --git a/packages/@sanity/vision/src/SanityVision.tsx b/packages/@sanity/vision/src/SanityVision.tsx index 545a01bc71f..d78399ea529 100644 --- a/packages/@sanity/vision/src/SanityVision.tsx +++ b/packages/@sanity/vision/src/SanityVision.tsx @@ -1,4 +1,4 @@ -import {type Tool, useClient} from 'sanity' +import {type Tool, useClient, usePerspective} from 'sanity' import {DEFAULT_API_VERSION} from './apiVersions' import {VisionContainer} from './containers/VisionContainer' @@ -11,6 +11,7 @@ interface SanityVisionProps { function SanityVision(props: SanityVisionProps) { const client = useClient({apiVersion: '1'}) + const perspective = usePerspective() const config: VisionConfig = { defaultApiVersion: DEFAULT_API_VERSION, ...props.tool.options, @@ -18,7 +19,7 @@ function SanityVision(props: SanityVisionProps) { return ( - + ) } diff --git a/packages/@sanity/vision/src/components/VisionGui.tsx b/packages/@sanity/vision/src/components/VisionGui.tsx index 7db5d1bf068..c63c9df2f3d 100644 --- a/packages/@sanity/vision/src/components/VisionGui.tsx +++ b/packages/@sanity/vision/src/components/VisionGui.tsx @@ -1,6 +1,11 @@ /* eslint-disable complexity */ import {SplitPane} from '@rexxars/react-split-pane' -import {type ListenEvent, type MutationEvent, type SanityClient} from '@sanity/client' +import { + type ClientPerspective, + type ListenEvent, + type MutationEvent, + type SanityClient, +} from '@sanity/client' import {CopyIcon, ErrorOutlineIcon, PlayIcon, StopIcon} from '@sanity/icons' import { Box, @@ -19,14 +24,23 @@ import { } from '@sanity/ui' import {isHotkey} from 'is-hotkey-esm' import {debounce} from 'lodash' -import {type ChangeEvent, createRef, PureComponent, type RefObject} from 'react' -import {type TFunction, Translate} from 'sanity' +import { + type ChangeEvent, + type ComponentType, + createRef, + PureComponent, + type RefObject, + useMemo, +} from 'react' +import isEqual from 'react-fast-compare' +import {type PerspectiveContextValue, type TFunction, Translate} from 'sanity' import {API_VERSIONS, DEFAULT_API_VERSION} from '../apiVersions' import {VisionCodeMirror} from '../codemirror/VisionCodeMirror' import { DEFAULT_PERSPECTIVE, isSupportedPerspective, + isVirtualPerspective, SUPPORTED_PERSPECTIVES, type SupportedPerspective, } from '../perspectives' @@ -192,6 +206,14 @@ export class VisionGui extends PureComponent { perspective = DEFAULT_PERSPECTIVE } + if (perspective == 'pinnedRelease' && !hasPinnedPerspective(this.props.pinnedPerspective)) { + perspective = DEFAULT_PERSPECTIVE + } + + if (perspective !== 'pinnedRelease' && hasPinnedPerspective(this.props.pinnedPerspective)) { + perspective = 'pinnedRelease' + } + if (typeof lastQuery !== 'string') { lastQuery = '' } @@ -209,7 +231,10 @@ export class VisionGui extends PureComponent { this._client = props.client.withConfig({ apiVersion: customApiVersion || apiVersion, dataset, - perspective: perspective, + perspective: getActivePerspective({ + visionPerspective: perspective, + pinnedPerspective: this.props.pinnedPerspective, + }), allowReconfigure: true, }) @@ -264,6 +289,7 @@ export class VisionGui extends PureComponent { this.handleKeyDown = this.handleKeyDown.bind(this) this.handleResize = this.handleResize.bind(this) this.handleOnPasteCapture = this.handleOnPasteCapture.bind(this) + this.setPerspective = this.setPerspective.bind(this) } componentDidMount() { @@ -280,6 +306,30 @@ export class VisionGui extends PureComponent { this.cancelResizeListener() } + componentDidUpdate(prevProps: Readonly): void { + if (hasPinnedPerspectiveChanged(prevProps.pinnedPerspective, this.props.pinnedPerspective)) { + if ( + this.state.perspective !== 'pinnedRelease' && + hasPinnedPerspective(this.props.pinnedPerspective) + ) { + this.setPerspective('pinnedRelease') + return + } + + if ( + this.state.perspective === 'pinnedRelease' && + !hasPinnedPerspective(this.props.pinnedPerspective) + ) { + this.setPerspective('raw') + return + } + + if (this.state.perspective === 'pinnedRelease') { + this.setPerspective('pinnedRelease') + } + } + } + handleResizeListen() { if (!this._visionRoot.current) { return @@ -338,11 +388,17 @@ export class VisionGui extends PureComponent { } } - const perspective = isSupportedPerspective(parts.options.perspective) - ? parts.options.perspective - : undefined - - if (perspective && !isSupportedPerspective(perspective)) { + const perspective = + isSupportedPerspective(parts.options.perspective) && + !isVirtualPerspective(parts.options.perspective) + ? parts.options.perspective + : undefined + + if ( + perspective && + (!isSupportedPerspective(parts.options.perspective) || + isVirtualPerspective(parts.options.perspective)) + ) { this.props.toast.push({ closable: true, id: 'vision-paste-unsupported-perspective', @@ -378,7 +434,10 @@ export class VisionGui extends PureComponent { this._client.config({ dataset: this.state.dataset, apiVersion: customApiVersion || apiVersion, - perspective: this.state.perspective, + perspective: getActivePerspective({ + visionPerspective: this.state.perspective, + pinnedPerspective: this.props.pinnedPerspective, + }), }) this.handleQueryExecution() this.props.toast.push({ @@ -399,7 +458,6 @@ export class VisionGui extends PureComponent { if (!this._querySubscription) { return } - this._querySubscription.unsubscribe() this._querySubscription = undefined } @@ -466,6 +524,10 @@ export class VisionGui extends PureComponent { handleChangePerspective(evt: ChangeEvent) { const perspective = evt.target.value + this.setPerspective(perspective) + } + + setPerspective(perspective: string): void { if (!isSupportedPerspective(perspective)) { return } @@ -473,7 +535,10 @@ export class VisionGui extends PureComponent { this.setState({perspective}, () => { this._localStorage.set('perspective', this.state.perspective) this._client.config({ - perspective: this.state.perspective, + perspective: getActivePerspective({ + visionPerspective: this.state.perspective, + pinnedPerspective: this.props.pinnedPerspective, + }), }) this.handleQueryExecution() }) @@ -598,9 +663,13 @@ export class VisionGui extends PureComponent { this.ensureSelectedApiVersion() - const urlQueryOpts: Record = {} + const urlQueryOpts: Record = {} if (this.state.perspective !== 'raw') { - urlQueryOpts.perspective = this.state.perspective + urlQueryOpts.perspective = + getActivePerspective({ + visionPerspective: this.state.perspective, + pinnedPerspective: this.props.pinnedPerspective, + }) ?? [] } const url = this._client.getUrl( @@ -613,20 +682,22 @@ export class VisionGui extends PureComponent { this._querySubscription = this._client.observable .fetch(query, params, {filterResponse: false, tag: 'vision'}) .subscribe({ - next: (res) => + next: (res) => { this.setState({ queryTime: res.ms, e2eTime: Date.now() - queryStart, queryResult: res.result, queryInProgress: false, error: undefined, - }), - error: (error) => + }) + }, + error: (error) => { this.setState({ error, query, queryInProgress: false, - }), + }) + }, }) return true @@ -670,7 +741,7 @@ export class VisionGui extends PureComponent { } render() { - const {datasets, t} = this.props + const {datasets, t, pinnedPerspective} = this.props const { apiVersion, customApiVersion, @@ -778,9 +849,21 @@ export class VisionGui extends PureComponent { @@ -1027,3 +1110,65 @@ export class VisionGui extends PureComponent { ) } } + +function getActivePerspective({ + visionPerspective, + pinnedPerspective, +}: { + visionPerspective: ClientPerspective | SupportedPerspective + pinnedPerspective: PerspectiveContextValue +}): ClientPerspective | undefined { + if (visionPerspective !== 'pinnedRelease') { + return visionPerspective + } + + if (pinnedPerspective.perspectiveStack.length !== 0) { + return pinnedPerspective.perspectiveStack + } + + if (typeof pinnedPerspective.selectedPerspectiveName !== 'undefined') { + return [pinnedPerspective.selectedPerspectiveName] + } + + return undefined +} + +const PinnedReleasePerspectiveOption: ComponentType<{ + pinnedPerspective: PerspectiveContextValue + t: TFunction +}> = ({pinnedPerspective, t}) => { + const name = + typeof pinnedPerspective.selectedPerspective === 'object' + ? pinnedPerspective.selectedPerspective.metadata.title + : pinnedPerspective.selectedPerspectiveName + + const label = hasPinnedPerspective(pinnedPerspective) + ? `(${t('settings.perspectives.pinned-release-label')})` + : t('settings.perspectives.pinned-release-label') + + const text = useMemo( + () => [name, label].filter((value) => typeof value !== 'undefined').join(' '), + [label, name], + ) + + return ( + + ) +} + +function hasPinnedPerspective({selectedPerspectiveName}: PerspectiveContextValue): boolean { + return typeof selectedPerspectiveName !== 'undefined' +} + +function hasPinnedPerspectiveChanged( + previous: PerspectiveContextValue, + next: PerspectiveContextValue, +): boolean { + const hasPerspectiveStackChanged = !isEqual(previous.perspectiveStack, next.perspectiveStack) + + return ( + previous.selectedPerspectiveName !== next.selectedPerspectiveName || hasPerspectiveStackChanged + ) +} diff --git a/packages/@sanity/vision/src/i18n/resources.ts b/packages/@sanity/vision/src/i18n/resources.ts index e28df6fee7f..e1281aea726 100644 --- a/packages/@sanity/vision/src/i18n/resources.ts +++ b/packages/@sanity/vision/src/i18n/resources.ts @@ -74,6 +74,8 @@ const visionLocaleStrings = defineLocalesResources('vision', { /** Description for popover that explains what "Perspectives" are */ 'settings.perspectives.description': 'Perspectives allow your query to run against different "views" of the content in your dataset', + /** Label for the pinned release perspective */ + 'settings.perspectives.pinned-release-label': 'pinned release', /** Title for popover that explains what "Perspectives" are */ 'settings.perspectives.title': 'Perspectives', } as const) diff --git a/packages/@sanity/vision/src/perspectives.ts b/packages/@sanity/vision/src/perspectives.ts index 7993a2f49b0..472422511a4 100644 --- a/packages/@sanity/vision/src/perspectives.ts +++ b/packages/@sanity/vision/src/perspectives.ts @@ -1,15 +1,36 @@ -import {type ClientPerspective} from '@sanity/client' - -export type SupportedPerspective = 'raw' | 'previewDrafts' | 'published' | 'drafts' - export const SUPPORTED_PERSPECTIVES = [ + 'pinnedRelease', 'raw', 'previewDrafts', 'published', 'drafts', -] satisfies ClientPerspective[] -export const DEFAULT_PERSPECTIVE = SUPPORTED_PERSPECTIVES[0] +] as const + +export type SupportedPerspective = (typeof SUPPORTED_PERSPECTIVES)[number] + +/** + * Virtual perspectives are recognised by Vision, but do not concretely reflect the names of real + * perspectives. Virtual perspectives are transformed into real perspectives before being used to + * interact with data. + * + * For example, the `pinnedRelease` virtual perspective is transformed to the real perspective + * currently pinned in Studio. + */ +export const VIRTUAL_PERSPECTIVES = ['pinnedRelease'] as const + +export type VirtualPerspective = (typeof VIRTUAL_PERSPECTIVES)[number] + +export const DEFAULT_PERSPECTIVE: SupportedPerspective = 'raw' export function isSupportedPerspective(p: string): p is SupportedPerspective { return SUPPORTED_PERSPECTIVES.includes(p as SupportedPerspective) } + +export function isVirtualPerspective( + maybeVirtualPerspective: unknown, +): maybeVirtualPerspective is VirtualPerspective { + return ( + typeof maybeVirtualPerspective === 'string' && + VIRTUAL_PERSPECTIVES.includes(maybeVirtualPerspective as VirtualPerspective) + ) +} diff --git a/packages/@sanity/vision/src/types.ts b/packages/@sanity/vision/src/types.ts index 5484086acdb..86fa89b1755 100644 --- a/packages/@sanity/vision/src/types.ts +++ b/packages/@sanity/vision/src/types.ts @@ -1,9 +1,11 @@ import {type SanityClient} from '@sanity/client' import {type ComponentType} from 'react' +import {type PerspectiveContextValue} from 'sanity' export interface VisionProps { client: SanityClient config: VisionConfig + pinnedPerspective: PerspectiveContextValue } export interface VisionConfig { diff --git a/packages/@sanity/vision/src/util/encodeQueryString.ts b/packages/@sanity/vision/src/util/encodeQueryString.ts index 3aa6332232f..43a578b9aa8 100644 --- a/packages/@sanity/vision/src/util/encodeQueryString.ts +++ b/packages/@sanity/vision/src/util/encodeQueryString.ts @@ -1,7 +1,7 @@ export function encodeQueryString( query: string, params: Record = {}, - options: Record = {}, + options: Record = {}, ): string { const searchParams = new URLSearchParams() searchParams.set('query', query) diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx index ce6896dc295..00d9e91ac1a 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx @@ -8,6 +8,7 @@ import { ColorSchemeProvider, CopyPasteProvider, defineConfig, + EMPTY_ARRAY, ResourceCacheProvider, type SchemaTypeDefinition, SourceProvider, @@ -19,6 +20,7 @@ import { import {Pane, PaneContent, PaneLayout} from 'sanity/structure' import {styled} from 'styled-components' +import {PerspectiveProvider} from '../../../../src/core/perspective/PerspectiveProvider' import {route} from '../../../../src/router' import {RouterProvider} from '../../../../src/router/RouterProvider' import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient' @@ -92,13 +94,18 @@ export const TestWrapper = (props: TestWrapperProps): React.JSX.Element | null = onOpenReviewChanges={() => {}} onSetFocus={() => {}} > - - - - {children} - - - + + + + + {children} + + + + 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/PerspectiveContext.ts b/packages/sanity/src/_singletons/context/PerspectiveContext.ts new file mode 100644 index 00000000000..77a2d7584c1 --- /dev/null +++ b/packages/sanity/src/_singletons/context/PerspectiveContext.ts @@ -0,0 +1,13 @@ +import {createContext} from 'sanity/_createContext' + +import type {PerspectiveContextValue} from '../../core/perspective/types' + +/** + * + * @hidden + * @beta + */ +export const PerspectiveContext = createContext( + 'sanity/_singletons/context/perspective-context', + 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..3f9b92665e6 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' @@ -36,6 +37,7 @@ export * from './context/NavbarContext' export * from './context/PaneContext' export * from './context/PaneLayoutContext' export * from './context/PaneRouterContext' +export * from './context/PerspectiveContext' export * from './context/PortableTextMarkersContext' export * from './context/PortableTextMemberItemElementRefsContext' export * from './context/PortableTextMemberItemsContext' @@ -51,6 +53,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 0168361a60b..55a36d04120 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' @@ -80,21 +82,24 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr selectedCommentId, isConnecting, onPathOpen, + releaseId, mentionsDisabled, } = 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]) @@ -115,7 +120,8 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr error, loading, } = useCommentsStore({ - documentId: publishedId, + documentId, + releaseId, client, transactionsIdMap, onLatestTransactionIdReceived: handleOnLatestTransactionIdReceived, @@ -232,7 +238,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - documentId: publishedId, + documentId: versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -260,7 +266,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - publishedId, + versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -280,7 +286,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr const ctxValue = useMemo( (): CommentsContextValue => ({ - documentId, + documentId: versionOrPublishedId, documentType, isCreatingDataset, @@ -316,7 +322,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr }, }), [ - 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 0796636191d..48989dd987d 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 { @@ -57,7 +63,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) @@ -65,7 +71,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..44d1946a89f 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, + useActiveReleases, +} 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,88 @@ const StyledText = styled(Text)` * * @internal */ -export function DocumentStatus({absoluteDate, draft, published, singleLine}: DocumentStatusProps) { +export function DocumentStatus({draft, published, versions, singleLine}: DocumentStatusProps) { + const {data: releases} = useActiveReleases() + 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..e32789e424d 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 {useActiveReleases} from '../../releases/store/useActiveReleases' +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} = useActiveReleases() + 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..c5bbb158c53 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -364,6 +364,37 @@ export const internalTasksReducer = (opts: { return result } +export const eventsAPIReducer = (opts: { + config: PluginOptions + initialValue: boolean + key: 'releases' | 'documents' +}): boolean => { + const {config, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + const result = flattenedConfig.reduce((acc: boolean, {config: innerConfig}) => { + // @ts-expect-error enabled is a legacy option we want to warn beta testers in case they have enabled it. + if (innerConfig.beta?.eventsAPI?.enabled) { + throw new Error( + `The \`beta.eventsAPI.enabled\` option has been removed. Use \`beta.eventsAPI.${opts.key}\` instead.`, + ) + } + + const enabled = innerConfig.beta?.eventsAPI?.[opts.key] + + if (typeof enabled === 'undefined') return acc + if (typeof enabled === 'boolean') return enabled + + throw new Error( + `Expected \`beta.eventsAPI.${opts.key}\` 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..e673140baed 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,10 @@ function resolveSource({ }, beta: { + eventsAPI: { + documents: eventsAPIReducer({config, initialValue: true, key: 'documents'}), + releases: eventsAPIReducer({config, initialValue: false, key: 'releases'}), + }, 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..f9a4bb7f7be 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 } /** @@ -810,6 +826,9 @@ export interface Source { /** @beta */ tasks?: WorkspaceOptions['tasks'] + /** @beta */ + releases?: WorkspaceOptions['releases'] + /** @internal */ __internal_serverDocumentActions?: WorkspaceOptions['__internal_serverDocumentActions'] /** Configuration for studio features. @@ -937,6 +956,7 @@ export type { export type DefaultPluginsWorkspaceOptions = { tasks: {enabled: boolean} scheduledPublishing: ScheduledPublishingPluginOptions + releases: {enabled: boolean} } /** @@ -992,4 +1012,15 @@ 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?: { + documents?: boolean + releases?: boolean + } } diff --git a/packages/sanity/src/core/create/components/StartInCreateBanner.tsx b/packages/sanity/src/core/create/components/StartInCreateBanner.tsx index e8a5b4042bd..3a319fe2509 100644 --- a/packages/sanity/src/core/create/components/StartInCreateBanner.tsx +++ b/packages/sanity/src/core/create/components/StartInCreateBanner.tsx @@ -15,6 +15,7 @@ import {useCallback, useState} from 'react' import {TextWithTone} from '../../components/textWithTone/TextWithTone' import {isDev} from '../../environment' import {useTranslation} from '../../i18n' +import {usePerspective} from '../../perspective/usePerspective' import {useWorkspace} from '../../studio' import {useSanityCreateConfig} from '../context' import {getCreateLinkUrl} from '../createDocumentUrls' @@ -33,12 +34,16 @@ import {StartInCreateDevInfoButton} from './StartInCreateDevInfoButton' export function StartInCreateBanner(props: StartInCreateBannerProps) { const {document, isInitialValueLoading} = props const {appIdCache, startInCreateEnabled} = useSanityCreateConfig() - + const {selectedPerspectiveName} = usePerspective() const isExcludedByOption = isSanityCreateExcludedType(props.documentType) const isNewPristineDoc = !document._createdAt const isStartCreateCompatible = isSanityCreateStartCompatibleDoc(props.document) + const liveEdit = Boolean(props.documentType?.liveEdit) + + const excludeOnPublished = selectedPerspectiveName === 'published' && !liveEdit if ( + excludeOnPublished || !isNewPristineDoc || !startInCreateEnabled || isExcludedByOption || 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/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..634becbd6d0 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -17,6 +17,7 @@ import {MenuButton, MenuItem, TooltipDelayGroupProvider} from '../../../../ui-co import {ContextMenuButton} from '../../../components/contextMenuButton' import {type DocumentFieldActionNode} from '../../../config' import {useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' import {EMPTY_ARRAY} from '../../../util/empty' import {FormField} from '../../components' import {usePublishedId} from '../../contexts/DocumentIdProvider' @@ -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 {selectedReleaseId} = usePerspective() const [fieldActionsNodes, setFieldActionNodes] = useState([]) const documentId = usePublishedId() @@ -74,6 +76,7 @@ export function ReferenceField(props: ReferenceFieldProps) { path, schemaType, value, + version: selectedReleaseId, }) // 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..ef7639bb3dc 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx @@ -8,6 +8,7 @@ import {catchError, filter, map, scan, switchMap, tap} from 'rxjs/operators' import {Button} from '../../../../ui-components' import {ReferenceInputPreviewCard} from '../../../components' import {Translate, useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' import {getPublishedId, isNonNullable} from '../../../util' import {Alert} from '../../components/Alert' import {useDidUpdate} from '../../hooks/useDidUpdate' @@ -52,16 +53,19 @@ export function ReferenceInput(props: ReferenceInputProps) { id, onPathFocus, value, + version, renderPreview, path, elementProps, focusPath, } = props + const {selectedReleaseId} = usePerspective() const {getReferenceInfo} = useReferenceInput({ path, schemaType, value, + version, }) const [searchState, setSearchState] = useState(INITIAL_SEARCH_STATE) @@ -82,10 +86,15 @@ export function ReferenceInput(props: ReferenceInputProps) { onChange(patches) - onEditReference({id: newDocumentId, type: option.type, template: option.template}) + onEditReference({ + id: newDocumentId, + type: option.type, + template: option.template, + version: selectedReleaseId, + }) onPathFocus([]) }, - [onChange, onEditReference, onPathFocus, schemaType], + [onChange, onEditReference, onPathFocus, schemaType.name, schemaType.weak, selectedReleaseId], ) const handleChange = useCallback( @@ -206,6 +215,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 +223,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..c4ca3edfd05 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts @@ -1,3 +1,4 @@ +import {type ReleaseId} from '@sanity/client' import { type I18nTextRecord, type Path, @@ -9,6 +10,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 +26,8 @@ export interface ReferenceInfo { preview: { draft: PreviewDocumentValue | undefined published: PreviewDocumentValue | undefined + version: PreviewDocumentValue | undefined + versions: VersionsRecord } } @@ -36,6 +40,7 @@ export interface EditReferenceEvent { id: string type: string template: ReferenceTemplate + version?: ReleaseId } export interface CreateReferenceOption { @@ -82,4 +87,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..e4efc7997f0 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -12,6 +12,9 @@ import { import {type FIXME} from '../../../FIXME' import {useSchema} from '../../../hooks' +import {usePerspective} from '../../../perspective/usePerspective' +import {useActiveReleases} from '../../../releases/store/useActiveReleases' +import {useReleasesIds} from '../../../releases/store/useReleasesIds' import {useDocumentPreviewStore} from '../../../store' import {isNonNullable} from '../../../util' import {useFormValue} from '../../contexts/FormValue' @@ -31,11 +34,15 @@ 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 {data} = useActiveReleases() + const {releasesIds} = useReleasesIds(data) const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -116,8 +123,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: releasesIds, + bundleStack: perspective.perspectiveStack, + }, + ), + [documentPreviewStore, schemaType, version, releasesIds, perspective.perspectiveStack], ) return { diff --git a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx b/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx index 7f0ae49f246..17ce9d60824 100644 --- a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx +++ b/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx @@ -1,3 +1,4 @@ +import {type ReleaseId} from '@sanity/client' import {type Path} from '@sanity/types' import {type ComponentType, type HTMLProps, type ReactNode, useContext, useMemo} from 'react' import {ReferenceInputOptionsContext} from 'sanity/_singletons' @@ -18,6 +19,7 @@ export interface EditReferenceOptions { type: string parentRefPath: Path template: TemplateOption + version?: ReleaseId } /** @internal */ 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..58bd03d4b39 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,25 @@ 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 PerspectiveStack} from '../../../../perspective/types' +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 +48,34 @@ export function getReferenceInfo( documentPreviewStore: DocumentPreviewStore, id: string, referenceType: ReferenceSchemaType, + {version}: {version?: string} = {}, + perspective: {bundleIds: string[]; bundleStack: PerspectiveStack} = { + 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.version?.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 +83,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -65,9 +92,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 +115,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -99,10 +132,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 +148,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 +216,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 +230,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.snapshot : undefined) as | PreviewDocumentValue | undefined, + published: (isRecord(value.published.snapshot) + ? value.published.snapshot + : undefined) as PreviewDocumentValue | undefined, + version: (isRecord(value.version?.snapshot) + ? value.version.snapshot + : undefined) as PreviewDocumentValue | undefined, + + versions: isRecord(value.versions) ? value.versions : {}, }, } }), @@ -186,6 +288,13 @@ export function referenceSearch( }) return search(textTerm, {includeDrafts: true}).pipe( map(({hits}) => hits.map(({hit}) => hit)), + map((docs) => + docs.map((doc) => ({ + ...doc, + // Pass the original id if available, it could be a `draftId` or a `versionId` , the _id will be the published one when using perspectives to query the data. + _id: (doc._originalId as string) || doc._id, + })), + ), map((docs) => collate(docs)), // pick the 100 best matches map((collated) => collated.slice(0, 100)), 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..75be2e92bdf 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -14,6 +14,7 @@ import {catchError, mergeMap} from 'rxjs/operators' import {type FIXME} from '../../../../FIXME' import {useSchema} from '../../../../hooks' +import {usePerspective} from '../../../../perspective/usePerspective' import {useDocumentPreviewStore} from '../../../../store' import {useSource} from '../../../../studio' import {useSearchMaxFieldDepth} from '../../../../studio/components/navbar/search/hooks/useSearchMaxFieldDepth' @@ -61,9 +62,11 @@ type SearchError = { export function StudioReferenceInput(props: StudioReferenceInputProps) { const source = useSource() const searchClient = source.getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const {perspectiveStack} = usePerspective() const schema = useSchema() const maxFieldDepth = useSearchMaxFieldDepth() const documentPreviewStore = useDocumentPreviewStore() + const {selectedReleaseId} = usePerspective() const {path, schemaType} = props const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -90,6 +93,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { tag: 'search.reference', maxFieldDepth, strategy: searchStrategy, + perspective: perspectiveStack, }), ), @@ -102,7 +106,16 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { }), ), - [schemaType, documentRef, path, getClient, searchClient, maxFieldDepth, searchStrategy], + [ + schemaType, + documentRef, + path, + getClient, + searchClient, + maxFieldDepth, + searchStrategy, + perspectiveStack, + ], ) const template = props.value?._strengthenOnPublish?.template @@ -131,6 +144,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { id: event.id, type: event.type, template: event.template, + version: event.version, }) }, [onEditReference, path], @@ -190,6 +204,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { editReferenceLinkComponent={EditReferenceLink} createOptions={createOptions} onEditReference={handleEditReference} + version={selectedReleaseId} /> ) } 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..6d050f35b6c 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,96 @@ 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 Draft chip in global header */ + 'release.chip.global.drafts': 'Drafts', + /** 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 */ @@ -1609,6 +1718,12 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Title for error when the timeline for the given document can't be loaded */ 'timeline.error.load-document-changes-title': 'An error occurred whilst retrieving document changes.', + /** Description for error when the timeline for the given document can't be loaded */ + 'timeline.error.load-document-changes-version-description': + 'Enable the events API through the Studio config to view document history.', + /** Title for error when the timeline for the given version document can't be loaded */ + 'timeline.error.load-document-changes-version-title': + 'Version documents history is only available through the Events API.', /** Error description for when the document doesn't have history */ 'timeline.error.no-document-history-description': 'When changing the content of the document, the document versions will appear in this menu.', @@ -1630,6 +1745,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 945e7796488..08616edebe0 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -11,12 +11,44 @@ export * from './FIXME' export * from './form' export * from './hooks' export * from './i18n' +export {PerspectiveProvider} from './perspective/PerspectiveProvider' +export { + type PerspectiveContextValue, + type PerspectiveStack, + type SelectedPerspective, +} from './perspective/types' +export {useExcludedPerspective} from './perspective/useExcludedPerspective' +export {usePerspective} from './perspective/usePerspective' +export {useSetPerspective} from './perspective/useSetPerspective' export * from './presence' export * from './preview' +export { + AddedVersion, + DiscardVersionDialog, + formatRelativeLocalePublishDate, + getPublishDateFromRelease, + getReleaseIdFromReleaseDocumentId, + getReleaseTone, + isDraftPerspective, + isPublishedPerspective, + isReleaseDocument, + isReleaseScheduledOrScheduling, + LATEST, + type ReleaseDocument, + RELEASES_INTENT, + useActiveReleases, + useArchivedReleases, + useDocumentVersions, + useIsReleaseActive, + useReleasesIds, + useVersionOperations, + VersionChip, + 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/perspective/GlobalPerspectiveProvider.tsx b/packages/sanity/src/core/perspective/GlobalPerspectiveProvider.tsx new file mode 100644 index 00000000000..d2efb0f2c7b --- /dev/null +++ b/packages/sanity/src/core/perspective/GlobalPerspectiveProvider.tsx @@ -0,0 +1,102 @@ +import {type ReleaseId} from '@sanity/client' +import {Text, useToast} from '@sanity/ui' +import {type ReactNode, useEffect, useMemo} from 'react' +import {useRouter} from 'sanity/router' + +import {useTranslation} from '../i18n/hooks/useTranslation' +import {Translate} from '../i18n/Translate' +import {useActiveReleases} from '../releases/store/useActiveReleases' +import {useArchivedReleases} from '../releases/store/useArchivedReleases' +import {LATEST} from '../releases/util/const' +import {getReleaseIdFromReleaseDocumentId} from '../releases/util/getReleaseIdFromReleaseDocumentId' +import {isPublishedPerspective} from '../releases/util/util' +import {EMPTY_ARRAY} from '../util/empty' +import {PerspectiveProvider} from './PerspectiveProvider' +import {usePerspective} from './usePerspective' +import {useSetPerspective} from './useSetPerspective' + +const ResetPerspectiveHandler = () => { + const toast = useToast() + const {t} = useTranslation() + const {data: releases, loading: releasesLoading} = useActiveReleases() + const {data: archivedReleases} = useArchivedReleases() + const {selectedPerspectiveName} = usePerspective() + const setPerspective = useSetPerspective() + + useEffect(() => { + // clear the perspective param when it is not an active release + if ( + releasesLoading || + !selectedPerspectiveName || + isPublishedPerspective(selectedPerspectiveName) + ) + return + const isCurrentPerspectiveValid = releases.some( + (release) => getReleaseIdFromReleaseDocumentId(release._id) === selectedPerspectiveName, + ) + if (!isCurrentPerspectiveValid) { + setPerspective(LATEST) + const archived = archivedReleases.find( + (r) => getReleaseIdFromReleaseDocumentId(r._id) === selectedPerspectiveName, + ) + + toast.push({ + id: `bundle-deleted-toast-${selectedPerspectiveName}`, + status: 'warning', + title: ( + + + + ), + duration: 10000, + }) + } + }, [ + archivedReleases, + selectedPerspectiveName, + releases, + releasesLoading, + setPerspective, + toast, + t, + ]) + return null +} + +/** + * This component is not meant to be exported by `sanity`, it's meant only for internal use from the `` file. + * It sets the `` listening to the changes happening in the router. + * + * If you need to add the PerspectiveProvider you should use that component directly. + * It's up to you to define how the selectedPerspectiveName and excludedPerspectives should worl. + */ +export function GlobalPerspectiveProvider({children}: {children: ReactNode}) { + const router = useRouter() + + const selectedPerspectiveName = router.stickyParams.perspective as + | 'published' + | ReleaseId + | undefined + + const excludedPerspectives = useMemo( + () => router.stickyParams.excludedPerspectives?.split(',') || EMPTY_ARRAY, + [router.stickyParams.excludedPerspectives], + ) + return ( + + {children} + + + ) +} diff --git a/packages/sanity/src/core/perspective/PerspectiveProvider.tsx b/packages/sanity/src/core/perspective/PerspectiveProvider.tsx new file mode 100644 index 00000000000..315bff9515e --- /dev/null +++ b/packages/sanity/src/core/perspective/PerspectiveProvider.tsx @@ -0,0 +1,56 @@ +import {type ReleaseId} from '@sanity/client' +import {useMemo} from 'react' +import {PerspectiveContext} from 'sanity/_singletons' + +import {getReleasesPerspectiveStack} from '../releases/hooks/utils' +import {useActiveReleases} from '../releases/store/useActiveReleases' +import {getReleaseIdFromReleaseDocumentId} from '../releases/util/getReleaseIdFromReleaseDocumentId' +import {EMPTY_ARRAY} from '../util/empty' +import {type PerspectiveContextValue, type SelectedPerspective} from './types' + +/** + * @internal + */ +export function PerspectiveProvider({ + children, + selectedPerspectiveName, + excludedPerspectives = EMPTY_ARRAY, +}: { + children: React.ReactNode + selectedPerspectiveName: 'published' | ReleaseId | undefined + excludedPerspectives?: string[] +}) { + const {data: releases} = useActiveReleases() + + const selectedPerspective: SelectedPerspective = useMemo(() => { + if (!selectedPerspectiveName) return 'drafts' + if (selectedPerspectiveName === 'published') return 'published' + const selectedRelease = releases.find( + (release) => getReleaseIdFromReleaseDocumentId(release._id) === selectedPerspectiveName, + ) + return selectedRelease || 'drafts' + }, [selectedPerspectiveName, releases]) + + const perspectiveStack = useMemo( + () => + getReleasesPerspectiveStack({ + releases, + selectedPerspectiveName, + excludedPerspectives, + }), + [releases, selectedPerspectiveName, excludedPerspectives], + ) + + const value: PerspectiveContextValue = useMemo( + () => ({ + selectedPerspective, + selectedPerspectiveName, + selectedReleaseId: + selectedPerspectiveName === 'published' ? undefined : selectedPerspectiveName, + perspectiveStack, + excludedPerspectives, + }), + [selectedPerspective, selectedPerspectiveName, perspectiveStack, excludedPerspectives], + ) + return {children} +} diff --git a/packages/sanity/src/core/perspective/ReleasesToolLink.tsx b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx new file mode 100644 index 00000000000..b4f5a213631 --- /dev/null +++ b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx @@ -0,0 +1,43 @@ +import {CalendarIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button +import {Box, Button} from '@sanity/ui' +import {useCallback} from 'react' +import {useTranslation} from 'react-i18next' +import {useRouterState} from 'sanity/router' + +import {Tooltip} from '../../ui-components/tooltip/Tooltip' +import {RELEASES_TOOL_NAME} from '../releases/plugin' +import {ToolLink} from '../studio/components/navbar/tools/ToolLink' + +/** + * represents the calendar icon for the releases tool. + * It will be hidden if users have turned off releases. + */ +export function ReleasesToolLink(): React.JSX.Element { + const {t} = useTranslation() + + const activeToolName = useRouterState( + useCallback( + (routerState) => (typeof routerState.tool === 'string' ? routerState.tool : undefined), + [], + ), + ) + + return ( + + + + ) + } + + return {visibleLabelChildren()} +} diff --git a/packages/sanity/src/core/perspective/navbar/useScrollIndicatorVisibility.ts b/packages/sanity/src/core/perspective/navbar/useScrollIndicatorVisibility.ts new file mode 100644 index 00000000000..93bada9ef55 --- /dev/null +++ b/packages/sanity/src/core/perspective/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/perspective/types.ts b/packages/sanity/src/core/perspective/types.ts new file mode 100644 index 00000000000..64cae70473c --- /dev/null +++ b/packages/sanity/src/core/perspective/types.ts @@ -0,0 +1,36 @@ +import {type ClientPerspective, type ReleaseId} from '@sanity/client' + +import {type ReleaseDocument} from '../releases/store/types' + +/** + * @internal + */ +export type SelectedPerspective = ReleaseDocument | 'published' | 'drafts' + +/** + * @internal + */ +export type PerspectiveStack = ExtractArray + +/** + * @internal + */ +export interface PerspectiveContextValue { + /* The selected perspective name, it could be a release or Published */ + selectedPerspectiveName: 'published' | ReleaseId | undefined + /** + * The releaseId as r; it will be undefined if the selected perspective is `published` or `drafts` + */ + selectedReleaseId: ReleaseId | undefined + + /* Return the current global release */ + selectedPerspective: SelectedPerspective + /** + * The stacked array of releases ids ordered chronologically to represent the state of documents at the given point in time. + */ + perspectiveStack: PerspectiveStack + /* The excluded perspectives */ + excludedPerspectives: string[] +} + +type ExtractArray = Union extends unknown[] ? Union : never diff --git a/packages/sanity/src/core/perspective/useExcludedPerspective.tsx b/packages/sanity/src/core/perspective/useExcludedPerspective.tsx new file mode 100644 index 00000000000..3c268a06446 --- /dev/null +++ b/packages/sanity/src/core/perspective/useExcludedPerspective.tsx @@ -0,0 +1,46 @@ +import {useCallback, useMemo} from 'react' +import {useRouter} from 'sanity/router' + +import {usePerspective} from './usePerspective' + +export interface ExcludedPerspectiveValue { + /* The excluded perspectives */ + excludedPerspectives: string[] + /* Add/remove excluded perspectives */ + toggleExcludedPerspective: (perspectiveId: string) => void + /* Check if a perspective is excluded */ + isPerspectiveExcluded: (perspectiveId: string) => boolean +} + +/** + * Gets the excluded perspectives. + + * @internal + */ +export function useExcludedPerspective(): ExcludedPerspectiveValue { + const {navigateStickyParams} = useRouter() + const {excludedPerspectives} = usePerspective() + + const toggleExcludedPerspective = useCallback( + (excluded: string) => { + const existingPerspectives = excludedPerspectives || [] + + const nextExcludedPerspectives = existingPerspectives.includes(excluded) + ? existingPerspectives.filter((id) => id !== excluded) + : [...existingPerspectives, excluded] + + navigateStickyParams({excludedPerspectives: nextExcludedPerspectives.toString()}) + }, + [excludedPerspectives, navigateStickyParams], + ) + + const isPerspectiveExcluded = useCallback( + (perspectiveId: string) => Boolean(excludedPerspectives?.includes(perspectiveId)), + [excludedPerspectives], + ) + + return useMemo( + () => ({excludedPerspectives, toggleExcludedPerspective, isPerspectiveExcluded}), + [excludedPerspectives, toggleExcludedPerspective, isPerspectiveExcluded], + ) +} diff --git a/packages/sanity/src/core/perspective/usePerspective.ts b/packages/sanity/src/core/perspective/usePerspective.ts new file mode 100644 index 00000000000..f3bc90c201d --- /dev/null +++ b/packages/sanity/src/core/perspective/usePerspective.ts @@ -0,0 +1,15 @@ +import {useContext} from 'react' +import {PerspectiveContext} from 'sanity/_singletons' + +import {type PerspectiveContextValue} from './types' + +/** + * @internal + */ +export function usePerspective(): PerspectiveContextValue { + const context = useContext(PerspectiveContext) + if (!context) { + throw new Error('usePerspective must be used within a PerspectiveProvider') + } + return context +} diff --git a/packages/sanity/src/core/perspective/useSetPerspective.tsx b/packages/sanity/src/core/perspective/useSetPerspective.tsx new file mode 100644 index 00000000000..9d4fe8e7005 --- /dev/null +++ b/packages/sanity/src/core/perspective/useSetPerspective.tsx @@ -0,0 +1,30 @@ +import {type ReleaseId} from '@sanity/client' +import {useCallback} from 'react' +import {useRouter} from 'sanity/router' + +/** + * @internal + */ +export function useSetPerspective() { + const router = useRouter() + const setPerspective = useCallback( + (releaseId: 'published' | 'drafts' | ReleaseId | undefined) => { + let perspectiveParam = '' + + if (!releaseId || releaseId === 'drafts') { + perspectiveParam = '' + } else if (releaseId === 'published' || releaseId.startsWith('r')) { + perspectiveParam = releaseId + } else { + throw new Error(`Invalid releaseId: ${releaseId}`) + } + + router.navigateStickyParams({ + excludedPerspectives: '', + perspective: perspectiveParam, + }) + }, + [router], + ) + return setPerspective +} diff --git a/packages/sanity/src/core/preview/availability.ts b/packages/sanity/src/core/preview/availability.ts index 52131be2c7f..f79ce5cb254 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} 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 = 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/documentPair.ts b/packages/sanity/src/core/preview/documentPair.ts index dae8945130e..670ba93e141 100644 --- a/packages/sanity/src/core/preview/documentPair.ts +++ b/packages/sanity/src/core/preview/documentPair.ts @@ -24,12 +24,17 @@ export function createObservePathsDocumentPair(options: { 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) { + if ( + !availability.draft.available && + !availability.published.available && + !availability.version?.available + ) { // short circuit, neither draft nor published is available so no point in trying to get a snapshot return of({ id: publishedId, @@ -42,6 +47,14 @@ export function createObservePathsDocumentPair(options: { availability: availability.published, snapshot: undefined, }, + ...(availability.version + ? { + version: { + availability: availability.version, + snapshot: undefined, + }, + } + : {}), }) } @@ -50,10 +63,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 +86,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 245dbf44eca..9ee22381990 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -57,11 +57,13 @@ export interface DocumentPreviewStore { */ unstable_observeDocumentPairAvailability: ( id: string, + options?: {version?: string}, ) => Observable unstable_observePathsDocumentPair: ( id: string, paths: PreviewPath[], + options?: {version?: string}, ) => Observable> /** @@ -137,7 +139,10 @@ export function createDocumentPreviewStore({ ) } - const observeDocumentIdSet = createDocumentIdSetObserver(versionedClient) + const observeDocumentIdSet = createDocumentIdSetObserver( + // TODO: COREL - Replace once releases API are stable. + versionedClient.withConfig({apiVersion: 'X'}), + ) const observeForPreview = createPreviewObserver({observeDocumentTypeFromId, observePaths}) const observeDocumentPairAvailability = createPreviewAvailabilityObserver( 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/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/getPreviewStateObservable.ts b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts index f37e7490868..b01fd1f6735 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -1,15 +1,33 @@ 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 {type PerspectiveStack} from '../../perspective/types' +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 +42,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: PerspectiveStack + + /** + * 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/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..f345aa9a5f7 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx @@ -0,0 +1,82 @@ +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 {usePerspective} from '../../../perspective/usePerspective' +import {Preview} from '../../../preview' +import {getPublishedId, getVersionFromId, isVersionId} from '../../../util/draftUtils' +import {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..949804a11ad --- /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 '../../../components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../components/inputs/DateInputs/calendar/types' +import {DateTimeInput} from '../../../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' && ( + + + + + + )} + + ) +} 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..97aa7a75b0d --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx @@ -0,0 +1,93 @@ +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 for asap release', async () => { + await renderTest({release: publishedASAPRelease}) + + expect(screen.getByText('Revert release')).toBeInTheDocument() + }) + + test('shows revert button for scheduled release', async () => { + await renderTest({ + release: { + ...publishedASAPRelease, + metadata: {...publishedASAPRelease.metadata, releaseType: 'scheduled'}, + }, + }) + + 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..9487f398d9d --- /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 { + mockUseActiveReleases, + useActiveReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useActiveReleases.mock' +import {useReleaseOperationsMockReturn} from '../../../store/__tests__/__mocks/useReleaseOperations.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/useActiveReleases', () => ({ + useActiveReleases: vi.fn(() => useActiveReleasesMockReturn), +})) + +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() + mockUseActiveReleases.mockClear() + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + 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() + + mockUseActiveReleases.mockClear() + mockUseBundleDocuments.mockClear() + + mockUseBundleDocuments.mockReturnValue({...useBundleDocumentsMockReturn, loading: true}) + + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + 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 () => { + mockUseActiveReleases.mockReset() + + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + 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 () => { + mockUseActiveReleases.mockReset() + + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + 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..3975c3203eb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx @@ -0,0 +1,331 @@ +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', + 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', + 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..01aa97b2f89 --- /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/history/${client.config().dataset}/events/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..3d42b1f0417 --- /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 {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 { + // Needs vX version of the API + const client = useClient({apiVersion: 'X'}) + const documentPreviewStore = useDocumentPreviewStore() + const {state$: releasesState$} = useReleasesStore() + const source = useSource() + const eventsAPIEnabled = Boolean(source.beta?.eventsAPI?.releases) + + 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..7e7defc6caf --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx @@ -0,0 +1,75 @@ +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, ...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 ( + + +