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 86cc378a588..eb6aa075f3d 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..ae9ddbca81d 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -179,6 +179,9 @@ const defaultWorkspace = defineConfig({ tasks: { enabled: true, }, + releases: { + enabled: true, + }, }) export default defineConfig([ @@ -208,6 +211,9 @@ export default defineConfig([ unstable_tasks: { enabled: false, }, + releases: { + enabled: true, + }, }, { name: 'tsdoc', @@ -216,6 +222,9 @@ export default defineConfig([ dataset: 'tsdoc-2', plugins: [sharedSettings()], basePath: '/tsdoc', + releases: { + enabled: true, + }, }, { name: 'playground', @@ -225,6 +234,14 @@ export default defineConfig([ dataset: 'playground', plugins: [sharedSettings()], basePath: '/playground', + beta: { + eventsAPI: { + releases: true, + }, + }, + releases: { + enabled: true, + }, }, { name: 'listener-events', @@ -234,6 +251,9 @@ export default defineConfig([ dataset: 'data-loss', plugins: [sharedSettings()], basePath: '/listener-events', + releases: { + enabled: true, + }, }, { name: 'playground-partial-indexing', @@ -243,6 +263,9 @@ export default defineConfig([ dataset: 'playground-partial-indexing', plugins: [sharedSettings()], basePath: '/playground-partial-indexing', + releases: { + enabled: true, + }, }, { name: 'staging', @@ -259,6 +282,9 @@ export default defineConfig([ unstable_tasks: { enabled: true, }, + releases: { + enabled: true, + }, }, { name: 'custom-components', @@ -293,6 +319,9 @@ export default defineConfig([ toolMenu: CustomToolMenu, }, }, + releases: { + enabled: true, + }, }, { name: 'google-theme', @@ -303,6 +332,9 @@ export default defineConfig([ basePath: '/google', theme: googleTheme, icon: GoogleLogo, + releases: { + enabled: true, + }, }, { name: 'vercel-theme', @@ -313,6 +345,9 @@ export default defineConfig([ basePath: '/vercel', theme: vercelTheme, icon: VercelLogo, + releases: { + enabled: true, + }, }, { name: 'tailwind-theme', @@ -323,6 +358,9 @@ export default defineConfig([ basePath: '/tailwind', theme: tailwindTheme, icon: TailwindLogo, + releases: { + enabled: true, + }, }, { name: 'ai-assist', @@ -331,6 +369,9 @@ export default defineConfig([ dataset: 'test', plugins: [sharedSettings(), assist()], basePath: '/ai-assist', + releases: { + enabled: true, + }, }, { name: 'stega', @@ -344,6 +385,9 @@ export default defineConfig([ input: StegaDebugger, }, }, + releases: { + enabled: true, + }, }, { name: 'presentation', @@ -363,5 +407,8 @@ export default defineConfig([ sharedSettings(), ], basePath: '/presentation', + releases: { + enabled: true, + }, }, ]) as WorkspaceOptions[] 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/components/inputs/DateInputs/DatePicker.tsx b/packages/sanity/src/core/components/inputs/DateInputs/DatePicker.tsx index 2d65a13bc12..cbdef111aa7 100644 --- a/packages/sanity/src/core/components/inputs/DateInputs/DatePicker.tsx +++ b/packages/sanity/src/core/components/inputs/DateInputs/DatePicker.tsx @@ -13,6 +13,7 @@ export const DatePicker = forwardRef(function DatePicker( monthPickerVariant?: CalendarProps['monthPickerVariant'] padding?: number showTimezone?: boolean + isPastDisabled?: boolean }, ref: ForwardedRef, ) { diff --git a/packages/sanity/src/core/components/inputs/DateInputs/DateTimeInput.tsx b/packages/sanity/src/core/components/inputs/DateInputs/DateTimeInput.tsx index a89e6c66702..7d3a40f2ecc 100644 --- a/packages/sanity/src/core/components/inputs/DateInputs/DateTimeInput.tsx +++ b/packages/sanity/src/core/components/inputs/DateInputs/DateTimeInput.tsx @@ -1,12 +1,15 @@ import {CalendarIcon} from '@sanity/icons' -import {Box, Flex, LayerProvider, useClickOutsideEvent} from '@sanity/ui' +import {Box, Card, Flex, LayerProvider, Text, useClickOutsideEvent} from '@sanity/ui' +import {isPast} from 'date-fns' import { type FocusEvent, type ForwardedRef, forwardRef, type KeyboardEvent, useCallback, + useEffect, useImperativeHandle, + useMemo, useRef, useState, } from 'react' @@ -14,6 +17,7 @@ import FocusLock from 'react-focus-lock' import {Button} from '../../../../ui-components/button/Button' import {Popover} from '../../../../ui-components/popover/Popover' +import {useTranslation} from '../../../i18n' import {type CalendarProps} from './calendar/Calendar' import {type CalendarLabels} from './calendar/types' import {DatePicker} from './DatePicker' @@ -35,6 +39,7 @@ export interface DateTimeInputProps { monthPickerVariant?: CalendarProps['monthPickerVariant'] padding?: number disableInput?: boolean + isPastDisabled?: boolean } export const DateTimeInput = forwardRef(function DateTimeInput( @@ -53,17 +58,28 @@ export const DateTimeInput = forwardRef(function DateTimeInput( constrainSize = true, monthPickerVariant, padding, + disableInput, + isPastDisabled, ...rest } = props + const {t} = useTranslation() const popoverRef = useRef(null) const ref = useRef(null) const buttonRef = useRef(null) + const [referenceElement, setReferenceElement] = useState(null) + useImperativeHandle( forwardedRef, () => ref.current, ) + /** + * Setting referenceElement in effect makes sure it's up to date after the initial render + * cycle - avoiding referenceElement used byPopover from being out of sync with render state + */ + useEffect(() => setReferenceElement(ref.current), []) + const [isPickerOpen, setPickerOpen] = useState(false) useClickOutsideEvent( @@ -84,6 +100,11 @@ export const DateTimeInput = forwardRef(function DateTimeInput( const handleClick = useCallback(() => setPickerOpen(true), []) + const isDateInPastWarningShown = useMemo( + () => inputValue && isPastDisabled && isPast(new Date(inputValue)), + [inputValue, isPastDisabled], + ) + const suffix = readOnly ? null : ( + ) + } + + 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..3eda5476860 --- /dev/null +++ b/packages/sanity/src/core/perspective/useSetPerspective.tsx @@ -0,0 +1,20 @@ +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) => { + router.navigateStickyParams({ + excludedPerspectives: '', + perspective: releaseId === 'drafts' ? '' : releaseId, + }) + }, + [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/ScheduleDatePicker.tsx b/packages/sanity/src/core/releases/components/ScheduleDatePicker.tsx new file mode 100644 index 00000000000..29a3e4e658f --- /dev/null +++ b/packages/sanity/src/core/releases/components/ScheduleDatePicker.tsx @@ -0,0 +1,72 @@ +import {EarthGlobeIcon} from '@sanity/icons' +import {Flex} from '@sanity/ui' +import {format, isValid, parse} from 'date-fns' +import {useCallback, useMemo} from 'react' + +import {Button} from '../../../ui-components/button' +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' +import {useTranslation} from '../../i18n/hooks/useTranslation' +import useDialogTimeZone from '../../scheduledPublishing/hooks/useDialogTimeZone' +import useTimeZone from '../../scheduledPublishing/hooks/useTimeZone' + +interface ScheduleDatePickerProps { + initialValue: Date + onChange: (date: Date) => void +} + +const inputDateFormat = 'PP HH:mm' + +export const ScheduleDatePicker = ({ + initialValue: inputValue, + onChange, +}: ScheduleDatePickerProps) => { + const {t} = useTranslation() + const {timeZone} = useTimeZone() + const {dialogTimeZoneShow} = useDialogTimeZone() + + const handlePublishAtCalendarChange = (date: Date | null) => { + if (!date) return + + onChange(date) + } + + const handlePublishAtInputChange = useCallback( + (event: React.FocusEvent) => { + const date = event.currentTarget.value + const parsedDate = parse(date, inputDateFormat, new Date()) + + if (isValid(parsedDate)) onChange(parsedDate) + }, + [onChange], + ) + + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(t), [t]) + + return ( + + + + + )} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDashboardFooter.test.tsx new file mode 100644 index 00000000000..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..0e95d86bddb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx @@ -0,0 +1,329 @@ +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('../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..09d4e62af78 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseTypePicker.test.tsx @@ -0,0 +1,228 @@ +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, 'Calendar') + const CalendarMonth = getByDataUi(document.body, 'CalendarMonth') + + // Select the 10th day in the calendar month + fireEvent.click(within(Calendar).getByTestId('calendar-next-month')) + fireEvent.click(within(CalendarMonth).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')) + + await waitFor(() => { + // Check if the spinner is displayed while updating + screen.queryByTestId('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 ( + + +