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 { - {SUPPORTED_PERSPECTIVES.map((p) => ( - {p} - ))} + {SUPPORTED_PERSPECTIVES.map((perspectiveName) => { + if (perspectiveName === 'pinnedRelease') { + return ( + <> + + + > + ) + } + return {perspectiveName} + })} @@ -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 ( + + {text} + + ) +} + +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 : ( + {isDateInPastWarningShown && ( + + {t('inputs.dateTime.past-date-warning')} + + )} diff --git a/packages/sanity/src/core/components/inputs/DateInputs/calendar/Calendar.tsx b/packages/sanity/src/core/components/inputs/DateInputs/calendar/Calendar.tsx index 84366251e84..f84202a8057 100644 --- a/packages/sanity/src/core/components/inputs/DateInputs/calendar/Calendar.tsx +++ b/packages/sanity/src/core/components/inputs/DateInputs/calendar/Calendar.tsx @@ -42,6 +42,7 @@ export type CalendarProps = Omit, 'onSelect'> & { monthPickerVariant?: (typeof MONTH_PICKER_VARIANT)[keyof typeof MONTH_PICKER_VARIANT] padding?: number showTimezone?: boolean + isPastDisabled?: boolean } // This is used to maintain focus on a child element of the calendar-grid between re-renders @@ -76,6 +77,7 @@ export const Calendar = forwardRef(function Calendar( timeStep = 1, onSelect, labels, + isPastDisabled, monthPickerVariant = 'select', padding = 2, showTimezone = false, @@ -232,12 +234,14 @@ export const Calendar = forwardRef(function Calendar( icon={ChevronLeftIcon} mode="bleed" onClick={() => moveFocusedDate(-1)} + data-testid="calendar-prev-month" tooltipProps={{content: 'Previous month'}} /> moveFocusedDate(1)} + data-testid="calendar-next-month" tooltipProps={{content: 'Next month'}} /> @@ -312,6 +316,7 @@ export const Calendar = forwardRef(function Calendar( focused={focusedDate} onSelect={handleDateChange} selected={selectedDate} + isPastDisabled={isPastDisabled} /> {PRESERVE_FOCUS_ELEMENT} diff --git a/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarDay.tsx b/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarDay.tsx index f7916da6af0..87428355f14 100644 --- a/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarDay.tsx +++ b/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarDay.tsx @@ -1,4 +1,5 @@ import {Card, Text} from '@sanity/ui' +import {isPast} from 'date-fns' import {useCallback} from 'react' interface CalendarDayProps { @@ -8,10 +9,11 @@ interface CalendarDayProps { isCurrentMonth?: boolean isToday: boolean selected?: boolean + isPastDisabled?: boolean } export function CalendarDay(props: CalendarDayProps) { - const {date, focused, isCurrentMonth, isToday, onSelect, selected} = props + const {date, focused, isCurrentMonth, isToday, onSelect, selected, isPastDisabled} = props const handleClick = useCallback(() => { onSelect(date) @@ -28,6 +30,7 @@ export function CalendarDay(props: CalendarDayProps) { data-focused={focused ? 'true' : ''} role="button" tabIndex={-1} + disabled={isPastDisabled && !isToday && isPast(date)} onClick={handleClick} padding={2} radius={2} diff --git a/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarMonth.tsx b/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarMonth.tsx index 3a609d14fe6..423c9e5493f 100644 --- a/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarMonth.tsx +++ b/packages/sanity/src/core/components/inputs/DateInputs/calendar/CalendarMonth.tsx @@ -11,6 +11,7 @@ interface CalendarMonthProps { selected?: Date onSelect: (date: Date) => void hidden?: boolean + isPastDisabled?: boolean weekDayNames: [ mon: string, tue: string, @@ -62,6 +63,7 @@ export function CalendarMonth(props: CalendarMonthProps) { key={`${weekIdx}-${dayIdx}`} onSelect={props.onSelect} selected={selected} + isPastDisabled={props.isPastDisabled} /> ) }), diff --git a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts index eff10151d30..6a0ace3a6a4 100644 --- a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts +++ b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts @@ -165,6 +165,7 @@ describe('resolveConfig', () => { {name: 'sanity/tasks'}, {name: 'sanity/scheduled-publishing'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) @@ -192,6 +193,7 @@ describe('resolveConfig', () => { {name: 'sanity/comments'}, {name: 'sanity/tasks'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) }) diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index 1fcc613e510..c5bbb158c53 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -364,6 +364,37 @@ export const internalTasksReducer = (opts: { return result } +export const eventsAPIReducer = (opts: { + config: PluginOptions + initialValue: boolean + key: 'releases' | 'documents' +}): boolean => { + const {config, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + const result = flattenedConfig.reduce((acc: boolean, {config: innerConfig}) => { + // @ts-expect-error enabled is a legacy option we want to warn beta testers in case they have enabled it. + if (innerConfig.beta?.eventsAPI?.enabled) { + throw new Error( + `The \`beta.eventsAPI.enabled\` option has been removed. Use \`beta.eventsAPI.${opts.key}\` instead.`, + ) + } + + const enabled = innerConfig.beta?.eventsAPI?.[opts.key] + + if (typeof enabled === 'undefined') return acc + if (typeof enabled === 'boolean') return enabled + + throw new Error( + `Expected \`beta.eventsAPI.${opts.key}\` to be a boolean, but received ${getPrintableType( + enabled, + )}`, + ) + }, initialValue) + + return result +} + export const serverDocumentActionsReducer = (opts: { config: PluginOptions initialValue: boolean | undefined diff --git a/packages/sanity/src/core/config/prepareConfig.tsx b/packages/sanity/src/core/config/prepareConfig.tsx index f9b7c4966c2..e673140baed 100644 --- a/packages/sanity/src/core/config/prepareConfig.tsx +++ b/packages/sanity/src/core/config/prepareConfig.tsx @@ -26,6 +26,7 @@ import { documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, + eventsAPIReducer, fileAssetSourceResolver, imageAssetSourceResolver, initialDocumentActions, @@ -645,6 +646,10 @@ function resolveSource({ }, beta: { + eventsAPI: { + documents: eventsAPIReducer({config, initialValue: true, key: 'documents'}), + releases: eventsAPIReducer({config, initialValue: false, key: 'releases'}), + }, treeArrayEditing: { // This beta feature is no longer available. enabled: false, diff --git a/packages/sanity/src/core/config/resolveDefaultPlugins.ts b/packages/sanity/src/core/config/resolveDefaultPlugins.ts index b567b8f839a..166da75b177 100644 --- a/packages/sanity/src/core/config/resolveDefaultPlugins.ts +++ b/packages/sanity/src/core/config/resolveDefaultPlugins.ts @@ -1,5 +1,6 @@ import {comments} from '../comments/plugin' import {createIntegration} from '../create/createIntegrationPlugin' +import {releases, RELEASES_NAME} from '../releases/plugin' import {DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS} from '../scheduledPublishing/constants' import {SCHEDULED_PUBLISHING_NAME, scheduledPublishing} from '../scheduledPublishing/plugin' import {tasks, TASKS_NAME} from '../tasks/plugin' @@ -10,7 +11,7 @@ import { type WorkspaceOptions, } from './types' -const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration()] +const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration(), releases()] export function getDefaultPlugins( options: DefaultPluginsWorkspaceOptions, @@ -24,6 +25,9 @@ export function getDefaultPlugins( if (plugin.name === TASKS_NAME) { return options.tasks.enabled } + if (plugin.name === RELEASES_NAME) { + return options.releases.enabled + } return true }) } @@ -41,5 +45,9 @@ export function getDefaultPluginsOptions( ...DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS, ...workspace.scheduledPublishing, }, + releases: { + ...workspace.releases, + enabled: workspace.releases?.enabled ?? false, + }, } } diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 656de9a7c94..f9a4bb7f7be 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -483,6 +483,10 @@ export interface WorkspaceOptions extends SourceOptions { * @internal */ tasks?: DefaultPluginsWorkspaceOptions['tasks'] + /** + * @internal + */ + releases?: DefaultPluginsWorkspaceOptions['releases'] /** * @hidden @@ -542,6 +546,13 @@ export interface ResolveProductionUrlContext extends ConfigContext { document: SanityDocumentLike } +/** + * @hidden + * @beta + */ + +export type DocumentActionsVersionType = 'published' | 'draft' | 'revision' | 'version' + /** * @hidden * @beta @@ -549,6 +560,11 @@ export interface ResolveProductionUrlContext extends ConfigContext { export interface DocumentActionsContext extends ConfigContext { documentId?: string schemaType: string + + /** releaseId of the open document, it's undefined if it's published or the draft */ + releaseId?: string + /** the type of the currently active document. */ + versionType?: DocumentActionsVersionType } /** @@ -810,6 +826,9 @@ export interface Source { /** @beta */ tasks?: WorkspaceOptions['tasks'] + /** @beta */ + releases?: WorkspaceOptions['releases'] + /** @internal */ __internal_serverDocumentActions?: WorkspaceOptions['__internal_serverDocumentActions'] /** Configuration for studio features. @@ -937,6 +956,7 @@ export type { export type DefaultPluginsWorkspaceOptions = { tasks: {enabled: boolean} scheduledPublishing: ScheduledPublishingPluginOptions + releases: {enabled: boolean} } /** @@ -992,4 +1012,15 @@ export interface BetaFeatures { */ fallbackStudioOrigin?: string } + /** + * Config for the history events API . + * + * If enabled, it will use the new events API to fetch document history. + * + * If it is not enabled, it will continue using the legacy Timeline. + */ + eventsAPI?: { + documents?: boolean + releases?: boolean + } } diff --git a/packages/sanity/src/core/create/components/StartInCreateBanner.tsx b/packages/sanity/src/core/create/components/StartInCreateBanner.tsx index e8a5b4042bd..3a319fe2509 100644 --- a/packages/sanity/src/core/create/components/StartInCreateBanner.tsx +++ b/packages/sanity/src/core/create/components/StartInCreateBanner.tsx @@ -15,6 +15,7 @@ import {useCallback, useState} from 'react' import {TextWithTone} from '../../components/textWithTone/TextWithTone' import {isDev} from '../../environment' import {useTranslation} from '../../i18n' +import {usePerspective} from '../../perspective/usePerspective' import {useWorkspace} from '../../studio' import {useSanityCreateConfig} from '../context' import {getCreateLinkUrl} from '../createDocumentUrls' @@ -33,12 +34,16 @@ import {StartInCreateDevInfoButton} from './StartInCreateDevInfoButton' export function StartInCreateBanner(props: StartInCreateBannerProps) { const {document, isInitialValueLoading} = props const {appIdCache, startInCreateEnabled} = useSanityCreateConfig() - + const {selectedPerspectiveName} = usePerspective() const isExcludedByOption = isSanityCreateExcludedType(props.documentType) const isNewPristineDoc = !document._createdAt const isStartCreateCompatible = isSanityCreateStartCompatibleDoc(props.document) + const liveEdit = Boolean(props.documentType?.liveEdit) + + const excludeOnPublished = selectedPerspectiveName === 'published' && !liveEdit if ( + excludeOnPublished || !isNewPristineDoc || !startInCreateEnabled || isExcludedByOption || diff --git a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx index 0fb5b59c251..2b98c8c39af 100644 --- a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx +++ b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx @@ -88,6 +88,7 @@ export default function ChangeListStory() { rootDiff: diff, schemaType, value: {name: 'Test'}, + showFromValue: true, }), [diff, documentId, FieldWrapper, schemaType], ) diff --git a/packages/sanity/src/core/field/diff/components/ChangesError.tsx b/packages/sanity/src/core/field/diff/components/ChangesError.tsx new file mode 100644 index 00000000000..df32868746f --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/ChangesError.tsx @@ -0,0 +1,22 @@ +import {Card, Stack, Text} from '@sanity/ui' + +import {useTranslation} from '../../../i18n' + +/** + * @internal + * */ +export function ChangesError() { + const {t} = useTranslation() + return ( + + + + {t('changes.error-title')} + + + {t('changes.error-description')} + + + + ) +} diff --git a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx index 8fb65329191..6c0100d1cc0 100644 --- a/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx +++ b/packages/sanity/src/core/field/diff/components/DiffTooltip.tsx @@ -1,5 +1,5 @@ import {type Path} from '@sanity/types' -import {Flex, Inline, Stack, Text} from '@sanity/ui' +import {Card, Flex, Inline, Stack, Text} from '@sanity/ui' import {type ReactNode} from 'react' import {Tooltip, type TooltipProps} from '../../../../ui-components' @@ -9,6 +9,7 @@ import {useTranslation} from '../../../i18n' import {useUser} from '../../../store' import {type AnnotationDetails, type Diff} from '../../types' import {getAnnotationAtPath, useAnnotationColor} from '../annotations' +import {Event} from '../components/Event' /** @internal */ export interface DiffTooltipProps extends TooltipProps { @@ -46,7 +47,7 @@ function DiffTooltipWithAnnotation(props: DiffTooltipWithAnnotationsProps) { } const content = ( - + {description || t('changes.changed-label')} @@ -75,26 +76,35 @@ function AnnotationItem({annotation}: {annotation: AnnotationDetails}) { const {t} = useTranslation() return ( - - - - - - {user ? user.displayName : t('changes.loading-author')} + <> + {annotation.event ? ( + <> + + + > + ) : ( + + + + + + {user ? user.displayName : t('changes.loading-author')} + + + + + {timeAgo} - - - {timeAgo} - - + )} + > ) } diff --git a/packages/sanity/src/core/field/diff/components/Event.tsx b/packages/sanity/src/core/field/diff/components/Event.tsx new file mode 100644 index 00000000000..ebe1edd22c0 --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/Event.tsx @@ -0,0 +1,204 @@ +import {type AvatarSize, AvatarStack, Box, Flex, Skeleton, Stack, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2, type ThemeColorAvatarColorKey} from '@sanity/ui/theme' +import {useMemo} from 'react' +import {css, styled} from 'styled-components' + +import {Tooltip} from '../../../../ui-components' +import {UserAvatar} from '../../../components/userAvatar/UserAvatar' +import {useDateTimeFormat} from '../../../hooks/useDateTimeFormat' +import {type RelativeTimeOptions, useRelativeTime} from '../../../hooks/useRelativeTime' +import {useTranslation} from '../../../i18n/hooks/useTranslation' +import {VersionInlineBadge} from '../../../releases/components/VersionInlineBadge' +import {getReleaseTone} from '../../../releases/util/getReleaseTone' +import { + type DocumentGroupEvent, + isEditDocumentVersionEvent, + isPublishDocumentVersionEvent, +} from '../../../store/events/types' +import {useUser} from '../../../store/user/hooks' +import {getDocumentVariantType} from '../../../util/getDocumentVariantType' +import { + TIMELINE_ICON_COMPONENTS, + TIMELINE_ITEM_EVENT_TONE, + TIMELINE_ITEM_I18N_KEY_MAPPING, +} from './constants' + +interface UserAvatarStackProps { + maxLength?: number + userIds: string[] + size?: AvatarSize + withTooltip?: boolean +} + +function UserAvatarStack({maxLength, userIds, size, withTooltip = true}: UserAvatarStackProps) { + return ( + + {userIds.map((userId) => ( + + ))} + + ) +} + +const IconBox = styled(Flex)<{$color: ThemeColorAvatarColorKey}>((props) => { + const theme = getTheme_v2(props.theme) + const color = props.$color + + return css` + --card-icon-color: ${theme.color.avatar[color].fg}; + background-color: ${theme.color.avatar[color].bg}; + box-shadow: 0 0 0 1px var(--card-bg-color); + + position: absolute; + width: ${theme.avatar.sizes[0].size}px; + height: ${theme.avatar.sizes[0].size}px; + right: -3px; + bottom: -3px; + border-radius: 50%; + ` +}) + +const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = { + minimal: true, + useTemporalPhrase: true, +} + +const AvatarSkeleton = styled(Skeleton)((props) => { + const theme = getTheme_v2(props.theme) + return css` + border-radius: 50%; + width: ${theme.avatar.sizes[1].size}px; + height: ${theme.avatar.sizes[1].size}px; + ` +}) + +const NameSkeleton = styled(Skeleton)((props) => { + const theme = getTheme_v2(props.theme) + return css` + width: 6ch; + height: ${theme.font.text.sizes[0].lineHeight}px; + ` +}) + +const UserLine = ({userId}: {userId: string}) => { + const [user, loading] = useUser(userId) + + return ( + + {loading || !user ? : } + + {loading || !user?.displayName ? ( + + + + ) : ( + + {user.displayName} + + )} + + + ) +} +const ChangesBy = ({collaborators}: {collaborators: string[]}) => { + const {t} = useTranslation('studio') + return ( + + + + {t('timeline.changes.title')} + + + {collaborators.map((userId) => ( + + ))} + + ) +} + +interface TimelineItemProps { + event: DocumentGroupEvent + showChangesBy: 'tooltip' | 'inline' | 'hidden' +} +/** + * @internal + */ +export function Event({event, showChangesBy = 'tooltip'}: TimelineItemProps) { + const {t} = useTranslation('studio') + const documentVariantType = getDocumentVariantType(event.documentId) + const {type, timestamp} = event + + const IconComponent = TIMELINE_ICON_COMPONENTS[type] + const contributors = 'contributors' in event ? event.contributors || [] : [] + + const dateFormat = useDateTimeFormat({dateStyle: 'medium', timeStyle: 'short'}) + const date = new Date(timestamp) + + const updatedTimeAgo = useRelativeTime(date || '', RELATIVE_TIME_OPTIONS) + + const formattedTimestamp = useMemo(() => { + const parsedDate = new Date(timestamp) + const formattedDate = dateFormat.format(parsedDate) + + return formattedDate + }, [timestamp, dateFormat]) + + const userIds = isEditDocumentVersionEvent(event) ? event.contributors : [event.author] + + return ( + <> + + + + + {IconComponent && } + + + + + {t(TIMELINE_ITEM_I18N_KEY_MAPPING[type])} + {isPublishDocumentVersionEvent(event) && documentVariantType === 'published' && ( + <> + {' '} + {event.release ? ( + + {event.release.metadata.title} + + ) : ( + + {t('changes.versions.draft')} + + )} + > + )} + + + + {updatedTimeAgo} + + + + {contributors.length > 0 && showChangesBy == 'tooltip' && ( + + } portal> + + + + + + )} + + {contributors.length > 0 && showChangesBy === 'inline' && ( + + + + )} + > + ) +} diff --git a/packages/sanity/src/core/field/diff/components/NoChanges.tsx b/packages/sanity/src/core/field/diff/components/NoChanges.tsx index 437096ba9c3..c99cf5e6ac0 100644 --- a/packages/sanity/src/core/field/diff/components/NoChanges.tsx +++ b/packages/sanity/src/core/field/diff/components/NoChanges.tsx @@ -6,7 +6,7 @@ import {useTranslation} from '../../../i18n' export function NoChanges() { const {t} = useTranslation() return ( - + {t('changes.no-changes-title')} diff --git a/packages/sanity/src/core/field/diff/components/constants.ts b/packages/sanity/src/core/field/diff/components/constants.ts new file mode 100644 index 00000000000..5a0c3fffa71 --- /dev/null +++ b/packages/sanity/src/core/field/diff/components/constants.ts @@ -0,0 +1,62 @@ +import { + AddCircleIcon, + CalendarIcon, + CircleIcon, + CloseIcon, + EditIcon, + type IconComponent, + PublishIcon, + TrashIcon, + UnpublishIcon, +} from '@sanity/icons' +import {type ThemeColorAvatarColorKey} from '@sanity/ui/theme' + +import {type StudioLocaleResourceKeys} from '../../../i18n/bundles/studio' +import {type DocumentVersionEventType} from '../../../store/events/types' + +export const TIMELINE_ICON_COMPONENTS: Record = { + createDocumentVersion: AddCircleIcon, + createLiveDocument: AddCircleIcon, + deleteDocumentGroup: TrashIcon, + deleteDocumentVersion: CloseIcon, + editDocumentVersion: EditIcon, + updateLiveDocument: EditIcon, + publishDocumentVersion: PublishIcon, + unpublishDocument: UnpublishIcon, + scheduleDocumentVersion: CalendarIcon, + unscheduleDocumentVersion: CircleIcon, +} + +export const TIMELINE_ITEM_EVENT_TONE: Record = + { + createDocumentVersion: 'green', + createLiveDocument: 'blue', + updateLiveDocument: 'green', + editDocumentVersion: 'yellow', + unpublishDocument: 'orange', + deleteDocumentVersion: 'orange', + deleteDocumentGroup: 'orange', + scheduleDocumentVersion: 'cyan', + unscheduleDocumentVersion: 'cyan', + publishDocumentVersion: 'green', + } + +/** + * @internal + * mapping of events types with a readable key for translation + */ +export const TIMELINE_ITEM_I18N_KEY_MAPPING: Record< + DocumentVersionEventType, + StudioLocaleResourceKeys +> = { + createDocumentVersion: 'timeline.operation.created', + publishDocumentVersion: 'timeline.operation.published', + updateLiveDocument: 'timeline.operation.edited-live', + editDocumentVersion: 'timeline.operation.edited-draft', + unpublishDocument: 'timeline.operation.unpublished', + deleteDocumentVersion: 'timeline.operation.draft-discarded', + deleteDocumentGroup: 'timeline.operation.deleted', + scheduleDocumentVersion: 'timeline.operation.published', + unscheduleDocumentVersion: 'timeline.operation.published', + createLiveDocument: 'timeline.operation.created', +} diff --git a/packages/sanity/src/core/field/diff/components/index.ts b/packages/sanity/src/core/field/diff/components/index.ts index 073e58023cb..1fe0d20e381 100644 --- a/packages/sanity/src/core/field/diff/components/index.ts +++ b/packages/sanity/src/core/field/diff/components/index.ts @@ -1,13 +1,16 @@ export * from './ChangeBreadcrumb' export * from './ChangeList' export * from './ChangeResolver' +export * from './ChangesError' export * from './ChangeTitleSegment' +export {TIMELINE_ITEM_I18N_KEY_MAPPING} from './constants' export * from './DiffCard' export * from './DiffErrorBoundary' export * from './DiffFromTo' export * from './DiffInspectWrapper' export * from './DiffString' export * from './DiffTooltip' +export * from './Event' export * from './FallbackDiff' export * from './FieldChange' export * from './FromTo' diff --git a/packages/sanity/src/core/field/types.ts b/packages/sanity/src/core/field/types.ts index a281913b163..111d59f32c7 100644 --- a/packages/sanity/src/core/field/types.ts +++ b/packages/sanity/src/core/field/types.ts @@ -25,6 +25,7 @@ import { } from '@sanity/types' import {type ComponentType} from 'react' +import {type DocumentGroupEvent} from '../store/events' import {type FieldValueError} from './validation' /** @@ -69,7 +70,7 @@ export type Chunk = { * @beta */ export type AnnotationDetails = { - chunk: Chunk + event?: DocumentGroupEvent timestamp: string author: string } diff --git a/packages/sanity/src/core/form/FormBuilderProvider.tsx b/packages/sanity/src/core/form/FormBuilderProvider.tsx index b14969c90a0..50d7fefa30a 100644 --- a/packages/sanity/src/core/form/FormBuilderProvider.tsx +++ b/packages/sanity/src/core/form/FormBuilderProvider.tsx @@ -65,6 +65,7 @@ export interface FormBuilderProviderProps { schemaType: ObjectSchemaType unstable?: Source['form']['unstable'] validation: ValidationMarker[] + version?: string } const missingPatchChannel: PatchChannel = { @@ -113,6 +114,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { schemaType, unstable, validation, + version, } = props const __internal: FormBuilderContextValue['__internal'] = useMemo( @@ -171,6 +173,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, }), [ __internal, @@ -191,6 +194,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, ], ) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index 2df13d15d09..634becbd6d0 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -17,6 +17,7 @@ import {MenuButton, MenuItem, TooltipDelayGroupProvider} from '../../../../ui-co import {ContextMenuButton} from '../../../components/contextMenuButton' import {type DocumentFieldActionNode} from '../../../config' import {useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' import {EMPTY_ARRAY} from '../../../util/empty' import {FormField} from '../../components' import {usePublishedId} from '../../contexts/DocumentIdProvider' @@ -62,6 +63,7 @@ export function ReferenceField(props: ReferenceFieldProps) { const elementRef = useRef(null) const {schemaType, path, open, inputId, children, inputProps} = props const {readOnly, focused, renderPreview, onChange} = props.inputProps + const {selectedReleaseId} = usePerspective() const [fieldActionsNodes, setFieldActionNodes] = useState([]) const documentId = usePublishedId() @@ -74,6 +76,7 @@ export function ReferenceField(props: ReferenceFieldProps) { path, schemaType, value, + version: selectedReleaseId, }) // this is here to make sure the item is visible if it's being edited behind a modal diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx index 17eca82c815..ef7639bb3dc 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx @@ -8,6 +8,7 @@ import {catchError, filter, map, scan, switchMap, tap} from 'rxjs/operators' import {Button} from '../../../../ui-components' import {ReferenceInputPreviewCard} from '../../../components' import {Translate, useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' import {getPublishedId, isNonNullable} from '../../../util' import {Alert} from '../../components/Alert' import {useDidUpdate} from '../../hooks/useDidUpdate' @@ -52,16 +53,19 @@ export function ReferenceInput(props: ReferenceInputProps) { id, onPathFocus, value, + version, renderPreview, path, elementProps, focusPath, } = props + const {selectedReleaseId} = usePerspective() const {getReferenceInfo} = useReferenceInput({ path, schemaType, value, + version, }) const [searchState, setSearchState] = useState(INITIAL_SEARCH_STATE) @@ -82,10 +86,15 @@ export function ReferenceInput(props: ReferenceInputProps) { onChange(patches) - onEditReference({id: newDocumentId, type: option.type, template: option.template}) + onEditReference({ + id: newDocumentId, + type: option.type, + template: option.template, + version: selectedReleaseId, + }) onPathFocus([]) }, - [onChange, onEditReference, onPathFocus, schemaType], + [onChange, onEditReference, onPathFocus, schemaType.name, schemaType.weak, selectedReleaseId], ) const handleChange = useCallback( @@ -206,6 +215,7 @@ export function ReferenceInput(props: ReferenceInputProps) { const renderValue = useCallback(() => { return ( + loadableReferenceInfo.result?.preview.version?.title || loadableReferenceInfo.result?.preview.draft?.title || loadableReferenceInfo.result?.preview.published?.title || '' @@ -213,6 +223,7 @@ export function ReferenceInput(props: ReferenceInputProps) { }, [ loadableReferenceInfo.result?.preview.draft?.title, loadableReferenceInfo.result?.preview.published?.title, + loadableReferenceInfo.result?.preview.version?.title, ]) const handleFocus = useCallback(() => onPathFocus(['_ref']), [onPathFocus]) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx index 9371f6275a4..41daf411011 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx @@ -27,6 +27,7 @@ export function ReferencePreview(props: { const documentPresence = useDocumentPresence(id) const previewId = + preview.version?._id || preview.draft?._id || preview.published?._id || // note: during publish of the referenced document we might have both a missing draft and a missing published version @@ -44,8 +45,6 @@ export function ReferencePreview(props: { [previewId, refType.name], ) - const {draft, published} = preview - const previewProps = useMemo( () => ({ children: ( @@ -57,23 +56,32 @@ export function ReferencePreview(props: { )} - + ), layout, schemaType: refType, - tooltip: , + tooltip: ( + + ), value: previewStub, }), [ documentPresence, - draft, layout, preview.draft, preview.published, + preview.versions, previewStub, - published, refType, showTypeLabel, ], diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts index ed129607c31..c4ca3edfd05 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts @@ -1,3 +1,4 @@ +import {type ReleaseId} from '@sanity/client' import { type I18nTextRecord, type Path, @@ -9,6 +10,7 @@ import {type ComponentType, type ReactNode} from 'react' import {type Observable} from 'rxjs' import {type DocumentAvailability} from '../../../preview' +import {type VersionsRecord} from '../../../preview/utils/getPreviewStateObservable' import {type ObjectInputProps} from '../../types' export type PreviewDocumentValue = PreviewValue & { @@ -24,6 +26,8 @@ export interface ReferenceInfo { preview: { draft: PreviewDocumentValue | undefined published: PreviewDocumentValue | undefined + version: PreviewDocumentValue | undefined + versions: VersionsRecord } } @@ -36,6 +40,7 @@ export interface EditReferenceEvent { id: string type: string template: ReferenceTemplate + version?: ReleaseId } export interface CreateReferenceOption { @@ -82,4 +87,5 @@ export interface ReferenceInputProps onEditReference: (event: EditReferenceEvent) => void getReferenceInfo: (id: string, type: ReferenceSchemaType) => Observable + version?: string } diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx index cdfdc0c2df5..e4efc7997f0 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -12,6 +12,9 @@ import { import {type FIXME} from '../../../FIXME' import {useSchema} from '../../../hooks' +import {usePerspective} from '../../../perspective/usePerspective' +import {useActiveReleases} from '../../../releases/store/useActiveReleases' +import {useReleasesIds} from '../../../releases/store/useReleasesIds' import {useDocumentPreviewStore} from '../../../store' import {isNonNullable} from '../../../util' import {useFormValue} from '../../contexts/FormValue' @@ -31,11 +34,15 @@ interface Options { path: Path schemaType: ReferenceSchemaType value?: Reference + version?: string } export function useReferenceInput(options: Options) { - const {path, schemaType} = options + const {path, schemaType, version} = options const schema = useSchema() + const perspective = usePerspective() + const {data} = useActiveReleases() + const {releasesIds} = useReleasesIds(data) const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -116,8 +123,18 @@ export function useReferenceInput(options: Options) { }, [disableNew, initialValueTemplateItems, schemaType.to]) const getReferenceInfo = useCallback( - (id: string) => adapter.getReferenceInfo(documentPreviewStore, id, schemaType), - [documentPreviewStore, schemaType], + (id: string) => + adapter.getReferenceInfo( + documentPreviewStore, + id, + schemaType, + {version}, + { + bundleIds: releasesIds, + bundleStack: perspective.perspectiveStack, + }, + ), + [documentPreviewStore, schemaType, version, releasesIds, perspective.perspectiveStack], ) return { diff --git a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx b/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx index 7f0ae49f246..17ce9d60824 100644 --- a/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx +++ b/packages/sanity/src/core/form/studio/contexts/ReferenceInputOptions.tsx @@ -1,3 +1,4 @@ +import {type ReleaseId} from '@sanity/client' import {type Path} from '@sanity/types' import {type ComponentType, type HTMLProps, type ReactNode, useContext, useMemo} from 'react' import {ReferenceInputOptionsContext} from 'sanity/_singletons' @@ -18,6 +19,7 @@ export interface EditReferenceOptions { type: string parentRefPath: Path template: TemplateOption + version?: ReleaseId } /** @internal */ diff --git a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts index d8230175e04..58bd03d4b39 100644 --- a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts +++ b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts @@ -1,12 +1,25 @@ import {type SanityClient} from '@sanity/client' import {DEFAULT_MAX_FIELD_DEPTH} from '@sanity/schema/_internal' import {type ReferenceFilterSearchOptions, type ReferenceSchemaType} from '@sanity/types' -import {combineLatest, type Observable, of} from 'rxjs' -import {map, mergeMap, switchMap} from 'rxjs/operators' +import {omit} from 'lodash' +import {combineLatest, from, type Observable, of} from 'rxjs' +import {map, mergeMap, scan, startWith, switchMap} from 'rxjs/operators' -import {type DocumentPreviewStore} from '../../../../preview' +import {type PerspectiveStack} from '../../../../perspective/types' +import {type DocumentPreviewStore, getPreviewPaths, prepareForPreview} from '../../../../preview' +import { + type VersionsRecord, + type VersionTuple, +} from '../../../../preview/utils/getPreviewStateObservable' import {createSearch} from '../../../../search' -import {collate, type CollatedHit, getDraftId, getIdPair} from '../../../../util' +import { + collate, + type CollatedHit, + getDraftId, + getIdPair, + getVersionId, + isRecord, +} from '../../../../util' import { type PreviewDocumentValue, type ReferenceInfo, @@ -35,22 +48,34 @@ export function getReferenceInfo( documentPreviewStore: DocumentPreviewStore, id: string, referenceType: ReferenceSchemaType, + {version}: {version?: string} = {}, + perspective: {bundleIds: string[]; bundleStack: PerspectiveStack} = { + bundleIds: [], + bundleStack: [], + }, ): Observable { - const {publishedId, draftId} = getIdPair(id) + const {publishedId, draftId, versionId} = getIdPair(id, {version}) - const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id) + const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id, { + version, + }) return pairAvailability$.pipe( switchMap((pairAvailability) => { - if (!pairAvailability.draft.available && !pairAvailability.published.available) { + if ( + !pairAvailability.draft.available && + !pairAvailability.published.available && + !pairAvailability.version?.available + ) { // combine availability of draft + published const availability = + pairAvailability.version?.reason === 'PERMISSION_DENIED' || pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND - // short circuit, neither draft nor published is available so no point in trying to get preview + // short circuit, neither draft nor published nor version is available so no point in trying to get preview return of({ id, type: undefined, @@ -58,6 +83,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -65,9 +92,13 @@ export function getReferenceInfo( const typeName$ = combineLatest([ documentPreviewStore.observeDocumentTypeFromId(draftId), documentPreviewStore.observeDocumentTypeFromId(publishedId), + ...(versionId ? [documentPreviewStore.observeDocumentTypeFromId(versionId)] : []), ]).pipe( - // assume draft + published are always same type - map(([draftTypeName, publishedTypeName]) => draftTypeName || publishedTypeName), + // assume draft + published + version are always same type + map( + ([draftTypeName, publishedTypeName, versionTypeName]) => + versionTypeName || draftTypeName || publishedTypeName, + ), ) return typeName$.pipe( @@ -84,6 +115,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -99,10 +132,12 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } - + const previewPaths = getPreviewPaths(refSchemaType?.preview) || [] const draftPreview$ = documentPreviewStore.observeForPreview( {_id: draftId}, refSchemaType, @@ -113,10 +148,67 @@ export function getReferenceInfo( refSchemaType, ) - const value$ = combineLatest([draftPreview$, publishedPreview$]).pipe( - map(([draft, published]) => ({ + const versions$ = from(perspective.bundleIds).pipe( + mergeMap>((bundleId) => + documentPreviewStore + .observePaths({_id: getVersionId(id, bundleId)}, previewPaths) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => + result + ? [ + bundleId, + { + snapshot: { + _id: versionId, + ...prepareForPreview(result, refSchemaType), + }, + }, + ] + : [bundleId, {snapshot: null}], + ), + ), + ), + scan((byBundleId, [bundleId, value]) => { + if (value.snapshot === null) { + return omit({...byBundleId}, [bundleId]) + } + + return { + ...byBundleId, + [bundleId]: value, + } + }, {}), + startWith({}), + ) + + // Iterate the release stack in descending precedence, returning the highest precedence existing + // version document. + const versionPreview$ = versionId + ? versions$.pipe( + map((versions) => { + for (const bundleId of perspective.bundleStack) { + if (bundleId in versions) { + return versions[bundleId] + } + } + return null + }), + startWith(undefined), + ) + : undefined + + const value$ = combineLatest([ + draftPreview$, + publishedPreview$, + ...(versionPreview$ ? [versionPreview$] : []), + versions$, + ]).pipe( + map(([draft, published, versionValue, versions]) => ({ draft, published, + ...(versionValue ? {version: versionValue} : {}), + versions: versions, })), ) @@ -124,9 +216,12 @@ export function getReferenceInfo( map((value): ReferenceInfo => { const availability = // eslint-disable-next-line no-nested-ternary - pairAvailability.draft.available || pairAvailability.published.available + pairAvailability.version?.available || + pairAvailability.draft.available || + pairAvailability.published.available ? READABLE - : pairAvailability.draft.reason === 'PERMISSION_DENIED' || + : pairAvailability.version?.reason === 'PERMISSION_DENIED' || + pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND @@ -135,10 +230,17 @@ export function getReferenceInfo( id: publishedId, availability, preview: { - draft: (value.draft.snapshot || undefined) as PreviewDocumentValue | undefined, - published: (value.published.snapshot || undefined) as + draft: (isRecord(value.draft.snapshot) ? value.draft.snapshot : undefined) as | PreviewDocumentValue | undefined, + published: (isRecord(value.published.snapshot) + ? value.published.snapshot + : undefined) as PreviewDocumentValue | undefined, + version: (isRecord(value.version?.snapshot) + ? value.version.snapshot + : undefined) as PreviewDocumentValue | undefined, + + versions: isRecord(value.versions) ? value.versions : {}, }, } }), @@ -186,6 +288,13 @@ export function referenceSearch( }) return search(textTerm, {includeDrafts: true}).pipe( map(({hits}) => hits.map(({hit}) => hit)), + map((docs) => + docs.map((doc) => ({ + ...doc, + // Pass the original id if available, it could be a `draftId` or a `versionId` , the _id will be the published one when using perspectives to query the data. + _id: (doc._originalId as string) || doc._id, + })), + ), map((docs) => collate(docs)), // pick the 100 best matches map((collated) => collated.slice(0, 100)), diff --git a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx index 30eb1c4662e..75be2e92bdf 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -14,6 +14,7 @@ import {catchError, mergeMap} from 'rxjs/operators' import {type FIXME} from '../../../../FIXME' import {useSchema} from '../../../../hooks' +import {usePerspective} from '../../../../perspective/usePerspective' import {useDocumentPreviewStore} from '../../../../store' import {useSource} from '../../../../studio' import {useSearchMaxFieldDepth} from '../../../../studio/components/navbar/search/hooks/useSearchMaxFieldDepth' @@ -61,9 +62,11 @@ type SearchError = { export function StudioReferenceInput(props: StudioReferenceInputProps) { const source = useSource() const searchClient = source.getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const {perspectiveStack} = usePerspective() const schema = useSchema() const maxFieldDepth = useSearchMaxFieldDepth() const documentPreviewStore = useDocumentPreviewStore() + const {selectedReleaseId} = usePerspective() const {path, schemaType} = props const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -90,6 +93,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { tag: 'search.reference', maxFieldDepth, strategy: searchStrategy, + perspective: perspectiveStack, }), ), @@ -102,7 +106,16 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { }), ), - [schemaType, documentRef, path, getClient, searchClient, maxFieldDepth, searchStrategy], + [ + schemaType, + documentRef, + path, + getClient, + searchClient, + maxFieldDepth, + searchStrategy, + perspectiveStack, + ], ) const template = props.value?._strengthenOnPublish?.template @@ -131,6 +144,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { id: event.id, type: event.type, template: event.template, + version: event.version, }) }, [onEditReference, path], @@ -190,6 +204,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { editReferenceLinkComponent={EditReferenceLink} createOptions={createOptions} onEditReference={handleEditReference} + version={selectedReleaseId} /> ) } diff --git a/packages/sanity/src/core/form/types/fieldProps.ts b/packages/sanity/src/core/form/types/fieldProps.ts index 4dda84e5f85..f3ff9c41fce 100644 --- a/packages/sanity/src/core/form/types/fieldProps.ts +++ b/packages/sanity/src/core/form/types/fieldProps.ts @@ -60,6 +60,7 @@ export interface BaseFieldProps { index: number changed: boolean children: ReactNode + version?: string renderDefault: (props: FieldProps) => React.JSX.Element } diff --git a/packages/sanity/src/core/hooks/useConnectionState.ts b/packages/sanity/src/core/hooks/useConnectionState.ts index 2405544535b..b23971a9104 100644 --- a/packages/sanity/src/core/hooks/useConnectionState.ts +++ b/packages/sanity/src/core/hooks/useConnectionState.ts @@ -11,12 +11,16 @@ export type ConnectionState = 'connecting' | 'reconnecting' | 'connected' const INITIAL: ConnectionState = 'connecting' /** @internal */ -export function useConnectionState(publishedDocId: string, docTypeName: string): ConnectionState { +export function useConnectionState( + publishedDocId: string, + docTypeName: string, + {version}: {version?: string} = {}, +): ConnectionState { const documentStore = useDocumentStore() const observable = useMemo( () => - documentStore.pair.documentEvents(publishedDocId, docTypeName).pipe( + documentStore.pair.documentEvents(publishedDocId, docTypeName, version).pipe( map((ev: {type: string}) => ev.type), map((eventType) => eventType !== 'reconnect'), switchMap((isConnected) => @@ -25,7 +29,7 @@ export function useConnectionState(publishedDocId: string, docTypeName: string): startWith(INITIAL as any), distinctUntilChanged(), ), - [docTypeName, documentStore.pair, publishedDocId], + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/hooks/useDocumentOperation.ts b/packages/sanity/src/core/hooks/useDocumentOperation.ts index 4e67302fd06..105de51f1b3 100644 --- a/packages/sanity/src/core/hooks/useDocumentOperation.ts +++ b/packages/sanity/src/core/hooks/useDocumentOperation.ts @@ -4,11 +4,15 @@ import {useObservable} from 'react-rx' import {type OperationsAPI, useDocumentStore} from '../store' /** @internal */ -export function useDocumentOperation(publishedDocId: string, docTypeName: string): OperationsAPI { +export function useDocumentOperation( + publishedDocId: string, + docTypeName: string, + version?: string, +): OperationsAPI { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.editOperations(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.editOperations(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) /** * We know that since the observable has a startWith operator, it will always emit a value diff --git a/packages/sanity/src/core/hooks/useEditState.ts b/packages/sanity/src/core/hooks/useEditState.ts index 6d2650b73cf..aec7be4afad 100644 --- a/packages/sanity/src/core/hooks/useEditState.ts +++ b/packages/sanity/src/core/hooks/useEditState.ts @@ -9,12 +9,16 @@ export function useEditState( publishedDocId: string, docTypeName: string, priority: 'default' | 'low' = 'default', + version?: string | undefined, ): EditStateFor { + if (version === 'published' || version === 'draft') { + throw new Error('Version cannot be published or daft') + } const documentStore = useDocumentStore() const observable = useMemo(() => { if (priority === 'low') { - const base = documentStore.pair.editState(publishedDocId, docTypeName).pipe(share()) + const base = documentStore.pair.editState(publishedDocId, docTypeName, version).pipe(share()) return merge( base.pipe(take(1)), @@ -25,8 +29,8 @@ export function useEditState( ) } - return documentStore.pair.editState(publishedDocId, docTypeName) - }, [docTypeName, documentStore.pair, priority, publishedDocId]) + return documentStore.pair.editState(publishedDocId, docTypeName, version) + }, [docTypeName, documentStore.pair, priority, publishedDocId, version]) /** * We know that since the observable has a startWith operator, it will always emit a value * and that's why the non-null assertion is used here diff --git a/packages/sanity/src/core/hooks/useSyncState.ts b/packages/sanity/src/core/hooks/useSyncState.ts index 385d3205a25..65888a19558 100644 --- a/packages/sanity/src/core/hooks/useSyncState.ts +++ b/packages/sanity/src/core/hooks/useSyncState.ts @@ -14,15 +14,19 @@ const SYNCING = {isSyncing: true} const NOT_SYNCING = {isSyncing: false} /** @internal */ -export function useSyncState(publishedDocId: string, documentType: string): SyncState { +export function useSyncState( + publishedDocId: string, + documentType: string, + {version}: {version?: string} = {}, +): SyncState { const documentStore = useDocumentStore() const observable = useMemo( () => documentStore.pair - .consistencyStatus(publishedDocId, documentType) + .consistencyStatus(publishedDocId, documentType, version) .pipe(map((isConsistent) => (isConsistent ? NOT_SYNCING : SYNCING))), - [documentStore.pair, documentType, publishedDocId], + [documentStore.pair, documentType, publishedDocId, version], ) return useObservable>(observable, NOT_SYNCING) } diff --git a/packages/sanity/src/core/hooks/useValidationStatus.ts b/packages/sanity/src/core/hooks/useValidationStatus.ts index 165e059d932..2411b0eabe8 100644 --- a/packages/sanity/src/core/hooks/useValidationStatus.ts +++ b/packages/sanity/src/core/hooks/useValidationStatus.ts @@ -7,12 +7,16 @@ import {type ValidationStatus} from '../validation' const INITIAL: ValidationStatus = {validation: [], isValidating: false} /** @internal */ -export function useValidationStatus(publishedDocId: string, docTypeName: string): ValidationStatus { +export function useValidationStatus( + publishedDocId: string, + docTypeName: string, + version?: string, +): ValidationStatus { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.validation(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.validation(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts index 1dfd634d41c..c0d3f1135dd 100644 --- a/packages/sanity/src/core/i18n/bundles/studio.ts +++ b/packages/sanity/src/core/i18n/bundles/studio.ts @@ -125,6 +125,10 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Text shown in usage dialog for an image asset when there are zero, one or more documents using the *unnamed* image **/ 'asset-source.usage-list.documents-using-image_unnamed_zero': 'No documents are using this image', + /** Label when a release has been deleted by a different user */ + 'banners.deleted-bundle-banner.text': + "The '{{title}}' release has been deleted.", + /** Action message for navigating to next month */ 'calendar.action.go-to-next-month': 'Go to next month', /** Action message for navigating to next year */ @@ -237,6 +241,10 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'changes.error-boundary.developer-info': 'Check the developer console for more information', /** Text shown when a diff component crashes during rendering, triggering the error boundary */ 'changes.error-boundary.title': 'Rendering the changes to this field caused an error', + /* Error description when changes could not be loaded */ + 'changes.error-description': "We're unable to load the changes for this document.", + /** Error title when changes could not be loaded */ + 'changes.error-title': 'Something went wrong', /** Error message shown when the value of a field is not the expected one */ 'changes.error.incorrect-type-message': 'Value error: Value is of type "{{actualType}}", expected "{{expectedType}}"', @@ -271,6 +279,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'Edit the document or select an older version in the timeline to see a list of changes appear in this panel.', /** No Changes title in the Review Changes pane */ 'changes.no-changes-title': 'There are no changes', + /* Label for the tooltip that shows when an action is not selectable*/ + 'changes.not-selectable': 'It is not possible to select this event', /** Portable Text diff: An annotation was added */ 'changes.portable-text.annotation_added': 'Added annotation', /** Portable Text diff: An annotation was changed */ @@ -314,6 +324,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'changes.removed-label': 'Removed', /** Title for the Review Changes pane */ 'changes.title': 'History', + /**The title that will be shown in the badge inside the events when the item is a draft */ + 'changes.versions.draft': 'Draft', /** --- Common components --- */ /** Tooltip text for context menu buttons */ @@ -353,6 +365,11 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Title for the default ordering/SortOrder if no orderings are provided and the title field is found */ 'default-orderings.title': 'Sort by Title', + /** Label to show in the document footer indicating the creation date of the document */ + 'document-status.created': 'Created {{date}}', + + /** Label to show in the document status indicating the date of the status */ + 'document-status.date': '{{date}}', /** Label to show in the document footer indicating the last edited date of the document */ 'document-status.edited': 'Edited {{date}}', /** Label to show in the document footer indicating the document is not published*/ @@ -542,6 +559,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'inputs.array.resolving-initial-value': 'Resolving initial value…', /** Tooltip content when boolean input is disabled */ 'inputs.boolean.disabled': 'Disabled', + /** Warning label when selected datetime is in the past */ + 'inputs.dateTime.past-date-warning': 'Select a date in the future.', /** Placeholder value for datetime input */ 'inputs.datetime.placeholder': 'e.g. {{example}}', /** Acessibility label for button to open file options menu */ @@ -1095,6 +1114,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { * when there are templates/types available for creation */ 'new-document.create-new-document-label': 'New document…', + /** Tooltip message for add document button when the selected perspective is for published or inactive release */ + 'new-document.disabled-release.tooltip': 'You cannot add documents to this release', /** Placeholder for the "filter" input within the new document menu */ 'new-document.filter-placeholder': 'Search document types', /** Loading indicator text within the new document menu */ @@ -1141,6 +1162,102 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /* Relative time, just now */ 'relative-time.just-now': 'just now', + /** Action message to add document to new release */ + 'release.action.add-to-new-release': 'Add to release', + /** Action message to add document to release */ + 'release.action.add-to-release': 'Add to {{title}}', + /** Action message for when document is already in release */ + 'release.action.already-in-release': 'Already in release {{title}}', + /** Action message for when you click to view all versions you can copy the current document to */ + 'release.action.copy-to': 'Copy version to', + /** Action message for creating new releases */ + 'release.action.create-new': 'New release', + /** Action message for when document is already in release */ + 'release.action.discard-version': 'Discard version', + /** Description for toast when version discarding failed */ + 'release.action.discard-version.failure': 'Failed to discard version', + /** Description for toast when version deletion is successfully discarded */ + 'release.action.discard-version.success': + '{{title}} version was successfully discarded', + /** Action message for when a new release is created off an existing version, draft or published document */ + 'release.action.new-release': 'New Release', + /** Error message for when a version is set to be unpublished */ + 'release.action.unpublish-version.failure': 'Failed to set version to be unpublished on release', + /** Action message for when a version is set to be unpublished successfully */ + 'release.action.unpublish-version.success': + 'Successfully set {{title}} to be unpublished on release', + /** Action message for when the view release is pressed */ + 'release.action.view-release': 'View release', + /** Label for banner when release is scheduled */ + 'release.banner.scheduled-for-publishing-on': 'Scheduled for publishing on {{date}}', + /** Label for Draft chip in document header */ + 'release.chip.draft': 'Draft', + /** Label for Draft chip in global header */ + 'release.chip.global.drafts': 'Drafts', + /** Label for Published chip in document header */ + 'release.chip.published': 'Published', + /** Label for tooltip in chip with the created date */ + 'release.chip.tooltip.created-date': 'Created {{date}}', + /** Label for tooltip in chip with the lasted edited date */ + 'release.chip.tooltip.edited-date': 'Edited {{date}}', + /** Label for tooltip in chip when document is intended for a future release that hasn't been scheduled */ + 'release.chip.tooltip.intended-for-date': 'Intended for {{date}}', + /** Label for tooltip in chip when there is no recent draft edits */ + 'release.chip.tooltip.no-edits': 'No edits', + /** Label for tooltip in chip when document isn't published */ + 'release.chip.tooltip.not-published': 'Not published', + /** Label for tooltip in chip with the published date */ + 'release.chip.tooltip.published-date': 'Published {{date}}', + /** Label for tooltip in chip when document is in a release that has been scheduled */ + 'release.chip.tooltip.scheduled-for-date': 'Scheduled for {{date}}', + /** Label for tooltip in scheduled chip without a known date */ + 'release.chip.tooltip.unknown-date': 'Unknown date', + /** Label for tooltip on deleted release */ + 'release.deleted-tooltip': 'This release has been deleted', + /** Title for copying version to a new release dialog */ + 'release.dialog.copy-to-release.title': 'Copy version to new release', + /** Title for creating releases dialog */ + 'release.dialog.create.title': 'Create release', + /** Label for description in tooltip to explain release types */ + 'release.dialog.tooltip.description': + 'This makes it possible to show whether documents are in conflict when working on multiple versions.', + /** Label for noting that a release time is not final */ + 'release.dialog.tooltip.note': + 'NOTE: You may change the time of release and set an exact time for scheduled publishing later.', + /** Title for tooltip to explain release time */ + 'release.dialog.tooltip.title': 'Approximate time of release', + /** The placeholder text when the release doesn't have a description */ + 'release.form.placeholer-describe-release': 'Describe the release…', + /** Tooltip for button to hide release visibility */ + 'release.layer.hide': 'Hide release', + /** Label for draft perspective in navbar */ + 'release.navbar.drafts': 'Drafts', + /** Label for published releases in navbar */ + 'release.navbar.published': 'Published', + /** Tooltip for releases navigation in navbar */ + 'release.navbar.tooltip': 'Releases', + /** The placeholder text when the release doesn't have a title */ + 'release.placeholder-untitled-release': 'Untitled release', + /** The toast title that will be shown when the user has a release perspective which is now archived */ + 'release.toast.archived-release.title': "The '{{title}}' release was archived", + /** The toast tiele that will be shown the creating a release fails */ + 'release.toast.create-release-error.title': 'Failed to create release', + /**The toast title that will be shown when the user has a release perspective which is now deleted */ + 'release.toast.not-found-release.title': "The '{{title}}' release could not be found", + /** Label for when a version of a document has already been added to the release */ + 'release.tooltip.already-added': 'A version of this document has already been added', + /** Label for when a release is scheduled / scheduling and a user can't add a document version to it */ + 'release.tooltip.locked': + 'This release has been scheduled. Unsechedule it to add more documents.', + /** Label for the release type 'as soon as possible' */ + 'release.type.asap': 'ASAP', + /** Label for the release type 'at time', meaning it's a release with a scheduled date */ + 'release.type.scheduled': 'At time', + /** Label for the release type 'undecided' */ + 'release.type.undecided': 'Undecided', + /** Tooltip for the dropdown to show all versions of document */ + 'release.version-list.tooltip': 'See all document versions', + /** Accessibility label to open search action when the search would go fullscreen (eg on narrower screens) */ 'search.action-open-aria-label': 'Open search', /** Action label for adding a search filter */ @@ -1609,6 +1726,12 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /** Title for error when the timeline for the given document can't be loaded */ 'timeline.error.load-document-changes-title': 'An error occurred whilst retrieving document changes.', + /** Description for error when the timeline for the given document can't be loaded */ + 'timeline.error.load-document-changes-version-description': + 'Enable the events API through the Studio config to view document history.', + /** Title for error when the timeline for the given version document can't be loaded */ + 'timeline.error.load-document-changes-version-title': + 'Version documents history is only available through the Events API.', /** Error description for when the document doesn't have history */ 'timeline.error.no-document-history-description': 'When changing the content of the document, the document versions will appear in this menu.', @@ -1630,6 +1753,8 @@ export const studioLocaleStrings = defineLocalesResources('studio', { 'timeline.list.aria-label': 'Document revisions', /** Label for loading history */ 'timeline.loading-history': 'Loading history…', + /* Label for when no previous since events are available*/ + 'timeline.no-previous-events': 'No previous events', /** Label shown in review changes timeline when a document has been created */ 'timeline.operation.created': 'Created', /** Label shown in review changes timeline when a document was initially created */ diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index 945e7796488..08616edebe0 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -11,12 +11,44 @@ export * from './FIXME' export * from './form' export * from './hooks' export * from './i18n' +export {PerspectiveProvider} from './perspective/PerspectiveProvider' +export { + type PerspectiveContextValue, + type PerspectiveStack, + type SelectedPerspective, +} from './perspective/types' +export {useExcludedPerspective} from './perspective/useExcludedPerspective' +export {usePerspective} from './perspective/usePerspective' +export {useSetPerspective} from './perspective/useSetPerspective' export * from './presence' export * from './preview' +export { + AddedVersion, + DiscardVersionDialog, + formatRelativeLocalePublishDate, + getPublishDateFromRelease, + getReleaseIdFromReleaseDocumentId, + getReleaseTone, + isDraftPerspective, + isPublishedPerspective, + isReleaseDocument, + isReleaseScheduledOrScheduling, + LATEST, + type ReleaseDocument, + RELEASES_INTENT, + useActiveReleases, + useArchivedReleases, + useDocumentVersions, + useIsReleaseActive, + useReleasesIds, + useVersionOperations, + VersionChip, + VersionInlineBadge, +} from './releases' export * from './scheduledPublishing' export * from './schema' export type {SearchFactoryOptions, SearchOptions, SearchSort, SearchTerms} from './search' -export {createSearch, getSearchableTypes} from './search' +export {createSearch, getSearchableTypes, isPerspectiveRaw} from './search' export * from './store' export * from './studio' export * from './studioClient' diff --git a/packages/sanity/src/core/perspective/GlobalPerspectiveProvider.tsx b/packages/sanity/src/core/perspective/GlobalPerspectiveProvider.tsx new file mode 100644 index 00000000000..d2efb0f2c7b --- /dev/null +++ b/packages/sanity/src/core/perspective/GlobalPerspectiveProvider.tsx @@ -0,0 +1,102 @@ +import {type ReleaseId} from '@sanity/client' +import {Text, useToast} from '@sanity/ui' +import {type ReactNode, useEffect, useMemo} from 'react' +import {useRouter} from 'sanity/router' + +import {useTranslation} from '../i18n/hooks/useTranslation' +import {Translate} from '../i18n/Translate' +import {useActiveReleases} from '../releases/store/useActiveReleases' +import {useArchivedReleases} from '../releases/store/useArchivedReleases' +import {LATEST} from '../releases/util/const' +import {getReleaseIdFromReleaseDocumentId} from '../releases/util/getReleaseIdFromReleaseDocumentId' +import {isPublishedPerspective} from '../releases/util/util' +import {EMPTY_ARRAY} from '../util/empty' +import {PerspectiveProvider} from './PerspectiveProvider' +import {usePerspective} from './usePerspective' +import {useSetPerspective} from './useSetPerspective' + +const ResetPerspectiveHandler = () => { + const toast = useToast() + const {t} = useTranslation() + const {data: releases, loading: releasesLoading} = useActiveReleases() + const {data: archivedReleases} = useArchivedReleases() + const {selectedPerspectiveName} = usePerspective() + const setPerspective = useSetPerspective() + + useEffect(() => { + // clear the perspective param when it is not an active release + if ( + releasesLoading || + !selectedPerspectiveName || + isPublishedPerspective(selectedPerspectiveName) + ) + return + const isCurrentPerspectiveValid = releases.some( + (release) => getReleaseIdFromReleaseDocumentId(release._id) === selectedPerspectiveName, + ) + if (!isCurrentPerspectiveValid) { + setPerspective(LATEST) + const archived = archivedReleases.find( + (r) => getReleaseIdFromReleaseDocumentId(r._id) === selectedPerspectiveName, + ) + + toast.push({ + id: `bundle-deleted-toast-${selectedPerspectiveName}`, + status: 'warning', + title: ( + + + + ), + duration: 10000, + }) + } + }, [ + archivedReleases, + selectedPerspectiveName, + releases, + releasesLoading, + setPerspective, + toast, + t, + ]) + return null +} + +/** + * This component is not meant to be exported by `sanity`, it's meant only for internal use from the `` file. + * It sets the `` listening to the changes happening in the router. + * + * If you need to add the PerspectiveProvider you should use that component directly. + * It's up to you to define how the selectedPerspectiveName and excludedPerspectives should worl. + */ +export function GlobalPerspectiveProvider({children}: {children: ReactNode}) { + const router = useRouter() + + const selectedPerspectiveName = router.stickyParams.perspective as + | 'published' + | ReleaseId + | undefined + + const excludedPerspectives = useMemo( + () => router.stickyParams.excludedPerspectives?.split(',') || EMPTY_ARRAY, + [router.stickyParams.excludedPerspectives], + ) + return ( + + {children} + + + ) +} diff --git a/packages/sanity/src/core/perspective/PerspectiveProvider.tsx b/packages/sanity/src/core/perspective/PerspectiveProvider.tsx new file mode 100644 index 00000000000..315bff9515e --- /dev/null +++ b/packages/sanity/src/core/perspective/PerspectiveProvider.tsx @@ -0,0 +1,56 @@ +import {type ReleaseId} from '@sanity/client' +import {useMemo} from 'react' +import {PerspectiveContext} from 'sanity/_singletons' + +import {getReleasesPerspectiveStack} from '../releases/hooks/utils' +import {useActiveReleases} from '../releases/store/useActiveReleases' +import {getReleaseIdFromReleaseDocumentId} from '../releases/util/getReleaseIdFromReleaseDocumentId' +import {EMPTY_ARRAY} from '../util/empty' +import {type PerspectiveContextValue, type SelectedPerspective} from './types' + +/** + * @internal + */ +export function PerspectiveProvider({ + children, + selectedPerspectiveName, + excludedPerspectives = EMPTY_ARRAY, +}: { + children: React.ReactNode + selectedPerspectiveName: 'published' | ReleaseId | undefined + excludedPerspectives?: string[] +}) { + const {data: releases} = useActiveReleases() + + const selectedPerspective: SelectedPerspective = useMemo(() => { + if (!selectedPerspectiveName) return 'drafts' + if (selectedPerspectiveName === 'published') return 'published' + const selectedRelease = releases.find( + (release) => getReleaseIdFromReleaseDocumentId(release._id) === selectedPerspectiveName, + ) + return selectedRelease || 'drafts' + }, [selectedPerspectiveName, releases]) + + const perspectiveStack = useMemo( + () => + getReleasesPerspectiveStack({ + releases, + selectedPerspectiveName, + excludedPerspectives, + }), + [releases, selectedPerspectiveName, excludedPerspectives], + ) + + const value: PerspectiveContextValue = useMemo( + () => ({ + selectedPerspective, + selectedPerspectiveName, + selectedReleaseId: + selectedPerspectiveName === 'published' ? undefined : selectedPerspectiveName, + perspectiveStack, + excludedPerspectives, + }), + [selectedPerspective, selectedPerspectiveName, perspectiveStack, excludedPerspectives], + ) + return {children} +} diff --git a/packages/sanity/src/core/perspective/ReleasesToolLink.tsx b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx new file mode 100644 index 00000000000..b4f5a213631 --- /dev/null +++ b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx @@ -0,0 +1,43 @@ +import {CalendarIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button +import {Box, Button} from '@sanity/ui' +import {useCallback} from 'react' +import {useTranslation} from 'react-i18next' +import {useRouterState} from 'sanity/router' + +import {Tooltip} from '../../ui-components/tooltip/Tooltip' +import {RELEASES_TOOL_NAME} from '../releases/plugin' +import {ToolLink} from '../studio/components/navbar/tools/ToolLink' + +/** + * represents the calendar icon for the releases tool. + * It will be hidden if users have turned off releases. + */ +export function ReleasesToolLink(): React.JSX.Element { + const {t} = useTranslation() + + const activeToolName = useRouterState( + useCallback( + (routerState) => (typeof routerState.tool === 'string' ? routerState.tool : undefined), + [], + ), + ) + + return ( + + + + + + ) +} diff --git a/packages/sanity/src/core/perspective/__mocks__/useExcludedPerspective.mock.ts b/packages/sanity/src/core/perspective/__mocks__/useExcludedPerspective.mock.ts new file mode 100644 index 00000000000..847e079ca25 --- /dev/null +++ b/packages/sanity/src/core/perspective/__mocks__/useExcludedPerspective.mock.ts @@ -0,0 +1,13 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {type ExcludedPerspectiveValue, useExcludedPerspective} from '../useExcludedPerspective' + +export const useExcludedPerspectiveMockReturn: Mocked = { + excludedPerspectives: [], + toggleExcludedPerspective: vi.fn(), + isPerspectiveExcluded: vi.fn().mockReturnValue(false), +} + +export const mockUseExcludedPerspective = useExcludedPerspective as Mock< + typeof useExcludedPerspective +> diff --git a/packages/sanity/src/core/perspective/__mocks__/usePerspective.mock.ts b/packages/sanity/src/core/perspective/__mocks__/usePerspective.mock.ts new file mode 100644 index 00000000000..950de2ab0a3 --- /dev/null +++ b/packages/sanity/src/core/perspective/__mocks__/usePerspective.mock.ts @@ -0,0 +1,15 @@ +import {type Mock, type Mocked} from 'vitest' + +import {type PerspectiveContextValue} from '../types' +import {usePerspective} from '../usePerspective' + +export const perspectiveContextValueMock: Mocked = { + selectedPerspectiveName: undefined, + selectedReleaseId: undefined, + selectedPerspective: 'drafts', + perspectiveStack: [], + excludedPerspectives: [], +} +export const usePerspectiveMockReturn = perspectiveContextValueMock + +export const mockUsePerspective = usePerspective as Mock diff --git a/packages/sanity/src/core/perspective/__mocks__/useSetPerspective.mock.ts b/packages/sanity/src/core/perspective/__mocks__/useSetPerspective.mock.ts new file mode 100644 index 00000000000..36970f87e7f --- /dev/null +++ b/packages/sanity/src/core/perspective/__mocks__/useSetPerspective.mock.ts @@ -0,0 +1,10 @@ +import {type ReleaseId} from '@sanity/client' +import {type Mock, type Mocked, vi} from 'vitest' + +import {useSetPerspective} from '../useSetPerspective' + +export const useSetPerspectiveMockReturn: Mocked< + (releaseId: 'published' | 'drafts' | ReleaseId | undefined) => void +> = vi.fn() + +export const mockUseSetPerspective = useSetPerspective as Mock diff --git a/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenu.tsx b/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenu.tsx new file mode 100644 index 00000000000..a66b58dc148 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenu.tsx @@ -0,0 +1,77 @@ +import {type ReleaseId} from '@sanity/client' +import {ChevronDownIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- Button requires props, only supported by @sanity/ui +import {Button, Menu} from '@sanity/ui' +import {useCallback, useRef, useState} from 'react' +import {styled} from 'styled-components' + +import {MenuButton} from '../../../ui-components' +import {CreateReleaseDialog} from '../../releases/components/dialog/CreateReleaseDialog' +import {ReleasesList} from './ReleasesList' +import {useScrollIndicatorVisibility} from './useScrollIndicatorVisibility' + +const StyledMenu = styled(Menu)` + min-width: 200px; + max-width: 320px; +` + +export function GlobalPerspectiveMenu({ + selectedReleaseId, + areReleasesEnabled = true, +}: { + selectedReleaseId: ReleaseId | undefined + areReleasesEnabled: boolean +}): React.JSX.Element { + const [createBundleDialogOpen, setCreateBundleDialogOpen] = useState(false) + const styledMenuRef = useRef(null) + + const {isRangeVisible, onScroll, resetRangeVisibility, setScrollContainer, scrollElementRef} = + useScrollIndicatorVisibility() + + const handleClose = useCallback(() => { + setCreateBundleDialogOpen(false) + }, []) + + return ( + <> + + } + id="releases-menu" + onClose={resetRangeVisibility} + menu={ + + + + } + popover={{ + constrainSize: true, + fallbackPlacements: ['bottom-end'], + placement: 'bottom-end', + portal: true, + tone: 'default', + zOffset: 3000, + }} + /> + {createBundleDialogOpen && ( + + )} + > + ) +} diff --git a/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenuItem.tsx b/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenuItem.tsx new file mode 100644 index 00000000000..1e8f1b3c79e --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/GlobalPerspectiveMenuItem.tsx @@ -0,0 +1,210 @@ +import {DotIcon, EyeClosedIcon, EyeOpenIcon, LockIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- custom use for MenuItem & Button not supported by ui-components +import {Box, Button, Flex, MenuItem, Stack, Text} from '@sanity/ui' +import {type CSSProperties, forwardRef, type MouseEvent, useCallback, useMemo} from 'react' +import {css, styled} from 'styled-components' + +import {Tooltip} from '../../../ui-components/tooltip' +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {useExcludedPerspective} from '../../perspective/useExcludedPerspective' +import {usePerspective} from '../../perspective/usePerspective' +import {useSetPerspective} from '../../perspective/useSetPerspective' +import {ReleaseAvatar} from '../../releases/components/ReleaseAvatar' +import {isReleaseDocument, type ReleaseDocument} from '../../releases/store/types' +import {type LATEST} from '../../releases/util/const' +import {getReleaseIdFromReleaseDocumentId} from '../../releases/util/getReleaseIdFromReleaseDocumentId' +import {getReleaseTone} from '../../releases/util/getReleaseTone' +import { + formatRelativeLocalePublishDate, + isDraftPerspective, + isPublishedPerspective, + isReleaseScheduledOrScheduling, +} from '../../releases/util/util' +import {GlobalPerspectiveMenuItemIndicator} from './PerspectiveLayerIndicator' + +export interface LayerRange { + lastIndex: number + offsets: { + asap: number + scheduled: number + undecided: number + } +} + +const ToggleLayerButton = styled(Button)<{$visible: boolean}>( + ({$visible}) => css` + --card-fg-color: inherit; + --card-icon-color: inherit; + + background-color: inherit; + opacity: ${$visible ? 0 : 1}; + + @media (hover: hover) { + &:not([data-disabled='true']):hover { + --card-fg-color: inherit; + --card-icon-color: inherit; + } + } + + [data-ui='MenuItem']:hover & { + opacity: 1; + } + `, +) + +const ExcludedLayerDot = () => ( + + + + + +) + +type rangePosition = 'first' | 'within' | 'last' | undefined + +export function getRangePosition(range: LayerRange, index: number): rangePosition { + const {lastIndex} = range + + if (lastIndex === 0) return undefined + if (index === 0) return 'first' + if (index === lastIndex) return 'last' + if (index > 0 && index < lastIndex) return 'within' + + return undefined +} + +export const GlobalPerspectiveMenuItem = forwardRef< + HTMLDivElement, + { + release: ReleaseDocument | 'published' | typeof LATEST + rangePosition: rangePosition + } +>((props, ref) => { + const {release, rangePosition} = props + const {selectedPerspective, selectedPerspectiveName} = usePerspective() + const setPerspective = useSetPerspective() + const {toggleExcludedPerspective, isPerspectiveExcluded} = useExcludedPerspective() + const releaseId = isReleaseDocument(release) + ? getReleaseIdFromReleaseDocumentId(release._id) + : release + + const active = selectedPerspectiveName + ? releaseId === selectedPerspectiveName + : isDraftPerspective(release) + + const isReleasePerspectiveExcluded = isPerspectiveExcluded(releaseId) + + const {t} = useTranslation() + + const displayTitle = useMemo(() => { + if (isPublishedPerspective(release)) return t('release.navbar.published') + if (isDraftPerspective(release)) return t('release.navbar.drafts') + + return release.metadata.title || t('release.placeholder-untitled-release') + }, [release, t]) + + const handleToggleReleaseVisibility = useCallback( + (event: MouseEvent) => { + event.stopPropagation() + toggleExcludedPerspective(releaseId) + }, + [toggleExcludedPerspective, releaseId], + ) + + const handleOnReleaseClick = useCallback( + () => setPerspective(releaseId), + [releaseId, setPerspective], + ) + + const canReleaseBeExcluded = useMemo(() => { + if (release === 'published') return false + if (isDraftPerspective(release)) return isReleaseDocument(selectedPerspective) + if (isReleaseScheduledOrScheduling(release)) return false + return rangePosition && ['first', 'within'].includes(rangePosition) + }, [rangePosition, release, selectedPerspective]) + + return ( + + + + + + {isReleasePerspectiveExcluded ? ( + + ) : ( + + )} + + + + + {displayTitle} + + {isReleaseDocument(release) && + release.metadata.releaseType === 'scheduled' && + (release.publishAt || release.metadata.intendedPublishAt) && ( + + {formatRelativeLocalePublishDate(release)} + + )} + + + {canReleaseBeExcluded && ( + + + + )} + {isReleaseDocument(release) && isReleaseScheduledOrScheduling(release) && ( + + + + + + )} + + + + + ) +}) + +GlobalPerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem' diff --git a/packages/sanity/src/core/perspective/navbar/PerspectiveLayerIndicator.tsx b/packages/sanity/src/core/perspective/navbar/PerspectiveLayerIndicator.tsx new file mode 100644 index 00000000000..87b32bd6884 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/PerspectiveLayerIndicator.tsx @@ -0,0 +1,114 @@ +import {Box} from '@sanity/ui' +import {css, styled} from 'styled-components' + +const INDICATOR_LEFT_OFFSET = 18 +const INDICATOR_WIDTH = 5 +const INDICATOR_COLOR_VAR_NAME = '--card-border-color' +const INDICATOR_BOTTOM_OFFSET = 4 + +export const GlobalPerspectiveMenuItemIndicator = styled.div<{ + $inRange: boolean + $last: boolean + $first: boolean + $isDraft: boolean +}>( + ({$inRange, $last, $first, $isDraft}) => css` + position: relative; + + --indicator-left: ${INDICATOR_LEFT_OFFSET}px; + --indicator-width: ${INDICATOR_WIDTH}px; + --indicator-color: var(${INDICATOR_COLOR_VAR_NAME}); + --indicator-bottom: ${INDICATOR_BOTTOM_OFFSET}px; + + --indicator-in-range-height: 16.5px; + + ${$inRange && + !$last && + css` + &:after { + content: ''; + display: block; + position: absolute; + left: var(--indicator-left); + bottom: -var(--indicator-bottom); + width: var(--indicator-width); + height: ${$isDraft ? 'calc(var(--indicator-bottom) + 12px)' : 'var(--indicator-bottom)'}; + background-color: var(--indicator-color); + } + `} + + ${$inRange && + css` + > [data-ui='MenuItem'] { + position: relative; + + &:before, + &:after { + content: ''; + display: block; + position: absolute; + left: var(--indicator-left); + width: var(--indicator-width); + background-color: var(--indicator-color); + } + + &:before { + top: 0; + height: var(--indicator-in-range-height); + } + + &:after { + top: var(--indicator-in-range-height); + bottom: 0; + } + } + `} + + ${$first && + css` + > [data-ui='MenuItem']:after { + margin-top: -3px; + border-top-left-radius: ${INDICATOR_WIDTH}px; + border-top-right-radius: ${INDICATOR_WIDTH}px; + } + > [data-ui='MenuItem']:before { + display: none; + } + `} + + ${$last && + css` + > [data-ui='MenuItem']:before { + // dot diameter (5px) - 1.6px stroke divided by 2 + padding-bottom: 1.7px; + border-bottom-left-radius: ${INDICATOR_WIDTH}px; + border-bottom-right-radius: ${INDICATOR_WIDTH}px; + } + > [data-ui='MenuItem']:after { + display: none; + } + `} + `, +) + +export const GlobalPerspectiveMenuLabelIndicator = styled(Box)<{$withinRange: boolean}>( + ({$withinRange}) => css` + position: relative; + // 4px padding + 33px release indicator width + 4px gap + padding-left: 41px; + + ${$withinRange && + css` + &:before { + content: ''; + display: block; + position: absolute; + left: ${INDICATOR_LEFT_OFFSET}px; + top: 0; + bottom: -${INDICATOR_BOTTOM_OFFSET}px; + width: ${INDICATOR_WIDTH}px; + background-color: var(${INDICATOR_COLOR_VAR_NAME}); + } + `} + `, +) diff --git a/packages/sanity/src/core/perspective/navbar/ReleaseTypeMenuSection.tsx b/packages/sanity/src/core/perspective/navbar/ReleaseTypeMenuSection.tsx new file mode 100644 index 00000000000..98ff10fc28a --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/ReleaseTypeMenuSection.tsx @@ -0,0 +1,74 @@ +import {type ReleaseId} from '@sanity/client' +import {Flex, Label} from '@sanity/ui' +import {useCallback} from 'react' + +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {usePerspective} from '../../perspective/usePerspective' +import {type ReleaseDocument, type ReleaseType} from '../../releases/store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../releases/util/getReleaseIdFromReleaseDocumentId' +import { + getRangePosition, + GlobalPerspectiveMenuItem, + type LayerRange, +} from './GlobalPerspectiveMenuItem' +import {GlobalPerspectiveMenuLabelIndicator} from './PerspectiveLayerIndicator' +import {type ScrollElement} from './useScrollIndicatorVisibility' + +const RELEASE_TYPE_LABELS: Record = { + asap: 'release.type.asap', + scheduled: 'release.type.scheduled', + undecided: 'release.type.undecided', +} + +export function ReleaseTypeMenuSection({ + releaseType, + releases, + range, + currentGlobalBundleMenuItemRef, +}: { + releaseType: ReleaseType + releases: ReleaseDocument[] + range: LayerRange + currentGlobalBundleMenuItemRef: React.RefObject +}): React.JSX.Element | null { + const {t} = useTranslation() + const {selectedReleaseId} = usePerspective() + + const getMenuItemRef = useCallback( + (releaseId: ReleaseId) => + selectedReleaseId === releaseId + ? (currentGlobalBundleMenuItemRef as React.RefObject) + : undefined, + [currentGlobalBundleMenuItemRef, selectedReleaseId], + ) + + if (releases.length === 0) return null + + const {lastIndex, offsets} = range + const releaseTypeOffset = offsets[releaseType] + + return ( + <> + 0 && lastIndex >= releaseTypeOffset} + paddingRight={2} + paddingTop={releaseType === 'asap' ? 1 : 4} + paddingBottom={2} + > + + {t(RELEASE_TYPE_LABELS[releaseType])} + + + + {releases.map((release, index) => ( + + ))} + + > + ) +} diff --git a/packages/sanity/src/core/perspective/navbar/ReleasesList.tsx b/packages/sanity/src/core/perspective/navbar/ReleasesList.tsx new file mode 100644 index 00000000000..60dfd5d6d37 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/ReleasesList.tsx @@ -0,0 +1,157 @@ +import {AddIcon} from '@sanity/icons' +import {Box, Flex, MenuDivider, Spinner} from '@sanity/ui' +import {type RefObject, useCallback, useMemo} from 'react' +import {css, styled} from 'styled-components' + +import {MenuItem} from '../../../ui-components/menuItem/MenuItem' +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {type ReleaseDocument, type ReleaseType} from '../../releases/store/types' +import {useActiveReleases} from '../../releases/store/useActiveReleases' +import {LATEST} from '../../releases/util/const' +import {getReleaseIdFromReleaseDocumentId} from '../../releases/util/getReleaseIdFromReleaseDocumentId' +import { + getRangePosition, + GlobalPerspectiveMenuItem, + type LayerRange, +} from './GlobalPerspectiveMenuItem' +import {ReleaseTypeMenuSection} from './ReleaseTypeMenuSection' +import {type ScrollElement} from './useScrollIndicatorVisibility' + +const orderedReleaseTypes: ReleaseType[] = ['asap', 'scheduled', 'undecided'] + +const ASAP_RANGE_OFFSET = 2 + +const StyledBox = styled(Box)` + overflow: auto; + max-height: 75vh; +` + +const StyledPublishedBox = styled(Box)<{$removePadding: boolean}>( + ({$removePadding}) => css` + position: sticky; + top: 0; + background-color: var(--card-bg-color); + z-index: 10; + padding-bottom: ${$removePadding ? '4px' : '16px'}; + `, +) + +export function ReleasesList({ + areReleasesEnabled, + setScrollContainer, + onScroll, + isRangeVisible, + selectedReleaseId, + setCreateBundleDialogOpen, + scrollElementRef, +}: { + areReleasesEnabled: boolean + setScrollContainer: (el: HTMLDivElement) => void + onScroll: (event: React.UIEvent) => void + isRangeVisible: boolean + selectedReleaseId: string | undefined + setCreateBundleDialogOpen: (open: boolean) => void + scrollElementRef: RefObject +}): React.JSX.Element { + const {loading, data: releases} = useActiveReleases() + const {t} = useTranslation() + /* create new release */ + const handleCreateBundleClick = useCallback(() => { + setCreateBundleDialogOpen(true) + }, [setCreateBundleDialogOpen]) + + const sortedReleaseTypeReleases = useMemo( + () => + orderedReleaseTypes.reduce>( + (ReleaseTypeReleases, releaseType) => ({ + ...ReleaseTypeReleases, + [releaseType]: releases.filter(({metadata}) => metadata.releaseType === releaseType), + }), + {} as Record, + ), + [releases], + ) + + const range: LayerRange = useMemo(() => { + let lastIndex = 0 + + const {asap, scheduled} = sortedReleaseTypeReleases + const countAsapReleases = asap.length + const countScheduledReleases = scheduled.length + + const offsets = { + asap: ASAP_RANGE_OFFSET, + scheduled: ASAP_RANGE_OFFSET + countAsapReleases, + undecided: ASAP_RANGE_OFFSET + countAsapReleases + countScheduledReleases, + } + + const adjustIndexForReleaseType = (type: ReleaseType) => { + const groupSubsetReleases = sortedReleaseTypeReleases[type] + const offset = offsets[type] + + groupSubsetReleases.forEach((release, groupReleaseIndex) => { + const index = offset + groupReleaseIndex + + if (selectedReleaseId === getReleaseIdFromReleaseDocumentId(release._id)) { + lastIndex = index + } + }) + } + + orderedReleaseTypes.forEach(adjustIndexForReleaseType) + + return { + lastIndex, + offsets, + } + }, [selectedReleaseId, sortedReleaseTypeReleases]) + + if (loading) { + return ( + + + + ) + } + + return ( + + + + + + + {areReleasesEnabled && ( + <> + {orderedReleaseTypes.map((releaseType) => ( + + ))} + > + )} + + {areReleasesEnabled && ( + <> + + + > + )} + + ) +} diff --git a/packages/sanity/src/core/perspective/navbar/ReleasesNav.tsx b/packages/sanity/src/core/perspective/navbar/ReleasesNav.tsx new file mode 100644 index 00000000000..2a6b9355ba9 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/ReleasesNav.tsx @@ -0,0 +1,53 @@ +import {CloseIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button +import {Box, Button, Card, Flex} from '@sanity/ui' +import {AnimatePresence} from 'framer-motion' + +import {usePerspective} from '../../perspective/usePerspective' +import {useSetPerspective} from '../../perspective/useSetPerspective' +import {LATEST} from '../../releases/util/const' +import {isDraftPerspective} from '../../releases/util/util' +import {useWorkspace} from '../../studio' +import {ReleasesToolLink} from '../ReleasesToolLink' +import {CurrentGlobalPerspectiveLabel} from './currentGlobalPerspectiveLabel' +import {GlobalPerspectiveMenu} from './GlobalPerspectiveMenu' + +export function ReleasesNav(): React.JSX.Element { + const areReleasesEnabled = !!useWorkspace().releases?.enabled + + const {selectedPerspective, selectedReleaseId} = usePerspective() + const setPerspective = useSetPerspective() + + const handleClearPerspective = () => setPerspective(LATEST) + + return ( + + + {areReleasesEnabled && ( + + + + )} + + + + + {!isDraftPerspective(selectedPerspective) && ( + + + + )} + + + ) +} diff --git a/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesList.test.tsx b/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesList.test.tsx new file mode 100644 index 00000000000..a32e28adfa2 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesList.test.tsx @@ -0,0 +1,98 @@ +import {Menu} from '@sanity/ui' +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import { + activeASAPRelease, + activeScheduledRelease, + activeUndecidedRelease, +} from '../../../releases/__fixtures__/release.fixture' +import { + mockUseActiveReleases, + useActiveReleasesMockReturn, +} from '../../../releases/store/__tests__/__mocks/useActiveReleases.mock' +import {ReleasesList} from '../ReleasesList' + +vi.mock('../../../releases/store/useActiveReleases', () => ({ + useActiveReleases: vi.fn(() => useActiveReleasesMockReturn), +})) + +const setCreateBundleDialogOpen = vi.fn() + +describe('ReleasesList', () => { + describe('when releases are enabled', () => { + beforeEach(async () => { + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + data: [activeASAPRelease, activeScheduledRelease, activeUndecidedRelease], + }) + const wrapper = await createTestProvider() + render( + + + , + {wrapper}, + ) + }) + + it('renders releases when not loading', async () => { + expect(screen.getByText('active asap Release')).toBeInTheDocument() + expect(screen.getByText('active Release')).toBeInTheDocument() + expect(screen.getByText('undecided Release')).toBeInTheDocument() + }) + + it('calls setCreateBundleDialogOpen when create new release button is clicked', () => { + fireEvent.click(screen.getByTestId('create-new-release-button')) + expect(setCreateBundleDialogOpen).toHaveBeenCalledWith(true) + }) + }) + + describe('when releases are disabled', () => { + beforeEach(() => { + beforeEach(async () => { + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + data: [activeASAPRelease, activeScheduledRelease, activeUndecidedRelease], + }) + const wrapper = await createTestProvider() + render( + + + , + {wrapper}, + ) + }) + }) + + it('should hide the releases list, but show publish and draft', async () => { + waitFor(() => { + expect(screen.queryByTestId('release-drafts')).toBeInTheDocument() + expect(screen.queryByTestId('release-drafts')).toBeInTheDocument() + expect(screen.queryByTestId('release-rASAP')).not.toBeInTheDocument() + expect(screen.queryByTestId('release-rScheduled')).not.toBeInTheDocument() + expect(screen.queryByTestId('release-rActive')).not.toBeInTheDocument() + }) + }) + + it('should hide the create new release', async () => { + expect(screen.queryByTestId('create-new-release-button')).toBeNull() + }) + }) +}) diff --git a/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesNav.test.tsx b/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesNav.test.tsx new file mode 100644 index 00000000000..45c9a1231a3 --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/__tests__/ReleasesNav.test.tsx @@ -0,0 +1,323 @@ +import {fireEvent, render, type RenderResult, screen, waitFor, within} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import {useExcludedPerspectiveMockReturn} from '../../../perspective/__mocks__/useExcludedPerspective.mock' +import {usePerspectiveMockReturn} from '../../../perspective/__mocks__/usePerspective.mock' +import { + activeASAPRelease, + activeScheduledRelease, + scheduledRelease, +} from '../../../releases/__fixtures__/release.fixture' +import {useActiveReleasesMockReturn} from '../../../releases/store/__tests__/__mocks/useActiveReleases.mock' +import {LATEST} from '../../../releases/util/const' +import {ReleasesNav} from '../ReleasesNav' + +vi.mock('../../../perspective/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +vi.mock('../../../perspective/useExcludedPerspective', () => ({ + useExcludedPerspective: vi.fn(() => useExcludedPerspectiveMockReturn), +})) + +const mockedSetPerspective = vi.fn() +vi.mock('../../../perspective/useSetPerspective', () => ({ + useSetPerspective: vi.fn(() => mockedSetPerspective), +})) + +vi.mock('../../../releases/store/useActiveReleases', () => ({ + useActiveReleases: vi.fn(() => useActiveReleasesMockReturn), +})) + +const mockedUseWorkspace = vi.fn() +vi.mock('../../../studio/useWorkspace', () => ({ + useWorkspace: vi.fn(() => mockedUseWorkspace), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: vi.fn().mockImplementation((props) => ), + useRouterState: vi.fn().mockReturnValue(undefined), +})) + +let currentRenderedInstance: RenderResult | undefined + +const renderTest = async () => { + const wrapper = await createTestProvider({ + resources: [], + }) + currentRenderedInstance = render(, {wrapper}) + + return currentRenderedInstance +} + +describe('ReleasesNav', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('should have link to releases tool', async () => { + await renderTest() + + const releasesLink = screen.getByRole('link') + + expect(releasesLink).toHaveAttribute('href', '/') + expect(releasesLink).not.toHaveAttribute('data-selected') + }) + + it('should have dropdown menu for global perspectives', async () => { + await renderTest() + + screen.getByTestId('global-perspective-menu-button') + }) + + it('should not have clear button when no perspective is chosen', async () => { + await renderTest() + + expect(screen.queryByTestId('clear-perspective-button')).toBeNull() + }) + + it('should have clear button to unset perspective when a perspective is chosen', async () => { + usePerspectiveMockReturn.selectedPerspective = activeScheduledRelease + usePerspectiveMockReturn.selectedReleaseId = 'rActive' + + await renderTest() + + fireEvent.click(screen.getByTestId('clear-perspective-button')) + + expect(mockedSetPerspective).toHaveBeenCalledWith(LATEST) + }) + + it('should list the title of the chosen perspective', async () => { + usePerspectiveMockReturn.selectedPerspective = activeScheduledRelease + usePerspectiveMockReturn.selectedReleaseId = 'rActive' + + await renderTest() + + screen.getByText('active Release') + }) + + it('should show release avatar for chosen perspective', async () => { + usePerspectiveMockReturn.selectedPerspective = activeASAPRelease + usePerspectiveMockReturn.selectedReleaseId = 'rActive' + + await renderTest() + + screen.getByTestId('release-avatar-critical') + }) + + describe('global perspective menu', () => { + const renderAndWaitForStableMenu = async () => { + await renderTest() + + fireEvent.click(screen.getByTestId('global-perspective-menu-button')) + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).toBeNull() + }) + } + + beforeEach(async () => { + useActiveReleasesMockReturn.data = [ + activeScheduledRelease, + { + ...activeScheduledRelease, + _id: '_.releases.rScheduled2', + metadata: {...activeScheduledRelease.metadata, title: 'active Scheduled 2'}, + }, + activeASAPRelease, + + {...scheduledRelease, publishAt: '2023-10-10T09:00:00Z'}, + ] + }) + + describe('when menu is ready', () => { + beforeEach(renderAndWaitForStableMenu) + + it('should show published perspective item', async () => { + within(screen.getByTestId('release-menu')).getByText('Published') + + fireEvent.click(screen.getByText('Published')) + + expect(mockedSetPerspective).toHaveBeenCalledWith('published') + }) + + it('should list all the releases', async () => { + const releaseMenu = within(screen.getByTestId('release-menu')) + + // section titles + releaseMenu.getByText('ASAP') + releaseMenu.getByText('At time') + expect(releaseMenu.queryByText('Undecided')).toBeNull() + + // releases + releaseMenu.getByText('active Release') + releaseMenu.getByText('active Scheduled 2') + releaseMenu.getByText('active asap Release') + releaseMenu.getByText('scheduled Release') + }) + + it('should show the intended release date for intended schedule releases', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('should show the actual release date for a scheduled release', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-lock-icon') + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('allows for new release to be created', async () => { + fireEvent.click(screen.getByText('New release')) + + expect(screen.getByRole('dialog')).toHaveAttribute('id', 'create-release-dialog') + }) + }) + + describe('release layering', () => { + beforeEach(() => { + // since usePerspective is mocked, and the layering exclude toggle is + // controlled by currentGlobalBundleId, we need to manually set it + // to the release that will be selected in below tests + usePerspectiveMockReturn.selectedReleaseId = 'rScheduled2' + // add an undecided release to expand testing + useActiveReleasesMockReturn.data = [ + ...useActiveReleasesMockReturn.data, + { + ...activeASAPRelease, + _id: '_.releases.rUndecided', + metadata: { + ...activeASAPRelease.metadata, + title: 'undecided Release', + releaseType: 'undecided', + }, + }, + ] + }) + + describe('when a release is clicked', () => { + beforeEach(async () => { + await renderAndWaitForStableMenu() + + // select a release that has some other nested layer releases + fireEvent.click(screen.getByText('active Scheduled 2')) + }) + + it('should set a given perspective from the menu', async () => { + expect(mockedSetPerspective).toHaveBeenCalledWith('rScheduled2') + }) + + it('should allow for hiding of any deeper layered releases', async () => { + const deepLayerRelease = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + // toggle to hide + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(useExcludedPerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'rActive', + ) + + // toggle to include + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(useExcludedPerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'rActive', + ) + }) + + it('should not allow for hiding of published perspective', async () => { + const publishedRelease = within(screen.getByTestId('release-menu')) + .getByText('Published') + .closest('button')! + + expect( + within(publishedRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should allow for hiding of draft perspective', async () => { + const drafts = within(screen.getByTestId('release-menu')) + .getByText('Drafts') + .closest('button')! + + expect(within(drafts).queryByTestId('release-toggle-visibility')).toBeInTheDocument() + // toggle to hide + fireEvent.click(within(drafts).getByTestId('release-toggle-visibility')) + expect(useExcludedPerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'drafts', + ) + // toggle to include + fireEvent.click(within(drafts).getByTestId('release-toggle-visibility')) + expect(useExcludedPerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'drafts', + ) + }) + + it('should not allow hiding of the current perspective', async () => { + const currentRelease = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + expect( + within(currentRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of un-nested releases', async () => { + const unNestedRelease = within(screen.getByTestId('release-menu')) + .getByText('undecided Release') + .closest('button')! + + expect( + within(unNestedRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of locked in scheduled releases', async () => { + const scheduledReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + expect( + within(scheduledReleaseMenuItem).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + }) + + it('applies existing layering when opened', async () => { + useExcludedPerspectiveMockReturn.isPerspectiveExcluded.mockImplementation((id) => { + return id === 'rActive' + }) + + await renderAndWaitForStableMenu() + + const activeReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + expect( + within(activeReleaseMenuItem).queryByTestId('release-avatar-primary'), + ).not.toBeInTheDocument() + }) + + describe('when releases are disabled', () => { + beforeEach(() => { + mockedUseWorkspace.mockReturnValue({releases: {enabled: false}}) + }) + + it('should hide calendar icon', async () => { + expect(screen.queryByTestId('releases-tool-link')).toBeNull() + }) + }) + }) + }) +}) diff --git a/packages/sanity/src/core/perspective/navbar/currentGlobalPerspectiveLabel.tsx b/packages/sanity/src/core/perspective/navbar/currentGlobalPerspectiveLabel.tsx new file mode 100644 index 00000000000..1e492e95dcc --- /dev/null +++ b/packages/sanity/src/core/perspective/navbar/currentGlobalPerspectiveLabel.tsx @@ -0,0 +1,99 @@ +// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button +import {Box, Button, Card, Flex, Stack, Text} from '@sanity/ui' +import {motion} from 'framer-motion' +import {type PropsWithChildren} from 'react' +import {IntentLink} from 'sanity/router' + +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {ReleaseAvatar} from '../../releases/components/ReleaseAvatar' +import {RELEASES_INTENT} from '../../releases/plugin' +import {isReleaseDocument} from '../../releases/store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../releases/util/getReleaseIdFromReleaseDocumentId' +import {getReleaseTone} from '../../releases/util/getReleaseTone' +import {isDraftPerspective, isPublishedPerspective} from '../../releases/util/util' +import {type SelectedPerspective} from '../types' + +const AnimatedMotionDiv = ({children, ...props}: PropsWithChildren) => ( + + {children} + +) + +export function CurrentGlobalPerspectiveLabel({ + selectedPerspective, +}: { + selectedPerspective: SelectedPerspective +}): React.JSX.Element | null { + const {t} = useTranslation() + + if (!selectedPerspective) return null + + let displayTitle = t('release.placeholder-untitled-release') + + if (isPublishedPerspective(selectedPerspective)) { + displayTitle = t('release.chip.published') + } else if (isDraftPerspective(selectedPerspective)) { + displayTitle = t('release.chip.global.drafts') + } else if (isReleaseDocument(selectedPerspective)) { + displayTitle = selectedPerspective.metadata?.title || t('release.placeholder-untitled-release') + } + + const visibleLabelChildren = () => { + const labelContent = ( + + + + + + + {displayTitle} + + + + ) + + if (isPublishedPerspective(selectedPerspective) || isDraftPerspective(selectedPerspective)) { + return ( + + {labelContent} + + ) + } + + const releasesIntentLink = ({children, ...intentProps}: PropsWithChildren) => ( + + {children} + + ) + + return ( + + {labelContent} + + ) + } + + 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/components/VersionInlineBadge.tsx b/packages/sanity/src/core/releases/components/VersionInlineBadge.tsx new file mode 100644 index 00000000000..1a48e57dabe --- /dev/null +++ b/packages/sanity/src/core/releases/components/VersionInlineBadge.tsx @@ -0,0 +1,17 @@ +import {type BadgeTone} from '@sanity/ui' +import {css, styled} from 'styled-components' + +/** + * @internal + */ +export const VersionInlineBadge = styled.span<{$tone?: BadgeTone}>((props) => { + const {$tone} = props + return css` + color: var(--card-badge-${$tone ?? 'default'}-fg-color); + background-color: var(--card-badge-${$tone ?? 'default'}-bg-color); + border-radius: 3px; + text-decoration: none; + padding: 0px 2px; + font-weight: 500; + ` +}) diff --git a/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx new file mode 100644 index 00000000000..172cd6bb89d --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx @@ -0,0 +1,133 @@ +import {ArrowRightIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Flex, useToast} from '@sanity/ui' +import {type FormEvent, useCallback, useState} from 'react' + +import {Button, Dialog} from '../../../../ui-components' +import {useTranslation} from '../../../i18n' +import {CreatedRelease, type OriginInfo} from '../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../i18n' +import {type EditableReleaseDocument} from '../../store/types' +import {useReleaseOperations} from '../../store/useReleaseOperations' +import {DEFAULT_RELEASE_TYPE} from '../../util/const' +import {createReleaseId} from '../../util/createReleaseId' +import {getIsScheduledDateInPast} from '../../util/getIsScheduledDateInPast' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {ReleaseForm} from './ReleaseForm' + +interface CreateReleaseDialogProps { + onCancel: () => void + onSubmit: (createdReleaseId: string) => void + origin?: OriginInfo['origin'] +} + +export function CreateReleaseDialog(props: CreateReleaseDialogProps): React.JSX.Element { + const {onCancel, onSubmit, origin} = props + const toast = useToast() + const {createRelease} = useReleaseOperations() + const {t} = useTranslation() + const {t: tRelease} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + + const [release, setRelease] = useState((): EditableReleaseDocument => { + return { + _id: createReleaseId(), + metadata: { + title: '', + description: '', + releaseType: DEFAULT_RELEASE_TYPE, + }, + } as const + }) + const [isSubmitting, setIsSubmitting] = useState(false) + /** + * This state supports the scenario of: + * release.intendedPublishAt is set to a valid future date; but at time of submit it is in the past + * Without an update on this state, CreateReleaseDialog would not rerender + * and so date in past warning ui elements wouldn't show + */ + const [, setRerenderDialog] = useState(0) + + const isScheduledDateInPast = getIsScheduledDateInPast(release) + + const handleOnSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault() + + // re-evaluate if date is in past + // as dialog could have been left idle for a while + if (getIsScheduledDateInPast(release)) { + toast.push({ + closable: true, + status: 'warning', + title: tRelease('schedule-dialog.publish-date-in-past-warning'), + }) + setRerenderDialog((cur) => cur + 1) + return // do not submit if date is in past + } + + try { + setIsSubmitting(true) + + const submitValue = { + ...release, + metadata: {...release.metadata, title: release.metadata?.title?.trim()}, + } + await createRelease(submitValue) + telemetry.log(CreatedRelease, {origin}) + } catch (err) { + console.error(err) + toast.push({ + closable: true, + status: 'error', + title: t('release.toast.create-release-error.title'), + }) + } finally { + // TODO: Remove this! temporary fix to give some time for the release to be created and the releases store state updated before closing the dialog. + await new Promise((resolve) => setTimeout(resolve, 1000)) + // TODO: Remove the upper part + + setIsSubmitting(false) + onSubmit(getReleaseIdFromReleaseDocumentId(release._id)) + } + }, + [release, toast, tRelease, createRelease, telemetry, origin, t, onSubmit], + ) + + const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => { + setRelease(changedValue) + }, []) + + const dialogTitle = t('release.dialog.create.title') + + return ( + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx new file mode 100644 index 00000000000..f345aa9a5f7 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx @@ -0,0 +1,82 @@ +import {Box} from '@sanity/ui' +import {useCallback, useState} from 'react' + +import {Dialog} from '../../../../ui-components' +import {LoadingBlock} from '../../../components' +import {useDocumentOperation, useSchema} from '../../../hooks' +import {useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' +import {Preview} from '../../../preview' +import {getPublishedId, getVersionFromId, isVersionId} from '../../../util/draftUtils' +import {useVersionOperations} from '../../hooks' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' + +/** + * @internal + */ +export function DiscardVersionDialog(props: { + onClose: () => void + documentId: string + documentType: string +}): React.JSX.Element { + const {onClose, documentId, documentType} = props + const {t} = useTranslation(releasesLocaleNamespace) + const {discardChanges} = useDocumentOperation(getPublishedId(documentId), documentType) + + const {selectedPerspective} = usePerspective() + const {discardVersion} = useVersionOperations() + const schema = useSchema() + const [isDiscarding, setIsDiscarding] = useState(false) + + const schemaType = schema.get(documentType) + + const handleDiscardVersion = useCallback(async () => { + setIsDiscarding(true) + + if (isVersionId(documentId)) { + await discardVersion( + getVersionFromId(documentId) || + getReleaseIdFromReleaseDocumentId((selectedPerspective as ReleaseDocument)._id), + documentId, + ) + } else { + // on the document header you can also discard the draft + discardChanges.execute() + } + + setIsDiscarding(false) + + onClose() + }, [selectedPerspective, discardChanges, discardVersion, documentId, onClose]) + + return ( + + + {schemaType ? ( + + ) : ( + + )} + + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx b/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx new file mode 100644 index 00000000000..5a9c3d4c3a8 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx @@ -0,0 +1,158 @@ +import {InfoOutlineIcon} from '@sanity/icons' +import {Card, Flex, Stack, TabList, TabPanel, Text} from '@sanity/ui' +import {addMinutes, isValid} from 'date-fns' +import {useCallback, useEffect, useState} from 'react' + +import {Tab, Tooltip} from '../../../../ui-components' +import {useTranslation} from '../../../i18n' +import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone' +import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' +import {type EditableReleaseDocument, type ReleaseType} from '../../store/types' +import {ScheduleDatePicker} from '../ScheduleDatePicker' +import {TitleDescriptionForm} from './TitleDescriptionForm' + +const RELEASE_TYPES: ReleaseType[] = ['asap', 'scheduled', 'undecided'] + +/** @internal */ +export function ReleaseForm(props: { + onChange: (params: EditableReleaseDocument) => void + value: EditableReleaseDocument +}): React.JSX.Element { + const {onChange, value} = props + const {releaseType} = value.metadata || {} + const {t} = useTranslation() + + const {DialogTimeZone, dialogProps} = useDialogTimeZone() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const [currentTimezone, setCurrentTimezone] = useState(timeZone.name) + + const [buttonReleaseType, setButtonReleaseType] = useState(releaseType ?? 'asap') + + const [intendedPublishAt, setIntendedPublishAt] = useState() + + const handleBundlePublishAtCalendarChange = useCallback( + (date: Date) => { + setIntendedPublishAt(date) + onChange({...value, metadata: {...value.metadata, intendedPublishAt: date.toISOString()}}) + }, + [onChange, value], + ) + + const handleButtonReleaseTypeChange = useCallback( + (pickedReleaseType: ReleaseType) => { + setButtonReleaseType(pickedReleaseType) + // select time 1 minute from now so that form doesn't immediately + // evaluate to error + const nextInputValue = addMinutes(new Date().setSeconds(0, 0), 1) + if (pickedReleaseType === 'scheduled') { + setIntendedPublishAt(nextInputValue) + } + + onChange({ + ...value, + metadata: { + ...value.metadata, + releaseType: pickedReleaseType, + intendedPublishAt: + (pickedReleaseType === 'scheduled' && nextInputValue.toISOString()) || undefined, + }, + }) + }, + [onChange, value], + ) + + const handleTitleDescriptionChange = useCallback( + (updatedRelease: EditableReleaseDocument) => { + onChange({ + ...value, + metadata: { + ...value.metadata, + title: updatedRelease.metadata.title, + description: updatedRelease.metadata.description, + }, + }) + }, + [onChange, value], + ) + + useEffect(() => { + /** makes sure to wait for the useTimezone has enough time to update + * and based on that it will update the input value to the current timezone + */ + if (timeZone.name !== currentTimezone) { + setCurrentTimezone(timeZone.name) + if (intendedPublishAt && isValid(intendedPublishAt)) { + const currentZoneDate = utcToCurrentZoneDate(intendedPublishAt) + setIntendedPublishAt(currentZoneDate) + } + } + }, [currentTimezone, intendedPublishAt, timeZone, utcToCurrentZoneDate]) + + return ( + + + + {t('release.dialog.tooltip.title')} + + + {t('release.dialog.tooltip.description')} + + {t('release.dialog.tooltip.note')} + + + } + delay={0} + placement="right-start" + portal + > + + + + + + + + + {RELEASE_TYPES.map((type) => ( + handleButtonReleaseTypeChange(type)} + selected={buttonReleaseType === type} + label={t(`release.type.${type}`)} + /> + ))} + + + + {buttonReleaseType === 'scheduled' && ( + + + + )} + + + + + {DialogTimeZone && } + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx b/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx new file mode 100644 index 00000000000..27737a34ae3 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx @@ -0,0 +1,173 @@ +import {Stack} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {type ChangeEvent, useCallback, useEffect, useRef, useState} from 'react' +import {css, styled} from 'styled-components' + +import {useTranslation} from '../../../i18n/hooks/useTranslation' +import {type EditableReleaseDocument} from '../../index' +import {DEFAULT_RELEASE_TYPE} from '../../util/const' + +const MAX_DESCRIPTION_HEIGHT = 200 + +const TitleInput = styled.input((props) => { + const {color, font} = getTheme_v2(props.theme) + return css` + resize: none; + overflow: hidden; + appearance: none; + background: none; + border: 0; + padding: 0; + border-radius: 0; + outline: none; + width: 100%; + box-sizing: border-box; + font-family: ${font.text.family}; + font-weight: ${font.text.weights.bold}; + font-size: ${font.text.sizes[4].fontSize}px; + line-height: ${font.text.sizes[4].lineHeight}px; + margin: 0; + position: relative; + z-index: 1; + display: block; + transition: height 500ms; + /* NOTE: This is a hack to disable Chrome’s autofill styles */ + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus, + &:-webkit-autofill:active { + -webkit-text-fill-color: var(--input-fg-color) !important; + transition: background-color 5000s; + transition-delay: 86400s /* 24h */; + } + + color: ${color.input.default.enabled.fg}; + + &::placeholder { + color: ${color.input.default.enabled.placeholder}; + } + ` +}) + +const DescriptionTextArea = styled.textarea((props) => { + const {color, font} = getTheme_v2(props.theme) + + return css` + resize: none; + overflow: hidden; + appearance: none; + background: none; + border: 0; + padding: 0; + border-radius: 0; + outline: none; + width: 100%; + box-sizing: border-box; + font-family: ${font.text.family}; + font-weight: ${font.text.weights.regular}; + font-size: ${font.text.sizes[2].fontSize}px; + height: auto; + line-height: ${font.text.sizes[2].lineHeight}px; + margin: 0; + max-width: 624px; + position: relative; + z-index: 1; + display: block; + color: ${color.input.default.enabled.fg}; + + &::placeholder { + color: ${color.input.default.enabled.placeholder}; + } + ` +}) + +export function TitleDescriptionForm({ + release, + onChange, +}: { + release: EditableReleaseDocument + onChange: (changedValue: EditableReleaseDocument) => void +}): React.JSX.Element { + const descriptionRef = useRef(null) + + const [scrollHeight, setScrollHeight] = useState(46) + const [value, setValue] = useState((): EditableReleaseDocument => { + return { + _id: release?._id, + metadata: { + title: release?.metadata.title, + description: release?.metadata.description, + intendedPublishAt: release?.metadata?.intendedPublishAt, + releaseType: release?.metadata.releaseType || DEFAULT_RELEASE_TYPE, + }, + } as const + }) + const {t} = useTranslation() + + useEffect(() => { + // make sure that the text area for the description has the right height initially + if (descriptionRef.current) { + setScrollHeight(descriptionRef.current.scrollHeight) + } + }, []) + + const handleTitleChange = useCallback( + (event: ChangeEvent) => { + event.preventDefault() + const title = event.target.value + onChange({...value, metadata: {...value.metadata, title}}) + // save the values to make input snappier while requests happen in the background + setValue({...value, metadata: {...value.metadata, title}}) + }, + [onChange, value], + ) + + const handleDescriptionChange = useCallback( + (event: ChangeEvent) => { + event.preventDefault() + const description = event.target.value + onChange({...value, metadata: {...value.metadata, description}}) + // save the values to make input snappier while requests happen in the background + setValue({...value, metadata: {...value.metadata, description}}) + + /** we must reset the height in order to make sure that if the text area shrinks, + * that the actual input will change height as well */ + if (descriptionRef.current) { + descriptionRef.current.style.overflow = 'hidden' + descriptionRef.current.style.height = 'auto' + descriptionRef.current.style.height = `${descriptionRef.current.scrollHeight}px` + + if (parseInt(descriptionRef.current.style.height, 10) > MAX_DESCRIPTION_HEIGHT) { + descriptionRef.current.style.overflow = 'auto' + } + } + + setScrollHeight(event.currentTarget.scrollHeight) + }, + [onChange, value], + ) + + return ( + + + + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/UnpublishVersionDialog.tsx b/packages/sanity/src/core/releases/components/dialog/UnpublishVersionDialog.tsx new file mode 100644 index 00000000000..68e25a61a8e --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/UnpublishVersionDialog.tsx @@ -0,0 +1,117 @@ +import {Stack, Text} from '@sanity/ui' +import {type CSSProperties, useCallback, useState} from 'react' + +import {Dialog} from '../../../../ui-components/dialog/Dialog' +import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' +import {useSchema} from '../../../hooks/useSchema' +import {useTranslation} from '../../../i18n/hooks/useTranslation' +import {Translate} from '../../../i18n/Translate' +import {Preview} from '../../../preview/components/Preview' +import {getVersionFromId} from '../../../util/draftUtils' +import {useVersionOperations} from '../../hooks/useVersionOperations' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {useActiveReleases} from '../../store/useActiveReleases' +import {useArchivedReleases} from '../../store/useArchivedReleases' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {getReleaseTone} from '../../util/getReleaseTone' + +export function UnpublishVersionDialog(props: { + onClose: () => void + documentVersionId: string + documentType: string +}): React.JSX.Element { + const {onClose, documentVersionId, documentType} = props + const {t} = useTranslation(releasesLocaleNamespace) + const schema = useSchema() + const {unpublishVersion} = useVersionOperations() + const [isUnpublishing, setIsUnpublishing] = useState(false) + + const {data} = useActiveReleases() + const {data: archivedReleases} = useArchivedReleases() + + const releaseInDetail = data + .concat(archivedReleases) + .find( + (candidate) => + getReleaseIdFromReleaseDocumentId(candidate._id) === getVersionFromId(documentVersionId), + ) + + const tone = getReleaseTone(releaseInDetail as ReleaseDocument) + const schemaType = schema.get(documentType) + + const handleUnpublish = useCallback(async () => { + setIsUnpublishing(true) + + await unpublishVersion(documentVersionId) + setIsUnpublishing(false) + + onClose() + }, [documentVersionId, onClose, unpublishVersion]) + + return ( + + + {schemaType ? ( + + ) : ( + + )} + + + { + return ( + + {children} + + ) + }, + }} + /> + + + + {t('unpublish-dialog.description.lost-changes')} + + + + ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx b/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx new file mode 100644 index 00000000000..88c6453e5da --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx @@ -0,0 +1,67 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {activeASAPRelease} from '../../../__fixtures__/release.fixture' +import {type ReleaseDocument} from '../../../index' +import {useReleaseOperationsMockReturn} from '../../../store/__tests__/__mocks/useReleaseOperations.mock' +import {CreateReleaseDialog} from '../CreateReleaseDialog' + +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +describe('CreateReleaseDialog', () => { + describe('when creating a new release', () => { + const onCancelMock = vi.fn() + const onSubmitMock = vi.fn() + + beforeEach(async () => { + onCancelMock.mockClear() + onSubmitMock.mockClear() + + const wrapper = await createTestProvider() + render(, {wrapper}) + + await waitFor( + () => { + expect(screen.queryByTestId('loading-block')).not.toBeInTheDocument() + }, + {timeout: 5000, interval: 500}, + ) + }) + + it('should render the dialog', () => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should call onCancel when dialog is closed', () => { + fireEvent.click(screen.getByRole('button', {name: /close/i})) + + expect(onCancelMock).toHaveBeenCalled() + }) + + it('should call createRelease and onCreate when form is submitted', async () => { + const value: Partial = activeASAPRelease + + act(async () => { + const titleInput = screen.getByTestId('release-form-title') + fireEvent.change(titleInput, {target: {value: value.metadata?.title}}) + + const submitButton = screen.getByTestId('submit-release-button') + fireEvent.click(submitButton) + + waitFor(async () => { + await Promise.resolve() + + expect(onSubmitMock).toHaveBeenCalledOnce() + expect(useReleaseOperationsMockReturn.createRelease).toHaveBeenCalledWith( + expect.objectContaining({ + _id: expect.stringContaining('releases'), + }), + ) + }) + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx new file mode 100644 index 00000000000..4f42076d355 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx @@ -0,0 +1,211 @@ +import {fireEvent, render, screen} from '@testing-library/react' +import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {useDateTimeFormat} from '../../../../hooks' +import {type EditableReleaseDocument, type ReleaseDocument, useActiveReleases} from '../../../store' +import {RELEASE_DOCUMENT_TYPE} from '../../../store/constants' +import {useReleasesIds} from '../../../store/useReleasesIds' +import {ReleaseForm} from '../ReleaseForm' + +vi.mock('../../../../../core/hooks/useDateTimeFormat', () => ({ + useDateTimeFormat: vi.fn(), +})) +vi.mock('../../../store/useActiveReleases', () => ({ + useActiveReleases: vi.fn(), +})) + +vi.mock('../../../store/useReleasesIds', () => ({ + useReleasesIds: vi.fn(), +})) + +vi.mock('../../../i18n/hooks/useTranslation', () => ({ + useTranslate: vi.fn().mockReturnValue({ + t: vi.fn(), + }), +})) + +const mockUseActiveReleases = useActiveReleases as Mock +const mockUseReleasesIds = useReleasesIds as Mock +const mockUseDateTimeFormat = useDateTimeFormat as Mock + +describe('ReleaseForm', () => { + const onChangeMock = vi.fn() + const onErrorMock = vi.fn() + const valueMock: EditableReleaseDocument = { + _id: 'very-random', + metadata: { + title: '', + description: '', + }, + } + + describe('when creating a new release', () => { + beforeEach(async () => { + onChangeMock.mockClear() + onErrorMock.mockClear() + + // Mock the data returned by useBundles hook + const mockData: ReleaseDocument[] = [ + { + _id: 'db76c50e-358b-445c-a57c-8344c588a5d5', + _type: RELEASE_DOCUMENT_TYPE, + _createdAt: '2024-07-02T11:37:51Z', + _updatedAt: '2024-07-12T10:39:32Z', + name: 'spring-drop', + createdBy: 'unknown', + state: 'active', + metadata: { + releaseType: 'asap', + title: 'Spring Drop', + description: 'What a spring drop, allergies galore 🌸', + }, + _rev: '', + }, + // Add more mock data if needed + ] + mockUseActiveReleases.mockReturnValue({ + data: mockData, + loading: false, + dispatch: vi.fn(), + error: undefined, + }) + + mockUseReleasesIds.mockReturnValue({ + releasesIds: [], + }) + + mockUseDateTimeFormat.mockReturnValue({format: vi.fn().mockReturnValue('Mocked date')}) + + const wrapper = await createTestProvider() + render(, { + wrapper, + }) + }) + + it('should render the form fields', () => { + expect(screen.getByTestId('release-form-title')).toBeInTheDocument() + expect(screen.getByTestId('release-form-description')).toBeInTheDocument() + //expect(screen.getByTestId('release-form-publish-at')).toBeInTheDocument() + }) + + it('should call onChange when title input value changes', () => { + const titleInput = screen.getByTestId('release-form-title') + fireEvent.change(titleInput, {target: {value: 'Bundle 1'}}) + + expect(onChangeMock).toHaveBeenCalledWith({ + ...valueMock, + metadata: {...valueMock.metadata, title: 'Bundle 1'}, + }) + }) + + it('should call onChange when description textarea value changes', () => { + const descriptionTextarea = screen.getByTestId('release-form-description') + fireEvent.change(descriptionTextarea, {target: {value: 'New Description'}}) + + expect(onChangeMock).toHaveBeenCalledWith({ + ...valueMock, + metadata: {...valueMock.metadata, description: 'New Description'}, + }) + }) + + /*it('should call onChange when publishAt input value changes', () => { + const publishAtInput = screen.getByTestId('release-form-publish-at') + fireEvent.change(publishAtInput, {target: {value: '2022-01-01'}}) + + expect(onChangeMock).toHaveBeenCalledWith({...valueMock, publishAt: '2022-01-01'}) + }) + + it('should call onChange with undefined when publishAt input value is empty', () => { + const publishAtInput = screen.getByTestId('release-form-publish-at') + fireEvent.change(publishAtInput, {target: {value: ' '}}) + + expect(onChangeMock).toHaveBeenCalledWith({...valueMock, publishAt: ''}) + })*/ + + /*it('should show an error when the publishAt input value is invalid', () => { + const publishAtInput = screen.getByTestId('release-form-publish-at') + fireEvent.change(publishAtInput, {target: {value: 'invalid-date'}}) + + expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument() + })*/ + }) + + describe('when updating an existing release', () => { + const existingBundleValue: ReleaseDocument = { + _id: 'db76c50e-358b-445c-a57c-8344c588a5d5', + _type: RELEASE_DOCUMENT_TYPE, + _createdAt: '2024-07-02T11:37:51Z', + _updatedAt: '2024-07-12T10:39:32Z', + name: 'spring-drop', + createdBy: 'unknown', + state: 'active', + metadata: { + title: 'Summer Drop', + description: 'Summer time', + releaseType: 'asap', + }, + _rev: '', + } + beforeEach(async () => { + onChangeMock.mockClear() + onErrorMock.mockClear() + + // Mock the data returned by useBundles hook + const mockData: ReleaseDocument[] = [ + { + _id: 'db76c50e-358b-445c-a57c-8344c588a5d5', + _type: RELEASE_DOCUMENT_TYPE, + _createdAt: '2024-07-02T11:37:51Z', + _updatedAt: '2024-07-12T10:39:32Z', + name: 'spring-drop', + createdBy: 'unknown', + state: 'active', + metadata: { + releaseType: 'asap', + title: 'Spring Drop', + description: 'What a spring drop, allergies galore 🌸', + }, + _rev: '', + }, + // Add more mock data if needed + ] + mockUseActiveReleases.mockReturnValue({ + data: mockData, + loading: false, + dispatch: vi.fn(), + error: undefined, + }) + + mockUseReleasesIds.mockReturnValue({ + releasesIds: [], + }) + + mockUseDateTimeFormat.mockReturnValue({format: vi.fn().mockReturnValue('Mocked date')}) + + const wrapper = await createTestProvider() + render(, { + wrapper, + }) + }) + + it('should allow for any title to be used', async () => { + const titleInput = screen.getByTestId('release-form-title') + expect(titleInput).toHaveValue(existingBundleValue.metadata.title) + // the slug of this title already exists, + // but the slug for the existing edited release will not be changed + fireEvent.change(titleInput, {target: {value: 'Spring Drop'}}) + + expect(screen.queryByTestId('input-validation-icon-error')).not.toBeInTheDocument() + }) + + it('should populate the form with the existing release values', () => { + expect(screen.getByTestId('release-form-title')).toHaveValue( + existingBundleValue.metadata.title, + ) + expect(screen.getByTestId('release-form-description')).toHaveValue( + existingBundleValue.metadata.description, + ) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx new file mode 100644 index 00000000000..aa26a7d7cee --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx @@ -0,0 +1,258 @@ +import {LockIcon} from '@sanity/icons' +import {type BadgeTone, useClickOutsideEvent, useGlobalKeyDown} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import { + memo, + type MouseEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {css, styled} from 'styled-components' + +import {Button, Popover, Tooltip} from '../../../../ui-components' +import {getVersionId} from '../../../util/draftUtils' +import {useVersionOperations} from '../../hooks/useVersionOperations' +import {type ReleaseDocument, type ReleaseState} from '../../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {DiscardVersionDialog} from '../dialog/DiscardVersionDialog' +import {ReleaseAvatar} from '../ReleaseAvatar' +import {VersionContextMenu} from './contextMenu/VersionContextMenu' +import {CopyToNewReleaseDialog} from './dialog/CopyToNewReleaseDialog' + +interface ChipStyleProps { + $isArchived?: boolean +} + +const Chip = styled(Button)(({$isArchived, theme: themeRaw}) => { + const theme = getTheme_v2(themeRaw) + + return css` + border-radius: 9999px !important; + transition: none; + text-decoration: none !important; + cursor: pointer; + padding-right: ${theme.space[3]}px; + + // target enabled state + &:not([data-disabled='true']) { + --card-border-color: var(--card-badge-default-bg-color); + } + + &[data-disabled='true'] { + color: var(--card-muted-fg-color); + cursor: default; + + // archived will be disabled but should have bg color + ${$isArchived && + css` + background-color: var(--card-badge-default-bg-color); + `} + } + ` +}) + +/** + * @internal + */ +export const VersionChip = memo(function VersionChip(props: { + disabled?: boolean + selected: boolean + tooltipContent: ReactNode + onClick: () => void + text: string + tone: BadgeTone + locked?: boolean + contextValues: { + documentId: string + releases: ReleaseDocument[] + releasesLoading: boolean + documentType: string + menuReleaseId: string + fromRelease: string + releaseState?: ReleaseState + isVersion: boolean + disabled?: boolean + } +}) { + const { + disabled, + selected, + tooltipContent, + onClick, + text, + tone, + locked = false, + contextValues: { + documentId, + releases, + releasesLoading, + documentType, + menuReleaseId, + fromRelease, + releaseState, + isVersion, + disabled: contextMenuDisabled = false, + }, + } = props + + const [contextMenuPoint, setContextMenuPoint] = useState<{x: number; y: number} | undefined>( + undefined, + ) + const popoverRef = useRef(null) + const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false) + const [isCreateReleaseDialogOpen, setIsCreateReleaseDialogOpen] = useState(false) + + const chipRef = useRef(null) + + useEffect(() => { + if (selected) chipRef.current?.scrollIntoView({inline: 'center'}) + }, [selected]) + + const docId = isVersion ? getVersionId(documentId, fromRelease) : documentId // operations recognises publish and draft as empty + + const {createVersion} = useVersionOperations() + + const close = useCallback(() => setContextMenuPoint(undefined), []) + + const handleContextMenu = useCallback((event: MouseEvent) => { + event.preventDefault() + + setContextMenuPoint({x: event.clientX, y: event.clientY}) + }, []) + + useClickOutsideEvent( + () => { + if (contextMenuPoint?.x && contextMenuPoint?.y) { + close() + } + }, + () => [popoverRef.current], + ) + + useGlobalKeyDown( + useCallback( + (event) => { + if (event.key === 'Escape') { + close() + } + }, + [close], + ), + ) + + const openDiscardDialog = useCallback(() => { + setIsDiscardDialogOpen(true) + }, [setIsDiscardDialogOpen]) + + const openCreateReleaseDialog = useCallback(() => { + setIsCreateReleaseDialogOpen(true) + }, [setIsCreateReleaseDialogOpen]) + + const handleAddVersion = useCallback( + async (targetRelease: string) => { + await createVersion(getReleaseIdFromReleaseDocumentId(targetRelease), docId) + close() + }, + [createVersion, docId, close], + ) + + const referenceElement = useMemo(() => { + if (!contextMenuPoint) { + return null + } + + return { + getBoundingClientRect() { + return { + x: contextMenuPoint.x, + y: contextMenuPoint.y, + left: contextMenuPoint.x, + top: contextMenuPoint.y, + right: contextMenuPoint.x, + bottom: contextMenuPoint.y, + width: 0, + height: 0, + } + }, + } as HTMLElement + }, [contextMenuPoint]) + + const contextMenuHandler = disabled ? undefined : handleContextMenu + + return ( + <> + + } + iconRight={locked && LockIcon} + onContextMenu={contextMenuHandler} + $isArchived={releaseState === 'archived'} + /> + + + + } + fallbackPlacements={[]} + open={Boolean(referenceElement)} + portal + placement="bottom-start" + ref={popoverRef} + referenceElement={referenceElement} + zOffset={10} + /> + + {isDiscardDialogOpen && ( + setIsDiscardDialogOpen(false)} + documentId={ + isVersion + ? getVersionId(documentId, getReleaseIdFromReleaseDocumentId(menuReleaseId)) + : documentId + } + documentType={documentType} + /> + )} + + {isCreateReleaseDialogOpen && ( + setIsCreateReleaseDialogOpen(false)} + onCreateVersion={handleAddVersion} + documentId={ + isVersion + ? getVersionId(documentId, getReleaseIdFromReleaseDocumentId(menuReleaseId)) + : documentId + } + documentType={documentType} + tone={tone} + title={text} + /> + )} + > + ) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx new file mode 100644 index 00000000000..673ebfcd04c --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx @@ -0,0 +1,110 @@ +import {AddIcon, CalendarIcon, CopyIcon, TrashIcon} from '@sanity/icons' +import {Menu, MenuDivider, Spinner, Stack} from '@sanity/ui' +import {memo} from 'react' +import {IntentLink} from 'sanity/router' +import {styled} from 'styled-components' + +import {MenuGroup} from '../../../../../ui-components/menuGroup/MenuGroup' +import {MenuItem} from '../../../../../ui-components/menuItem/MenuItem' +import {useTranslation} from '../../../../i18n/hooks/useTranslation' +import {isPublishedId} from '../../../../util/draftUtils' +import {type ReleaseDocument} from '../../../store/types' +import {isReleaseScheduledOrScheduling} from '../../../util/util' +import {VersionContextMenuItem} from './VersionContextMenuItem' + +const ReleasesList = styled(Stack)` + max-width: 300px; + max-height: 200px; + overflow-y: auto; +` + +export const VersionContextMenu = memo(function VersionContextMenu(props: { + documentId: string + releases: ReleaseDocument[] + releasesLoading: boolean + fromRelease: string + isVersion: boolean + onDiscard: () => void + onCreateRelease: () => void + onCreateVersion: (targetId: string) => void + disabled?: boolean + locked?: boolean +}) { + const { + documentId, + releases, + releasesLoading, + fromRelease, + isVersion, + onDiscard, + onCreateRelease, + onCreateVersion, + disabled, + locked, + } = props + const {t} = useTranslation() + const isPublished = isPublishedId(documentId) && !isVersion + const optionsReleaseList = releases.map((release) => ({ + value: release, + })) + + const releaseId = isVersion ? fromRelease : documentId + + return ( + <> + + {isVersion && ( + + + + )} + {releasesLoading && } + + + {optionsReleaseList.map((option) => { + const isReleaseScheduled = isReleaseScheduledOrScheduling(option.value) + return ( + onCreateVersion(option.value._id)} + renderMenuItem={() => } + disabled={disabled || isReleaseScheduled} + tooltipProps={{content: isReleaseScheduled && t('release.tooltip.locked')}} + /> + ) + })} + {' '} + {optionsReleaseList.length > 1 && } + + + {!isPublished && ( + <> + + + > + )} + + > + ) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx new file mode 100644 index 00000000000..533ff2df69e --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx @@ -0,0 +1,40 @@ +import {LockIcon} from '@sanity/icons' +import {Flex, Stack, Text} from '@sanity/ui' +import {memo} from 'react' + +import {useTranslation} from '../../../../i18n' +import {type ReleaseDocument} from '../../../store/types' +import {getReleaseTone} from '../../../util/getReleaseTone' +import {formatRelativeLocalePublishDate, isReleaseScheduledOrScheduling} from '../../../util/util' +import {ReleaseAvatar} from '../../ReleaseAvatar' + +export const VersionContextMenuItem = memo(function VersionContextMenuItem(props: { + release: ReleaseDocument +}) { + const {release} = props + const {t} = useTranslation() + const isScheduled = isReleaseScheduledOrScheduling(release) + + return ( + + + + + {release.metadata?.title || t('release.placeholder-untitled-release')} + + + {release.metadata.releaseType === 'asap' && <>{t('release.type.asap')}>} + {release.metadata.releaseType === 'scheduled' && + (release.metadata.intendedPublishAt ? ( + <>{formatRelativeLocalePublishDate(release)}> + ) : ( + /** should not be allowed to do, but a fall back in case if somehow no date is added */ + <>{t('release.chip.tooltip.unknown-date')}> + ))} + {release.metadata.releaseType === 'undecided' && <>{t('release.type.undecided')}>} + + + {isScheduled && } + + ) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx new file mode 100644 index 00000000000..04badc913e9 --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx @@ -0,0 +1,133 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import {type ReleaseDocument} from '../../../../store/types' +import {VersionContextMenu} from '../VersionContextMenu' + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: ({children}: {children: React.ReactNode}) => {children}, + route: { + create: vi.fn(), + }, +})) + +describe('VersionContextMenu', () => { + const mockReleases: ReleaseDocument[] = [ + { + _id: '_.releases.release1', + name: 'release1', + _type: 'system.release', + _rev: 'rev1', + _updatedAt: '', + _createdAt: '', + state: 'active', + createdBy: 'safsd', + metadata: { + title: 'Release 1', + releaseType: 'asap', + }, + }, + { + _id: '_.releases.release2', + name: 'release2', + _type: 'system.release', + _rev: 'rev1', + _createdAt: '', + _updatedAt: '', + createdBy: 'safsd', + state: 'active', + metadata: { + title: 'Release 2', + releaseType: 'asap', + }, + }, + ] + + const defaultProps = { + documentId: 'versions.bundle.doc1', + releases: mockReleases, + releasesLoading: false, + fromRelease: 'release1', + isVersion: true, + onDiscard: vi.fn(), + onCreateRelease: vi.fn(), + onCreateVersion: vi.fn(), + disabled: false, + } + + it('renders the menu items correctly', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + + expect(screen.getByText('Copy version to')).toBeInTheDocument() + fireEvent.click(screen.getByText('Copy version to')) + await waitFor(() => { + expect(screen.getByText('New Release')).toBeInTheDocument() + expect(screen.getByText('Release 1')).toBeInTheDocument() + expect(screen.getByText('Release 2')).toBeInTheDocument() + }) + }) + + it('calls onCreateRelease when "New release" is clicked', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + + fireEvent.click(screen.getByText('Copy version to')) + await waitFor(() => { + fireEvent.click(screen.getByText('New Release')) + }) + expect(defaultProps.onCreateRelease).toHaveBeenCalled() + }) + + it('hides discard version on published chip', async () => { + const wrapper = await createTestProvider() + const publishedProps = { + ...defaultProps, + documentId: 'testid', + isVersion: false, + } + + render(, {wrapper}) + + expect(screen.queryByTestId('discard')).not.toBeInTheDocument() + }) + + it('calls onDiscard when "Discard version" is clicked', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + + await waitFor(() => { + fireEvent.click(screen.getByText('Discard version')) + }) + expect(defaultProps.onDiscard).toHaveBeenCalled() + }) + + it('calls onCreateRelease when a "new release" is clicked', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + + fireEvent.click(screen.getByText('Copy version to')) + await waitFor(() => { + fireEvent.click(screen.getByText('New Release')) + }) + expect(defaultProps.onCreateRelease).toHaveBeenCalled() + }) + + it('calls onCreateVersion when a release is clicked and sets the perspective to the release', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + + fireEvent.click(screen.getByText('Copy version to')) + await waitFor(() => { + fireEvent.click(screen.getByText('Release 2')) + }) + expect(defaultProps.onCreateRelease).toHaveBeenCalled() + }) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx new file mode 100644 index 00000000000..8db2e334c28 --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx @@ -0,0 +1,85 @@ +import {render, screen} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import {type ReleaseDocument, type ReleaseType} from '../../../../store/types' +import {VersionContextMenuItem} from '../VersionContextMenuItem' + +const mockRelease: ReleaseDocument = { + _id: '_.releases.1', + _type: 'system.release', + createdBy: '', + _createdAt: '', + _updatedAt: '', + state: 'active', + name: '1', + metadata: { + title: 'Test Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-01T10:00:00Z', + }, +} + +vi.mock('../../../../../util/formatRelativeLocale', () => ({ + formatRelativeLocale: () => 'formatted date', +})) + +describe('VersionContextMenuItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders release title', async () => { + const wrapper = await createTestProvider() + render(, {wrapper}) + expect(screen.getByText('Test Release')).toBeInTheDocument() + }) + + it('renders release type as scheduled with date', async () => { + const wrapper = await createTestProvider() + const scheduledRelease = {...mockRelease, releaseType: 'scheduled' as ReleaseType} + + render(, {wrapper}) + expect(screen.getByText('formatted date')).toBeInTheDocument() + }) + + it('renders release type as ASAP', async () => { + const asapRelease: ReleaseDocument = { + ...mockRelease, + metadata: {...mockRelease.metadata, releaseType: 'asap'}, + } + const wrapper = await createTestProvider() + + render(, {wrapper}) + expect(screen.getByText('ASAP')).toBeInTheDocument() + }) + + it('renders release type as undecided', async () => { + const asapRelease: ReleaseDocument = { + ...mockRelease, + metadata: {...mockRelease.metadata, releaseType: 'undecided'}, + } + const wrapper = await createTestProvider() + + render(, {wrapper}) + expect(screen.getByText('Undecided')).toBeInTheDocument() + }) + + it('renders "Unknown date" for scheduled release without date', async () => { + const noDateRelease: ReleaseDocument = { + ...mockRelease, + metadata: {...mockRelease.metadata, releaseType: 'scheduled', intendedPublishAt: undefined}, + } + const wrapper = await createTestProvider() + + render(, {wrapper}) + expect(screen.getByText('Unknown date')).toBeInTheDocument() + }) + + it('renders ReleaseAvatar component', async () => { + const wrapper = await createTestProvider() + + render(, {wrapper}) + expect(screen.getByTestId('release-avatar-primary')).toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx new file mode 100644 index 00000000000..769b2bd1bcf --- /dev/null +++ b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx @@ -0,0 +1,164 @@ +import {useTelemetry} from '@sanity/telemetry/react' +import {type BadgeTone, Box, Card, Flex, Text, useToast} from '@sanity/ui' +import {useCallback, useState} from 'react' + +import {Dialog} from '../../../../../ui-components/dialog/Dialog' +import {LoadingBlock} from '../../../../components/loadingBlock/LoadingBlock' +import {useSchema} from '../../../../hooks/useSchema' +import {useTranslation} from '../../../../i18n/hooks/useTranslation' +import {Preview} from '../../../../preview/components/Preview' +import {CreatedRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {type EditableReleaseDocument} from '../../../store/types' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {DEFAULT_RELEASE_TYPE} from '../../../util/const' +import {createReleaseId} from '../../../util/createReleaseId' +import {getIsScheduledDateInPast} from '../../../util/getIsScheduledDateInPast' +import {ReleaseForm} from '../../dialog/ReleaseForm' +import {ReleaseAvatar} from '../../ReleaseAvatar' + +export function CopyToNewReleaseDialog(props: { + onClose: () => void + documentId: string + documentType: string + tone: BadgeTone + title: string + onCreateVersion: (releaseId: string) => void +}): React.JSX.Element { + const {onClose, documentId, documentType, tone, title, onCreateVersion} = props + const {t} = useTranslation() + const {t: tRelease} = useTranslation(releasesLocaleNamespace) + const toast = useToast() + + const schema = useSchema() + const schemaType = schema.get(documentType) + + const [newReleaseId] = useState(createReleaseId) + + const [release, setRelease] = useState((): EditableReleaseDocument => { + return { + _id: newReleaseId, + metadata: { + title: '', + description: '', + releaseType: DEFAULT_RELEASE_TYPE, + }, + } as const + }) + const [isSubmitting, setIsSubmitting] = useState(false) + /** + * This state supports the scenario of: + * release.intendedPublishAt is set to a valid future date; but at time of submit it is in the past + * Without an update on this state, CopyToNewReleaseDialog would not rerender + * and so date in past warning ui elements wouldn't show + */ + const [, setRerenderDialog] = useState(0) + + const telemetry = useTelemetry() + const {createRelease} = useReleaseOperations() + + const displayTitle = title || t('release.placeholder-untitled-release') + + const isScheduledDateInPast = getIsScheduledDateInPast(release) + + const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => { + setRelease(changedValue) + }, []) + + const handleAddVersion = useCallback(async () => { + onCreateVersion(newReleaseId) + }, [onCreateVersion, newReleaseId]) + + const handleCreateRelease = useCallback(async () => { + // re-evaluate if date is in past + // as dialog could have been left idle for a while + if (getIsScheduledDateInPast(release)) { + setRerenderDialog((cur) => cur + 1) + return // do not submit if date is in past + } + + try { + setIsSubmitting(true) + + await createRelease(release) + + await handleAddVersion() + telemetry.log(CreatedRelease, {origin: 'document-panel'}) + } catch (err) { + console.error(err) + toast.push({ + closable: true, + status: 'error', + title: t('release.toast.create-release-error.title'), + description: err.message, + }) + } finally { + setIsSubmitting(false) + } + }, [release, toast, createRelease, handleAddVersion, telemetry, t]) + + return ( + + + + {schemaType ? ( + + ) : ( + + )} + + + + + {displayTitle} + + + + + + + {isScheduledDateInPast && ( + + {tRelease('schedule-dialog.publish-date-in-past-warning')} + + )} + + + + ) +} diff --git a/packages/sanity/src/core/releases/components/index.ts b/packages/sanity/src/core/releases/components/index.ts new file mode 100644 index 00000000000..1f7c1c4fbe7 --- /dev/null +++ b/packages/sanity/src/core/releases/components/index.ts @@ -0,0 +1,5 @@ +export * from './dialog/DiscardVersionDialog' +export * from './dialog/ReleaseForm' +export * from './documentHeader/VersionChip' +export * from './ReleaseAvatar' +export * from './VersionInlineBadge' diff --git a/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx b/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx new file mode 100644 index 00000000000..a7544ea9c9b --- /dev/null +++ b/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx @@ -0,0 +1,113 @@ +import {useCallback, useContext, useEffect, useMemo, useState} from 'react' +import {useObservable} from 'react-rx' +import {ReleasesMetadataContext} from 'sanity/_singletons' + +import {type MetadataWrapper} from '../store/createReleaseMetadataAggregator' +import {type ReleasesMetadata} from '../store/useReleasesMetadata' +import {useReleasesStore} from '../store/useReleasesStore' + +/** + * @internal + */ +export interface ReleasesMetadataContextValue { + state: MetadataWrapper + addReleaseIdsToListener: (slugs: string[]) => void + removeReleaseIdsFromListener: (slugs: string[]) => void +} + +const DEFAULT_METADATA_STATE: MetadataWrapper = { + data: null, + error: null, + loading: false, +} + +const ReleasesMetadataProviderInner = ({children}: {children: React.ReactNode}) => { + const [listenerReleaseIds, setListenerReleaseIds] = useState([]) + const {getMetadataStateForSlugs$} = useReleasesStore() + const [releasesMetadata, setReleasesMetadata] = useState | null>( + null, + ) + + const memoObservable = useMemo( + () => getMetadataStateForSlugs$(listenerReleaseIds.map((slug) => slug)), + [getMetadataStateForSlugs$, listenerReleaseIds], + ) + + const observedResult = useObservable(memoObservable) || DEFAULT_METADATA_STATE + + // patch metadata in local state + useEffect( + () => + setReleasesMetadata((prevReleaseMetadata) => { + if (!observedResult.data) return prevReleaseMetadata + + return {...(prevReleaseMetadata || {}), ...observedResult.data} + }), + [observedResult.data], + ) + + const addReleaseIdsToListener = useCallback((addReleaseIds: (string | undefined)[]) => { + setListenerReleaseIds((prevSlugs) => [ + ...prevSlugs, + ...addReleaseIds.filter((releaseId): releaseId is string => typeof releaseId === 'string'), + ]) + }, []) + + const removeReleaseIdsFromListener = useCallback((releaseIds: string[]) => { + setListenerReleaseIds((prevSlugs) => { + const {nextSlugs} = prevSlugs.reduce<{removedSlugs: string[]; nextSlugs: string[]}>( + (acc, slug) => { + const {removedSlugs, nextSlugs: accNextSlugs} = acc + /** + * In cases where multiple consumers are listening to the same release id + * the release id will appear multiple times in listenerReleaseIds array + * removing should only remove 1 instance of the slug and retain all others + */ + if (releaseIds.includes(slug) && !removedSlugs.includes(slug)) { + return {removedSlugs: [...removedSlugs, slug], nextSlugs: accNextSlugs} + } + return {removedSlugs, nextSlugs: [...accNextSlugs, slug]} + }, + {removedSlugs: [], nextSlugs: []}, + ) + return nextSlugs + }) + }, []) + + const context = useMemo<{ + addReleaseIdsToListener: (slugs: string[]) => void + removeReleaseIdsFromListener: (slugs: string[]) => void + state: MetadataWrapper + }>( + () => ({ + addReleaseIdsToListener: addReleaseIdsToListener, + removeReleaseIdsFromListener: removeReleaseIdsFromListener, + state: {...observedResult, data: releasesMetadata}, + }), + [addReleaseIdsToListener, releasesMetadata, observedResult, removeReleaseIdsFromListener], + ) + + return ( + {children} + ) +} + +export const ReleasesMetadataProvider = ({children}: {children: React.ReactNode}) => { + const context = useContext(ReleasesMetadataContext) + + // Avoid mounting the provider if it's already provided by a parent + if (context) return children + return {children} +} + +export const useReleasesMetadataProvider = (): ReleasesMetadataContextValue => { + const contextValue = useContext(ReleasesMetadataContext) + + return ( + contextValue || { + state: DEFAULT_METADATA_STATE, + addReleaseIdsToListener: () => null, + removeReleaseIdsFromListener: () => null, + } + ) +} diff --git a/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts new file mode 100644 index 00000000000..77f51b732c5 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {useVersionOperations, type VersionOperationsValue} from '../../useVersionOperations' + +export const useVersionOperationsReturn: Mocked = { + createVersion: vi.fn(), + discardVersion: vi.fn(), + unpublishVersion: vi.fn(), +} + +export const mockUseVersionOperations = useVersionOperations as Mock diff --git a/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx new file mode 100644 index 00000000000..4c306f3bc92 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx @@ -0,0 +1,66 @@ +import {renderHook, waitFor} from '@testing-library/react' +import {delay, of} from 'rxjs' +import {describe, expect, it, type Mock, vi} from 'vitest' + +import {type DocumentPreviewStore} from '../../../preview' +import {type DocumentIdSetObserverState} from '../../../preview/liveDocumentIdSet' +import {useDocumentPreviewStore} from '../../../store' +import {activeASAPRelease, activeScheduledRelease} from '../../__fixtures__/release.fixture' +import {type ReleaseDocument} from '../../store' +import {useDocumentVersions} from '../useDocumentVersions' + +vi.mock('../../store', () => ({ + useReleasesMetadata: vi.fn(), + useActiveReleases: vi.fn(), +})) + +vi.mock('../../store/useReleasesIds', () => ({ + useReleasesIds: vi.fn(), +})) + +vi.mock('../../../store', () => ({ + useDocumentPreviewStore: vi.fn(), +})) + +async function setupMocks({versionIds}: {releases: ReleaseDocument[]; versionIds: string[]}) { + const mockDocumentPreviewStore = useDocumentPreviewStore as Mock + + mockDocumentPreviewStore.mockReturnValue({ + unstable_observeDocumentIdSet: vi + .fn() + .mockImplementation(() => + of({status: 'connected', documentIds: versionIds} as DocumentIdSetObserverState).pipe( + // simulate async initial emission + delay(0), + ), + ), + } as unknown as DocumentPreviewStore) +} + +describe('useDocumentVersions', () => { + it('should return initial state', async () => { + await setupMocks({releases: [activeASAPRelease, activeScheduledRelease], versionIds: []}) + + const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'})) + expect(result.current.loading).toBe(true) + expect(result.current.error).toBe(null) + expect(result.current.data).toEqual([]) + }) + + it('should return an empty array if no versions are found', async () => { + await setupMocks({releases: [activeASAPRelease, activeScheduledRelease], versionIds: []}) + const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'})) + expect(result.current.data).toEqual([]) + }) + + it('should return the releases if versions are found', async () => { + await setupMocks({ + releases: [activeASAPRelease], + versionIds: ['versions.rASAP.document-1'], + }) + const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'})) + await waitFor(() => { + expect(result.current.data).toEqual(['versions.rASAP.document-1']) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx b/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx new file mode 100644 index 00000000000..8bbc5f7d1db --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx @@ -0,0 +1,69 @@ +import {act, renderHook} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import {usePerspectiveMockReturn} from '../../../perspective/__mocks__/usePerspective.mock' +import {useReleaseOperationsMockReturn} from '../../store/__tests__/__mocks/useReleaseOperations.mock' +import {useVersionOperations} from '../useVersionOperations' + +vi.mock('../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../../../perspective/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +const mockedUseSetPerspective = vi.fn() +vi.mock('../../../perspective/useSetPerspective', () => ({ + useSetPerspective: vi.fn(() => mockedUseSetPerspective), +})) + +describe('useVersionOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should create a version successfully', async () => { + const wrapper = await createTestProvider() + const {result} = renderHook(() => useVersionOperations(), {wrapper}) + + await act(async () => { + await result.current.createVersion('releaseId', 'documentId') + }) + + expect(useReleaseOperationsMockReturn.createVersion).toHaveBeenCalledWith( + 'releaseId', + 'documentId', + undefined, + ) + expect(mockedUseSetPerspective).toHaveBeenCalledWith('releaseId') + }) + + it('should discard a version successfully', async () => { + const wrapper = await createTestProvider() + const {result} = renderHook(() => useVersionOperations(), {wrapper}) + + await act(async () => { + await result.current.discardVersion('releaseId', 'documentId') + }) + + expect(useReleaseOperationsMockReturn.discardVersion).toHaveBeenCalledWith( + 'releaseId', + 'documentId', + ) + }) + + it('should unpublish a version successfully', async () => { + const wrapper = await createTestProvider() + const {result} = renderHook(() => useVersionOperations(), {wrapper}) + + await act(async () => { + await result.current.unpublishVersion('versions.release.documentId') + }) + + expect(useReleaseOperationsMockReturn.unpublishVersion).toHaveBeenCalledWith( + 'versions.release.documentId', + ) + }) +}) diff --git a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts new file mode 100644 index 00000000000..795f5f95e4e --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts @@ -0,0 +1,246 @@ +import {type ReleaseId} from '@sanity/client' +import {describe, expect, it} from 'vitest' + +import {RELEASE_DOCUMENT_TYPE} from '../../store/constants' +import {type ReleaseDocument} from '../../store/types' +import {createReleaseId} from '../../util/createReleaseId' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {getReleasesPerspectiveStack, sortReleases} from '../utils' + +function createReleaseMock( + value: Partial< + Omit & { + metadata: Partial + } + >, +): ReleaseDocument { + const id = value._id || createReleaseId() + const name = getReleaseIdFromReleaseDocumentId(id) + return { + _id: id, + _rev: 'rev', + _type: RELEASE_DOCUMENT_TYPE, + _createdAt: new Date().toISOString(), + _updatedAt: new Date().toISOString(), + state: 'active', + ...value, + metadata: { + title: `Release ${name}`, + releaseType: 'asap', + ...value.metadata, + }, + } +} +describe('sortReleases()', () => { + it('should return the asap releases ordered by createdAt', () => { + const releases: ReleaseDocument[] = [ + createReleaseMock({ + _id: '_.releases.rasap1', + _createdAt: '2024-10-24T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + createReleaseMock({ + _id: '_.releases.rasap2', + _createdAt: '2024-10-25T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + ] + const sorted = sortReleases(releases) + const expectedOrder = ['rasap2', 'rasap1'] + expectedOrder.forEach((expectedName, idx) => { + expect(getReleaseIdFromReleaseDocumentId(sorted[idx]._id)).toBe(expectedName) + }) + }) + it('should return the scheduled releases ordered by intendedPublishAt or publishAt', () => { + const releases: ReleaseDocument[] = [ + createReleaseMock({ + _id: '_.releases.rfuture2', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-11-25T00:00:00Z', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture1', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-11-23T00:00:00Z', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture4', + state: 'scheduled', + publishAt: '2024-11-31T00:00:00Z', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-10-20T00:00:00Z', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture3', + state: 'scheduled', + publishAt: '2024-11-26T00:00:00Z', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-11-22T00:00:00Z', + }, + }), + ] + const sorted = sortReleases(releases) + const expectedOrder = ['rfuture4', 'rfuture3', 'rfuture2', 'rfuture1'] + expectedOrder.forEach((expectedName, idx) => { + expect(getReleaseIdFromReleaseDocumentId(sorted[idx]._id)).toBe(expectedName) + }) + }) + it('should return the undecided releases ordered by createdAt', () => { + const releases: ReleaseDocument[] = [ + createReleaseMock({ + _id: '_.releases.rundecided1', + _createdAt: '2024-10-25T00:00:00Z', + metadata: { + releaseType: 'undecided', + }, + }), + createReleaseMock({ + _id: '_.releases.rundecided2', + _createdAt: '2024-10-26T00:00:00Z', + metadata: { + releaseType: 'undecided', + }, + }), + ] + const sorted = sortReleases(releases) + const expectedOrder = ['rundecided2', 'rundecided1'] + expectedOrder.forEach((expectedName, idx) => { + expect(getReleaseIdFromReleaseDocumentId(sorted[idx]._id)).toBe(expectedName) + }) + }) + it("should gracefully combine all release types, and sort them by 'undecided', 'scheduled', 'asap'", () => { + const releases = [ + createReleaseMock({ + _id: '_.releases.rasap2', + _createdAt: '2024-10-25T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + createReleaseMock({ + _id: '_.releases.rasap1', + _createdAt: '2024-10-24T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + createReleaseMock({ + _id: '_.releases.rundecided2', + _createdAt: '2024-10-26T00:00:00Z', + metadata: { + releaseType: 'undecided', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture4', + state: 'scheduled', + publishAt: '2024-11-31T00:00:00Z', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-10-20T00:00:00Z', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture1', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-11-23T00:00:00Z', + }, + }), + ] + const sorted = sortReleases(releases) + const expectedOrder = ['rundecided2', 'rfuture4', 'rfuture1', 'rasap2', 'rasap1'] + expectedOrder.forEach((expectedName, idx) => { + expect(getReleaseIdFromReleaseDocumentId(sorted[idx]._id)).toBe(expectedName) + }) + }) +}) + +describe('getReleasesPerspectiveStack()', () => { + const releases = [ + createReleaseMock({ + _id: '_.releases.rasap2', + _createdAt: '2024-10-25T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + createReleaseMock({ + _id: '_.releases.rasap1', + _createdAt: '2024-10-24T00:00:00Z', + metadata: { + releaseType: 'asap', + }, + }), + createReleaseMock({ + _id: '_.releases.rundecided2', + _createdAt: '2024-10-26T00:00:00Z', + metadata: { + releaseType: 'undecided', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture4', + state: 'scheduled', + publishAt: '2024-11-31T00:00:00Z', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-10-20T00:00:00Z', + }, + }), + createReleaseMock({ + _id: '_.releases.rfuture1', + metadata: { + releaseType: 'scheduled', + intendedPublishAt: '2024-11-23T00:00:00Z', + }, + }), + ] + // Define your test cases with the expected outcomes + const testCases: { + selectedPerspectiveName: ReleaseId | 'published' | undefined + excludedPerspectives: string[] + expected: string[] + }[] = [ + {selectedPerspectiveName: 'rasap1', excludedPerspectives: [], expected: ['rasap1', 'drafts']}, + { + selectedPerspectiveName: 'rasap2', + excludedPerspectives: [], + expected: ['rasap2', 'rasap1', 'drafts'], + }, + { + selectedPerspectiveName: 'rundecided2', + excludedPerspectives: [], + expected: ['rundecided2', 'rfuture4', 'rfuture1', 'rasap2', 'rasap1', 'drafts'], + }, + { + selectedPerspectiveName: 'rundecided2', + excludedPerspectives: ['rfuture1', 'drafts'], + expected: ['rundecided2', 'rfuture4', 'rasap2', 'rasap1'], + }, + {selectedPerspectiveName: 'published', excludedPerspectives: [], expected: []}, + {selectedPerspectiveName: undefined, excludedPerspectives: [], expected: []}, + ] + it.each(testCases)( + 'should return the correct release stack for %s', + ({selectedPerspectiveName, excludedPerspectives, expected}) => { + const result = getReleasesPerspectiveStack({ + releases, + selectedPerspectiveName, + excludedPerspectives, + }) + expect(result).toEqual(expected) + }, + ) +}) diff --git a/packages/sanity/src/core/releases/hooks/index.ts b/packages/sanity/src/core/releases/hooks/index.ts new file mode 100644 index 00000000000..9fd6022cbc8 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useDocumentVersions' +export * from './useIsReleaseActive' +export * from './useVersionOperations' diff --git a/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx new file mode 100644 index 00000000000..27f4369e0f5 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx @@ -0,0 +1,55 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {map, of} from 'rxjs' +import {catchError} from 'rxjs/operators' + +import {useDocumentPreviewStore} from '../../store' +import {getPublishedId} from '../../util/draftUtils' +import {createSWR} from '../../util/rxSwr' + +export interface DocumentPerspectiveProps { + documentId: string +} + +export interface DocumentPerspectiveState { + data: string[] + error?: unknown + loading: boolean +} + +const swr = createSWR<{documentIds: string[]}>({maxSize: 100}) + +/** + * Fetches the document versions for a given document + * @param props - document Id of the document (might include release id) + * @returns - data: document versions, loading, errors + * @hidden + * @beta + */ +export function useDocumentVersions(props: DocumentPerspectiveProps): DocumentPerspectiveState { + const {documentId} = props + + const publishedId = getPublishedId(documentId) + + const documentPreviewStore = useDocumentPreviewStore() + + const observable = useMemo(() => { + return documentPreviewStore + .unstable_observeDocumentIdSet(`sanity::versionsOf("${publishedId}")`) + .pipe( + swr(`${publishedId}`), + map(({value}) => ({ + data: value.documentIds, + loading: false, + error: null, + })), + catchError((error) => { + return of({error, data: [] as string[], loading: false}) + }), + ) + }, [documentPreviewStore, publishedId]) + + const result = useObservable(observable, {data: [], error: null, loading: true}) + + return result +} diff --git a/packages/sanity/src/core/releases/hooks/useIsReleaseActive.ts b/packages/sanity/src/core/releases/hooks/useIsReleaseActive.ts new file mode 100644 index 00000000000..c9323c33b50 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useIsReleaseActive.ts @@ -0,0 +1,14 @@ +import {usePerspective} from '../../perspective/usePerspective' +import {isReleaseDocument} from '../store/types' +import {isDraftPerspective, isPublishedPerspective} from '../util/util' + +/** @internal */ +export const useIsReleaseActive = () => { + const {selectedPerspective} = usePerspective() + + return ( + !isPublishedPerspective(selectedPerspective) && + (isDraftPerspective(selectedPerspective) || + (isReleaseDocument(selectedPerspective) && selectedPerspective.state === 'active')) + ) +} diff --git a/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx new file mode 100644 index 00000000000..5959857b08e --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx @@ -0,0 +1,107 @@ +import {type ReleaseId} from '@sanity/client' +import {useTelemetry} from '@sanity/telemetry/react' +import {useToast} from '@sanity/ui' + +import {Translate, useTranslation} from '../../i18n' +import {useSetPerspective} from '../../perspective/useSetPerspective' +import {getDocumentVariantType} from '../../util/getDocumentVariantType' +import {AddedVersion} from '../__telemetry__/releases.telemetry' +import {useReleaseOperations} from '../store/useReleaseOperations' + +export interface VersionOperationsValue { + createVersion: ( + releaseId: ReleaseId, + documentId: string, + initialValue?: Record, + ) => Promise + discardVersion: (releaseId: string, documentId: string) => Promise + unpublishVersion: (documentId: string) => Promise +} + +/** @internal */ +export function useVersionOperations(): VersionOperationsValue { + const telemetry = useTelemetry() + const {createVersion, discardVersion, unpublishVersion} = useReleaseOperations() + + const setPerspective = useSetPerspective() + + const toast = useToast() + const {t} = useTranslation() + + const handleCreateVersion = async ( + releaseId: ReleaseId, + documentId: string, + initialValue?: Record, + ) => { + const origin = getDocumentVariantType(documentId) + try { + await createVersion(releaseId, documentId, initialValue) + setPerspective(releaseId) + telemetry.log(AddedVersion, { + documentOrigin: origin, + }) + } catch (err) { + toast.push({ + closable: true, + status: 'error', + title: t('release.action.create-version.failure'), + description: err.message, + }) + } + } + + const handleDiscardVersion = async (releaseId: string, documentId: string) => { + try { + await discardVersion(releaseId, documentId) + + toast.push({ + closable: true, + status: 'success', + description: ( + + ), + }) + } catch (err) { + toast.push({ + closable: true, + status: 'error', + title: t('release.action.discard-version.failure'), + description: err.message, + }) + } + } + + const handleUnpublishVersion = async (documentId: string) => { + try { + await unpublishVersion(documentId) + + toast.push({ + closable: true, + status: 'success', + description: ( + + ), + }) + } catch (err) { + toast.push({ + closable: true, + status: 'error', + title: t('release.action.unpublish-version.failure'), + description: err.message, + }) + } + } + return { + createVersion: handleCreateVersion, + discardVersion: handleDiscardVersion, + unpublishVersion: handleUnpublishVersion, + } +} diff --git a/packages/sanity/src/core/releases/hooks/utils.ts b/packages/sanity/src/core/releases/hooks/utils.ts new file mode 100644 index 00000000000..12bacd67fed --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/utils.ts @@ -0,0 +1,76 @@ +import {type ClientPerspective, type ReleaseId} from '@sanity/client' + +import {type PerspectiveStack} from '../../perspective/types' +import {DRAFTS_FOLDER} from '../../util/draftUtils' +import {type ReleaseDocument} from '../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../util/getReleaseIdFromReleaseDocumentId' + +export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[] { + // The order should always be: + // [undecided (sortByCreatedAt), scheduled(sortBy publishAt || metadata.intendedPublishAt), asap(sortByCreatedAt)] + return [...releases].sort((a, b) => { + // undecided are always first, then by createdAt descending + if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType !== 'undecided') { + return -1 + } + if (a.metadata.releaseType !== 'undecided' && b.metadata.releaseType === 'undecided') { + return 1 + } + if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType === 'undecided') { + // Sort by createdAt + return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime() + } + + // Scheduled are always at the middle, then by publishAt descending + if (a.metadata.releaseType === 'scheduled' && b.metadata.releaseType === 'scheduled') { + const aPublishAt = a.publishAt || a.metadata.intendedPublishAt + if (!aPublishAt) { + return 1 + } + const bPublishAt = b.publishAt || b.metadata.intendedPublishAt + if (!bPublishAt) { + return -1 + } + return new Date(bPublishAt).getTime() - new Date(aPublishAt).getTime() + } + + // ASAP are always last, then by createdAt descending + if (a.metadata.releaseType === 'asap' && b.metadata.releaseType !== 'asap') { + return 1 + } + if (a.metadata.releaseType !== 'asap' && b.metadata.releaseType === 'asap') { + return -1 + } + if (a.metadata.releaseType === 'asap' && b.metadata.releaseType === 'asap') { + // Sort by createdAt + return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime() + } + + return 0 + }) +} + +export function getReleasesPerspectiveStack({ + selectedPerspectiveName, + releases, + excludedPerspectives, +}: { + selectedPerspectiveName: ReleaseId | undefined | 'published' + releases: ReleaseDocument[] + excludedPerspectives: string[] +}): PerspectiveStack { + if (!selectedPerspectiveName || selectedPerspectiveName === 'published') { + return [] + } + const sorted: ClientPerspective = sortReleases(releases).map((release) => + getReleaseIdFromReleaseDocumentId(release._id), + ) + const selectedIndex = sorted.indexOf(selectedPerspectiveName) + if (selectedIndex === -1) { + return [] + } + return sorted + .slice(selectedIndex) + .concat(DRAFTS_FOLDER) + .filter((name) => !excludedPerspectives.includes(name)) +} diff --git a/packages/sanity/src/core/releases/i18n/index.ts b/packages/sanity/src/core/releases/i18n/index.ts new file mode 100644 index 00000000000..0580e6b9a42 --- /dev/null +++ b/packages/sanity/src/core/releases/i18n/index.ts @@ -0,0 +1,29 @@ +import {type LocaleResourceBundle} from '../../i18n' + +/** + * The locale namespace for the releases tool + * + * @public + */ +// api extractor take issues with 'as const' for literals +// eslint-disable-next-line @typescript-eslint/prefer-as-const +export const releasesLocaleNamespace: 'releases' = 'releases' + +/** + * The default locale release for the releases tool, which is US English. + * + * @internal + */ +export const releasesUsEnglishLocaleBundle: LocaleResourceBundle = { + locale: 'en-US', + namespace: releasesLocaleNamespace, + resources: () => import('./resources'), +} + +/** + * The locale resource keys for the releases tool. + * + * @alpha + * @hidden + */ +export type {ReleasesLocaleResourceKeys} from './resources' diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts new file mode 100644 index 00000000000..796d11fdcbd --- /dev/null +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -0,0 +1,346 @@ +/** + * Defined locale strings for the releases tool, in US English. + * + * @internal + */ +const releasesLocaleStrings = { + /** Action text for adding a document to release */ + 'action.add-document': 'Add document', + /** Action text for archiving a release */ + 'action.archive': 'Archive release', + /** Tooltip for when the archive release action is disabled due to release being scheduled */ + 'action.archive.tooltip': 'Unschedule this release to archive it', + /** Action text for showing the archived releases */ + 'action.archived': 'Archived', + /** Action text for comparing document versions */ + 'action.compare-versions': 'Compare versions', + /** Action text for reverting a release by creating a new release */ + 'action.create-revert-release': 'Stage in new release', + /** Action text for deleting a release */ + 'action.delete-release': 'Delete release', + /** Action text for editing a release */ + 'action.edit': 'Edit release', + /** Action text for opening a release */ + 'action.open': 'Open', + /** Action text for scheduling a release */ + 'action.schedule': 'Schedule for publishing...', + /** Action text for unpublishing a document in a release in the context menu */ + 'action.unpublish': 'Unpublish', + /** Action message for scheduling an unpublished of a document */ + 'action.unpublish-doc-actions': 'Unpublish when releasing', + /** Action text for unscheduling a release */ + 'action.unschedule': 'Unschedule for publishing', + /** Action text for publishing all documents in a release (and the release itself) */ + 'action.publish-all-documents': 'Publish all documents', + /** Text for the review changes button in release tool */ + 'action.review': 'Review changes', + /** Action text for reverting a release */ + 'action.revert': 'Revert release', + /** Text for the summary button in release tool */ + 'actions.summary': 'Summary', + /** Action text for reverting a release immediately without staging changes */ + 'action.immediate-revert-release': 'Revert now', + /** Label for unarchiving a release */ + 'action.unarchive': 'Unarchive release', + /* The text for the activity event when a document is added to a release */ + 'activity.event.add-document': 'added a document version', + /* The text for the activity event when the release is archived */ + 'activity.event.archive': 'archived the {{releaseTitle}} release', + /* The text for the activity event when the release is created */ + 'activity.event.create': + 'created the {{releaseTitle}} release targeting ', + /* The text for the activity event when a document is removed from a release */ + 'activity.event.discard-document': 'discarded a document version', + 'activity.event.edit': 'set release time to ', + /**The text to display in the changes when the release type changes to asap */ + 'activity.event.edit-time-asap': 'immediately', + /**The text to display in the changes when the release type changes to undecided */ + 'activity.event.edit-time-undecided': 'never', + /* The text for the activity event when the release is published */ + 'activity.event.publish': 'published the {{releaseTitle}} release', + /* The text for the activity event when the release is scheduled */ + 'activity.event.schedule': 'marked as scheduled', + /** The text for the activity event when the release is unarchived */ + 'activity.event.unarchive': 'unarchived the {{releaseTitle}} release', + /** The text for the activity event when the release is unscheduled */ + 'activity.event.unschedule': 'marked as unscheduled', + /** The loading text for when releases are loading */ + 'activity.panel.loading': 'Loading release activity', + /** The loading text for when releases are loading */ + 'activity.panel.error': 'An error occurred getting the release activity', + /** The title for the activity panel shown in the releases detail screen */ + 'activity.panel.title': 'Activity', + + /** Header for the dialog confirming the archive of a release */ + 'archive-dialog.confirm-archive-header': + "Are you sure you want to archive the '{{title}}' release?", + /** Title for the dialog confirming the archive of a release */ + 'archive-dialog.confirm-archive-title': + "Are you sure you want to archive the '{{title}}' release?", + /** Description for the dialog confirming the archive of a release with no documents */ + 'archive-dialog.confirm-archive-description_zero': 'This will not archive any documents.', + /** Description for the dialog confirming the archive of a release with one document */ + 'archive-dialog.confirm-archive-description_one': 'This will archive 1 document version.', + /** Description for the dialog confirming the archive of a release with more than one document */ + 'archive-dialog.confirm-archive-description_other': + 'This will archive {{count}} document versions.', + /** Label for the button to proceed with archiving a release */ + 'archive-dialog.confirm-archive-button': 'Yes, archive now', + + /** Title for changes to published documents */ + 'changes-published-docs.title': 'Changes to published documents', + /** Text for when a release / document was created */ + 'created': 'Created ', + + /** Text for the releases detail screen when a release was published ASAP */ + 'dashboard.details.published-asap': 'Published', + /** Text for the releases detail screen when a release was published from scheduling */ + 'dashboard.details.published-on': 'Published on {{date}}', + + /** Text for the releases detail screen in the pin release button. */ + 'dashboard.details.pin-release': 'Pin release', + + /** Activity inspector button text */ + 'dashboard.details.activity': 'Activity', + + /** Header for deleting a release dialog */ + 'delete-dialog.confirm-delete.header': "Are you sure you want to delete the '{{title}}' release?", + /** Description for the dialog confirming the deleting of a release with no documents */ + 'delete-dialog.confirm-delete-description_zero': 'This will not delete any documents.', + /** Description for the dialog confirming the deleting of a release with one document */ + 'delete-dialog.confirm-delete-description_one': 'This will delete 1 document version.', + /** Description for the dialog confirming the deleting of a release with more than one document */ + 'delete-dialog.confirm-delete-description_other': 'This will delete {{count}} document versions.', + /** Label for the button to proceed deleting a release */ + 'delete-dialog.confirm-delete-button': 'Delete', + + /** Text for when there's no changes in a release diff */ + 'diff.no-changes': 'No changes', + /** Text for when there's no changes in a release diff */ + 'diff.list-empty': 'Changes list is empty, see document', + /** Description for discarding a version of a document dialog */ + 'discard-version-dialog.description': + "The '{{title}}' version of this document will be permanently deleted.", + /** Header for discarding a version of a document dialog */ + 'discard-version-dialog.header': 'Are you sure you want to discard the document version?', + /** Title for dialog for discarding a version of a document */ + 'discard-version-dialog.title': 'Discard version', + + /** Label for the count of documents in to a release when only 1 document added */ + 'document-count_one': '{{count}} document', + /** Label for the count of documents in to a release */ + 'document-count_other': '{{count}} documents', + + /** Text for when documents of a release are loading */ + 'document-loading': 'Loading documents', + /** Label for when a document in a release has multiple validation warnings */ + 'document-validation.error_other': '{{count}} validation errors', + /** Label for when a document in a release has a single validation warning */ + 'document-validation.error_one': '{{count}} validation error', + + /** Label when a release has been deleted by a different user */ + 'deleted-release': "The '{{title}}' release has been deleted", + + /** Title text when error during release update */ + 'failed-edit-title': 'Failed to save changes', + /**The text that will be shown in the footer to indicate the time the release was archived */ + 'footer.status.archived': 'Archived', + /**The text that will be shown in the footer to indicate the time the release was created */ + 'footer.status.created': 'Created', + /**The text that will be shown in the footer to indicate the time the release was created */ + 'footer.status.edited': 'Edited', + /**The text that will be shown in the footer to indicate the time the release was published */ + 'footer.status.published': 'Published', + /**The text that will be shown in the footer to indicate the time the release was unarchived */ + 'footer.status.unarchived': 'Unarchived', + /** Label text for the loading state whilst release is being loaded */ + 'loading-release': 'Loading release', + + /** Label for the release menu */ + 'menu.label': 'Release menu', + /** Tooltip for the release menu */ + 'menu.tooltip': 'Actions', + /** Label for title of actions for "when releasing" */ + 'menu.group.when-releasing': 'When releasing', + + /** Text for when no archived releases are found */ + 'no-archived-release': 'No archived releases', + /** Text for when no releases are found */ + 'no-releases': 'No Releases', + /** Text for when a release is not found */ + 'not-found': 'Release not found: {{releaseId}}', + + /** Description for the release tool */ + 'overview.description': + 'Releases are collections of document versions which can be managed and published together.', + /** Text for the placeholder in the search release input */ + 'overview.search-releases-placeholder': 'Search releases', + /** Title for the release tool */ + 'overview.title': 'Releases', + + /** Title for the dialog confirming the publish of a release */ + 'publish-dialog.confirm-publish.title': + 'Are you sure you want to publish the release and all document versions?', + /** Description for the dialog confirming the publish of a release with one document */ + 'publish-dialog.confirm-publish-description_one': + "The '{{title}}' release and its document will be published.", + /** Description for the dialog confirming the publish of a release with multiple documents */ + 'publish-dialog.confirm-publish-description_other': + "The '{{title}}' release and its {{releaseDocumentsLength}} documents will be published.", + /** Label for when documents are being validated */ + 'publish-dialog.validation.loading': 'Validating documents...', + /** Label for when documents in release have validation errors */ + 'publish-dialog.validation.error': 'Some documents have validation errors', + + /** Description for the review changes button in release tool */ + 'review.description': 'Add documents to this release to review changes', + /** Text for when a document is edited */ + 'review.edited': 'Edited ', + /** Description for the dialog confirming the revert of a release with multiple documents */ + 'revert-dialog.confirm-revert-description_one': + 'This will revert {{releaseDocumentsLength}} document version.', + /** Description for the dialog confirming the revert of a release with multiple documents */ + 'revert-dialog.confirm-revert-description_other': + 'This will revert {{releaseDocumentsLength}} document versions.', + /** Title for the dialog confirming the revert of a release */ + 'revert-dialog.confirm-revert.title': "Are you sure you want to revert the '{{title}}' release?", + /** Checkbox label to confirm whether to create a staged release for revert or immediately revert */ + 'revert-dialog.confirm-revert.stage-revert-checkbox-label': + 'Stage revert actions in a new release', + /** Warning card text for when immediately revert a release with history */ + 'revert-dialog.confirm-revert.warning-card': + 'Changes were made to documents in this release after they were published. Reverting will overwrite these changes.', + /** Title of a reverted release */ + 'revert-release.title': 'Reverting "{{title}}"', + /** Description of a reverted release */ + 'revert-release.description': 'Revert changes to document versions in "{{title}}".', + + /** Title o unschedule release dialog */ + 'schedule-button.tooltip': 'Are you sure you want to unschedule the release?', + + /** Schedule release button tooltip when validation is loading */ + 'schedule-button-tooltip.validation.loading': 'Validating documents...', + /** Schedule release button tooltip when there are validation errors */ + 'schedule-button-tooltip.validation.error': 'Some documents have validation errors', + + /** Schedule release button tooltip when the release is already scheduled */ + 'schedule-button-tooltip.already-scheduled': 'This release is already scheduled', + + /** Title for unschedule release dialog */ + 'schedule-dialog.confirm-title': + 'Are you sure you want to schedule the release and all document versions for publishing?', + /** Description shown in unschedule relaease dialog */ + 'schedule-dialog.confirm-description_one': + "The '{{title}}' release and its document will be published on the selected date.", + /** Description for the dialog confirming the publish of a release with multiple documents */ + 'schedule-dialog.confirm-description_other': + 'The {{title}} release and its {{count}} document versions will be scheduled for publishing.', + + /** Description for the confirm button for scheduling a release */ + 'schedule-dialog.confirm-button': 'Yes, schedule for publishing', + + /** Label for date picker when scheduling a release */ + 'schedule-dialog.select-publish-date-label': 'Schedule for publishing on', + + /** Title for unschedule release dialog */ + 'unschedule-dialog.confirm-title': 'Are you sure you want to unschedule the release?', + /** Description shown in unschedule relaease dialog */ + 'unschedule-dialog.confirm-description': + 'The release will no longer be published on the scheduled date', + /** Description for warning that the published schedule time is in the past */ + 'schedule-dialog.publish-date-in-past-warning': + 'Schedule this release for a future time and date.', + + /** Placeholder for search of documents in a release */ + 'search-documents-placeholder': 'Search documents', + /** Text for when the release was created */ + 'summary.created': 'Created ', + /** Text for when the release was published */ + 'summary.published': 'Published ', + /** Text for when the release has not published */ + 'summary.not-published': 'Not published', + /** Text for when the release has no documents */ + 'summary.no-documents': 'No documents', + /** Text for when the release is composed of one document */ + 'summary.document-count_one': '{{count}} document', + /** Text for when the release is composed of multiple documents */ + 'summary.document-count_other': '{{count}} documents', + + /** add action type that will be shown in the table*/ + 'table-body.action.add': 'Add', + /** Change action type that will be shown in the table*/ + 'table-body.action.change': 'Change', + /** Change action type that will be shown in the table*/ + 'table-body.action.unpublish': 'Unpublish', + + /** Header for the document table in the release tool - contributors */ + 'table-header.contributors': 'Contributors', + /** Header for the document table in the release tool - type */ + 'table-header.type': 'Type', + /** Header for the document table in the release tool - release title */ + 'table-header.title': 'Release', + /** Header for the document table in the release tool - action */ + 'table-header.action': 'Action', + /** Header for the document table in the release tool - title */ + 'table-header.documents': 'Documents', + /** Header for the document table in the release tool - edited */ + 'table-header.edited': 'Edited', + /** Header for the document table in the release tool - time */ + 'table-header.time': 'Time', + + /** Text for toast when release has been archived */ + 'toast.archive.success': "The '{{title}}' release was archived.", + /** Text for toast when release failed to archive */ + 'toast.archive.error': "Failed to archive '{{title}}': {{error}}", + /** Description for toast when release deletion failed */ + 'toast.delete.error': "Failed to delete '{{title}}': {{error}}", + /** Description for toast when release is successfully deleted */ + 'toast.delete.success': "The '{{title}}' release was successfully deleted", + /** Text for toast when release failed to publish */ + 'toast.publish.error': "Failed to publish '{{title}}': {{error}}", + /** Text for toast when release has been published */ + 'toast.publish.success': "The '{{title}}' release was published.", + /** Text for toast when release failed to schedule */ + 'toast.schedule.error': "Failed to schedule '{{title}}': {{error}}", + /** Text for toast when release has been scheduled */ + 'toast.schedule.success': "The '{{title}}' release was scheduled.", + /** Text for toast when release failed to unschedule */ + 'toast.unschedule.error': "Failed to unscheduled '{{title}}': {{error}}", + /** Text for toast when release has been unschedule */ + 'toast.unschedule.success': "The '{{title}}' release was unscheduled.", + /** Text for toast when release has been unarchived */ + 'toast.unarchive.success': "The '{{title}}' release was unarchived.", + /** Text for toast when release failed to unarchive */ + 'toast.unarchive.error': "Failed to unarchive '{{title}}': {{error}}", + /** Description for toast when release deletion failed */ + /** Text for tooltip when a release has been scheduled */ + 'type-picker.tooltip.scheduled': 'The release is scheduled, unschedule it to change type', + /** Text for toast when release failed to revert */ + 'toast.revert.error': 'Failed to revert release: {{error}}', + /** Text for toast when release has been reverted immediately */ + 'toast.immediate-revert.success': "The '{{title}}' release was successfully reverted", + /** Text for toast when release has reverted release successfully staged */ + 'toast.revert-stage.success': "Revert release for '{{title}}' was successfully created. ", + /** Link text for toast link to the generated revert release */ + 'toast.revert-stage.success-link': 'View revert release', + + /** Title for the dialog confirming the unpublish of a release */ + 'unpublish-dialog.header': 'Are you sure you want to unpublish this document when releasing?', + /** Text action in unpublish dialog to cancel */ + 'unpublish-dialog.action.cancel': 'Cancel', + /** Text action in unpublish dialog to unpublish */ + 'unpublish-dialog.action.unpublish': 'Yes, unpublish when releasing', + /** Description for the unpublish dialog, explaining that it will create a draft if no draft exists at time of release */ + 'unpublish-dialog.description.to-draft': + 'This will unpublish the document as part of the {{title}} release, and create a draft if no draft exists at the time of release.', + /** Description for unpublish dialog, explaining that all changes made to this document will be lost */ + 'unpublish-dialog.description.lost-changes': + 'Any changes made to this document version will be lost.', +} + +/** + * @alpha + */ +export type ReleasesLocaleResourceKeys = keyof typeof releasesLocaleStrings + +export default releasesLocaleStrings diff --git a/packages/sanity/src/core/releases/index.ts b/packages/sanity/src/core/releases/index.ts new file mode 100644 index 00000000000..d766e5bf45c --- /dev/null +++ b/packages/sanity/src/core/releases/index.ts @@ -0,0 +1,12 @@ +export * from '../store/_legacy' +export * from '../store/user' +export * from './__telemetry__/releases.telemetry' +export * from './components' +export * from './hooks' +export {RELEASES_INTENT} from './plugin' +export * from './store' +export * from './util/const' +export * from './util/createReleaseId' +export * from './util/getReleaseIdFromReleaseDocumentId' +export * from './util/getReleaseTone' +export * from './util/util' diff --git a/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx new file mode 100644 index 00000000000..f8b5ad67fb0 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx @@ -0,0 +1,11 @@ +import {type ComponentType} from 'react' + +import {type LayoutProps} from '../../config' +import {AddonDatasetProvider} from '../../studio' +import {ReleasesMetadataProvider} from '../contexts/ReleasesMetadataProvider' + +export const ReleasesStudioLayout: ComponentType = (props) => ( + + {props.renderDefault(props)} + +) diff --git a/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx new file mode 100644 index 00000000000..de7913ca979 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx @@ -0,0 +1,66 @@ +import {TrashIcon} from '@sanity/icons' +import {useCallback, useState} from 'react' +import {useTranslation} from 'react-i18next' + +import {InsufficientPermissionsMessage} from '../../../components/InsufficientPermissionsMessage' +import { + type DocumentActionDescription, + type DocumentActionProps, +} from '../../../config/document/actions' +import {useDocumentPairPermissions} from '../../../store/_legacy/grants/documentPairPermissions' +import {useCurrentUser} from '../../../store/user/hooks' +import {DiscardVersionDialog} from '../../components/dialog/DiscardVersionDialog' + +/** + * @internal + */ +export const DiscardVersionAction = ( + props: DocumentActionProps, +): DocumentActionDescription | null => { + const {id, type, release, version} = props + const currentUser = useCurrentUser() + const {t} = useTranslation() + + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + version: release, + permission: 'publish', + }) + + const [dialogOpen, setDialogOpen] = useState(false) + + // Callbacks + const handleDialogOpen = useCallback(() => { + setDialogOpen(true) + }, []) + + if (!version) return null + const insufficientPermissions = !isPermissionsLoading && !permissions?.granted + + if (insufficientPermissions) { + return { + disabled: true, + icon: TrashIcon, + label: 'no permissions', + title: , + } + } + + return { + dialog: dialogOpen && { + type: 'custom', + component: ( + setDialogOpen(false)} + /> + ), + }, + label: t('release.action.discard-version'), + icon: TrashIcon, + onHandle: handleDialogOpen, + title: t('release.action.discard-version'), + } +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx b/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx new file mode 100644 index 00000000000..9d880827e66 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/UnpublishVersionAction.tsx @@ -0,0 +1,75 @@ +import {TrashIcon, UnpublishIcon} from '@sanity/icons' +import {useCallback, useState} from 'react' +import {useTranslation} from 'react-i18next' + +import {InsufficientPermissionsMessage} from '../../../components/InsufficientPermissionsMessage' +import { + type DocumentActionDescription, + type DocumentActionProps, +} from '../../../config/document/actions' +import {useDocumentPairPermissions} from '../../../store/_legacy/grants/documentPairPermissions' +import {useCurrentUser} from '../../../store/user/hooks' +import {UnpublishVersionDialog} from '../../components/dialog/UnpublishVersionDialog' +import {releasesLocaleNamespace} from '../../i18n' +import {isGoingToUnpublish} from '../../util/isGoingToUnpublish' + +/** + * @internal + */ +export const UnpublishVersionAction = ( + props: DocumentActionProps, +): DocumentActionDescription | null => { + const {id, type, release, published, version} = props + const currentUser = useCurrentUser() + const isPublished = published !== null + const {t} = useTranslation(releasesLocaleNamespace) + const isAlreadyUnpublished = version ? isGoingToUnpublish(version) : false + + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + version: release, + permission: 'unpublish', + }) + + const [dialogOpen, setDialogOpen] = useState(false) + + const handleDialogOpen = useCallback(() => { + setDialogOpen(true) + }, []) + + if (!version) return null + + const insufficientPermissions = !isPermissionsLoading && !permissions?.granted + + if (insufficientPermissions) { + return { + disabled: true, + icon: TrashIcon, + label: 'no permissions', + title: ( + + ), + } + } + + return { + dialog: dialogOpen && { + type: 'custom', + component: ( + setDialogOpen(false)} + /> + ), + }, + /** @todo should be switched once we have the document actions updated */ + label: t('action.unpublish-doc-actions'), + icon: UnpublishIcon, + onHandle: handleDialogOpen, + disabled: !isPublished || isAlreadyUnpublished, + /** @todo should be switched once we have the document actions updated */ + title: t('action.unpublish-doc-actions'), + } +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/index.ts b/packages/sanity/src/core/releases/plugin/documentActions/index.ts new file mode 100644 index 00000000000..fb981ed5008 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/index.ts @@ -0,0 +1,17 @@ +import {type DocumentActionComponent} from '../../../config/document/actions' +import {type DocumentActionsContext} from '../../../config/types' +import {DiscardVersionAction} from './DiscardVersionAction' +import {UnpublishVersionAction} from './UnpublishVersionAction' + +type Action = DocumentActionComponent + +export default function resolveDocumentActions( + existingActions: Action[], + context: DocumentActionsContext, +): Action[] { + const duplicateAction = existingActions.filter(({name}) => name === 'DuplicateAction') + + return context.versionType === 'version' + ? duplicateAction.concat(DiscardVersionAction).concat(UnpublishVersionAction) + : existingActions +} diff --git a/packages/sanity/src/core/releases/plugin/index.ts b/packages/sanity/src/core/releases/plugin/index.ts new file mode 100644 index 00000000000..c4877d57cb0 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/index.ts @@ -0,0 +1,58 @@ +import {route} from 'sanity/router' + +import {definePlugin} from '../../config/definePlugin' +import {releasesUsEnglishLocaleBundle} from '../i18n' +import {ReleasesTool} from '../tool/ReleasesTool' +import resolveDocumentActions from './documentActions' +import {ReleasesStudioLayout} from './ReleasesStudioLayout' + +/** + * @internal + */ +export const RELEASES_NAME = 'sanity/releases' + +/** + * @internal + */ +export const RELEASES_TOOL_NAME = 'releases' + +/** + * @internal + */ +export const RELEASES_INTENT = 'release' + +/** + * @internal + */ +export const releases = definePlugin({ + name: RELEASES_NAME, + studio: { + components: { + layout: ReleasesStudioLayout, + }, + }, + tools: [ + { + name: RELEASES_TOOL_NAME, + title: 'Releases', + component: ReleasesTool, + router: route.create('/', [route.create('/:releaseId')]), + canHandleIntent: (intent) => { + // If intent is release, open the releases tool. + return Boolean(intent === RELEASES_INTENT) + }, + getIntentState(intent, params) { + if (intent === RELEASES_INTENT) { + return {releaseId: params.id} + } + return null + }, + }, + ], + i18n: { + bundles: [releasesUsEnglishLocaleBundle], + }, + document: { + actions: (actions, context) => resolveDocumentActions(actions, context), + }, +}) diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts new file mode 100644 index 00000000000..dcb169cf213 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts @@ -0,0 +1,25 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import { + createReleaseOperationsStore, + type ReleaseOperationsStore, +} from '../../createReleaseOperationStore' + +export const createReleaseOperationsStoreReturn: Mocked = { + archive: vi.fn(), + unarchive: vi.fn(), + createRelease: vi.fn(), + createVersion: vi.fn(), + discardVersion: vi.fn(), + publishRelease: vi.fn(), + schedule: vi.fn(), + unschedule: vi.fn(), + updateRelease: vi.fn(), + deleteRelease: vi.fn(), + revertRelease: vi.fn(), + unpublishVersion: vi.fn(), +} + +export const mockCreateReleaseOperationsStore = createReleaseOperationsStore as Mock< + typeof createReleaseOperationsStore +> diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useActiveReleases.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useActiveReleases.mock.ts new file mode 100644 index 00000000000..f2e53aa68ec --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useActiveReleases.mock.ts @@ -0,0 +1,12 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {useActiveReleases} from '../../useActiveReleases' + +export const useActiveReleasesMockReturn: Mocked> = { + data: [], + dispatch: vi.fn(), + error: undefined, + loading: false, +} + +export const mockUseActiveReleases = useActiveReleases as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useAllReleases.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useAllReleases.mock.ts new file mode 100644 index 00000000000..cc5a254dd03 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useAllReleases.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked} from 'vitest' + +import {useAllReleases} from '../../useAllReleases' + +export const useAllReleasesMockReturn: Mocked> = { + data: [], + error: undefined, + loading: false, +} + +export const mockUseAllReleases = useAllReleases as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useArchivedReleases.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useArchivedReleases.mock.ts new file mode 100644 index 00000000000..e81ae31add6 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useArchivedReleases.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked} from 'vitest' + +import {useArchivedReleases} from '../../useArchivedReleases' + +export const useArchivedReleasesMockReturn: Mocked> = { + data: [], + error: undefined, + loading: false, +} + +export const mockUseArchivedReleases = useArchivedReleases as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts new file mode 100644 index 00000000000..9b7ce14aeca --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts @@ -0,0 +1,21 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {type ReleaseOperationsStore} from '../../createReleaseOperationStore' +import {useReleaseOperations} from '../../useReleaseOperations' + +export const useReleaseOperationsMockReturn: Mocked = { + archive: vi.fn(), + unarchive: vi.fn(), + createRelease: vi.fn(), + createVersion: vi.fn(), + discardVersion: vi.fn(), + publishRelease: vi.fn(), + schedule: vi.fn(), + unschedule: vi.fn(), + updateRelease: vi.fn(), + deleteRelease: vi.fn(), + revertRelease: vi.fn(), + unpublishVersion: vi.fn(), +} + +export const mockUseReleaseOperations = useReleaseOperations as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts new file mode 100644 index 00000000000..dd384c78de1 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked} from 'vitest' + +import {useReleasesMetadata} from '../../useReleasesMetadata' + +export const useReleasesMetadataMockReturn: Mocked> = { + data: null, + error: null, + loading: false, +} + +export const mockUseReleasesMetadata = useReleasesMetadata as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts new file mode 100644 index 00000000000..68a9145425c --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts @@ -0,0 +1,355 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {type RevertDocument} from '../../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' +import {createReleaseOperationsStore} from '../createReleaseOperationStore' +import {type ReleaseDocument} from '../types' + +describe('createReleaseOperationsStore', () => { + let mockClient: any + + beforeEach(() => { + mockClient = { + config: vi.fn().mockReturnValue({dataset: 'test-dataset'}), + request: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + getDocument: vi.fn(), + } + }) + + const createStore = () => createReleaseOperationsStore({client: mockClient}) + + it('should create a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Test Release'}} + await store.createRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: 'release-id', + metadata: release.metadata, + }, + ], + }, + }) + }) + + it('should update a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Updated Title'}} + await store.updateRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.edit', + releaseId: 'release-id', + patch: { + set: {metadata: release.metadata}, + unset: [], + }, + }, + ], + }, + }) + }) + + it('should publish a release using new publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', true) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish2', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should publish a release using stable publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', false) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should schedule a release', async () => { + const store = createStore() + const date = new Date('2024-01-01T00:00:00Z') + await store.schedule('_.releases.release-id', date) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.schedule', + releaseId: 'release-id', + publishAt: date.toISOString(), + }, + ], + }, + }) + }) + + it('should unschedule a release', async () => { + const store = createStore() + await store.unschedule('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should archive a release', async () => { + const store = createStore() + await store.archive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.archive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should unarchive a release', async () => { + const store = createStore() + await store.unarchive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should delete a release', async () => { + const store = createStore() + await store.deleteRelease('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.delete', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + describe('revertRelease', () => { + let store: ReturnType + const revertReleaseId: string = 'revert-release-id' + const revertReleaseDocumentId: string = '_.releases.revert-release-id' + let releaseDocuments: RevertDocument[] + let releaseMetadata: ReleaseDocument['metadata'] + + beforeEach(() => { + store = createStore() + releaseDocuments = [{_id: 'doc1'}, {_id: 'doc2'}] as RevertDocument[] + releaseMetadata = { + title: 'Revert Release', + description: 'A reverted release', + } as ReleaseDocument['metadata'] + }) + + it('should create a new release and publish immediately when revertType is "immediate"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'immediate', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'revert-release-id', + }, + ], + }, + }) + }) + + it('should create a new release without publishing when revertType is "staged"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.request).toHaveBeenCalledTimes(1) + }) + + it('should fail if a document does not exist and no initial value is provided', async () => { + mockClient.getDocument.mockResolvedValueOnce(null) // Simulate a missing document + + await expect( + store.revertRelease( + revertReleaseDocumentId, + [{_id: 'missing-doc'}] as RevertDocument[], + releaseMetadata, + 'staged', + ), + ).resolves.toBeUndefined() + }) + + it('should handle partial failure gracefully when creating versions', async () => { + mockClient.create.mockRejectedValueOnce(new Error('Failed to create version')) + + const result = await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(result).toBeUndefined() + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + }) + + it('should throw an error if creating the release fails', async () => { + mockClient.request.mockRejectedValueOnce(new Error('Failed to create release')) + + await expect( + store.revertRelease(revertReleaseDocumentId, releaseDocuments, releaseMetadata, 'staged'), + ).rejects.toThrow('Failed to create release') + }) + }) + + it('should create a version of a document', async () => { + const store = createStore() + mockClient.getDocument.mockResolvedValue({_id: 'doc-id', data: 'example'}) + await store.createVersion('release-id', 'doc-id', {newData: 'value'}) + expect(mockClient.create).toHaveBeenCalledWith({ + _id: `versions.release-id.doc-id`, + data: 'example', + newData: 'value', + }) + }) + + it('should discard a version of a document', async () => { + const store = createStore() + await store.discardVersion('release-id', 'doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.discard', + draftId: 'versions.release-id.doc-id', + }, + ], + }, + }) + }) + + it('should unpublish a version of a document', async () => { + const store = createStore() + await store.unpublishVersion('doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.version.unpublish', + draftId: 'doc-id', + publishedId: `doc-id`, + }, + ], + }, + }) + }) +}) diff --git a/packages/sanity/src/core/releases/store/constants.ts b/packages/sanity/src/core/releases/store/constants.ts new file mode 100644 index 00000000000..02d29f8a6ec --- /dev/null +++ b/packages/sanity/src/core/releases/store/constants.ts @@ -0,0 +1,4 @@ +// api extractor take issues with 'as const' for literals +// eslint-disable-next-line @typescript-eslint/prefer-as-const +export const RELEASE_DOCUMENT_TYPE: 'system.release' = 'system.release' +export const RELEASE_DOCUMENTS_PATH = '_.releases' diff --git a/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts b/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts new file mode 100644 index 00000000000..8bc84b1f19d --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts @@ -0,0 +1,152 @@ +import {type SanityClient} from '@sanity/client' +import { + bufferTime, + catchError, + EMPTY, + filter, + iif, + merge, + type Observable, + of, + startWith, + switchMap, +} from 'rxjs' + +import {getReleaseIdFromReleaseDocumentId} from '../util/getReleaseIdFromReleaseDocumentId' +import {type ReleasesMetadata} from './useReleasesMetadata' + +export type ReleasesMetadataMap = Record + +export type MetadataWrapper = {data: ReleasesMetadataMap | null; error: null; loading: boolean} + +const getFetchQuery = (releaseIds: string[]) => { + // projection key must be string - cover the case that a bundle has a number as first char + const getSafeKey = (id: string) => `release_${id.replaceAll('-', '_')}` + + return releaseIds.reduce( + ({subquery: accSubquery, projection: accProjection}, releaseId) => { + const bundleId = getReleaseIdFromReleaseDocumentId(releaseId) + // get a version of the id that is safe to use as key in objects + const safeId = getSafeKey(bundleId) + + const subquery = `${accSubquery}"${safeId}": *[_id in path("versions.${bundleId}.*")]{_updatedAt, "docId": string::split(_id, ".")[2] } | order(_updatedAt desc),` + + const projection = `${accProjection}"${releaseId}": { + "updatedAt": ${safeId}[0]._updatedAt, + "documentIds": ${safeId}[].docId, + },` + + return {subquery, projection} + }, + {subquery: '', projection: ''}, + ) +} + +/** + * @internal + * + * An initial fetch is made. This fetch is polled whenever a listener even is emitted + * Only releases that have been mutated are re-fetched + * + * @returns an Observable that accepts a list of release slugs and returns a stream of metadata + */ +export const createReleaseMetadataAggregator = (client: SanityClient | null) => { + const aggregatorFetch$ = ( + releaseIds: string[], + isInitialLoad: boolean = false, + ): Observable => { + if (!releaseIds?.length || !client) return of({data: null, error: null, loading: false}) + + const {subquery: queryAllDocumentsInReleases, projection: projectionToBundleMetadata} = + getFetchQuery(releaseIds) + + const fetchData$ = client.observable + .fetch< + Record< + string, + Omit & { + documentIds: string[] + } + > + >( + `{${queryAllDocumentsInReleases}}{${projectionToBundleMetadata}}`, + {}, + {tag: 'release-docs.fetch'}, + ) + .pipe( + switchMap((releaseDocumentIdResponse) => + of({ + data: Object.entries(releaseDocumentIdResponse).reduce((existing, el) => { + const [releaseId, metadata] = el + return { + ...existing, + [releaseId]: {...metadata, documentCount: metadata.documentIds?.length || 0}, + } + }, {}), + error: null, + loading: false, + }), + ), + catchError((error) => { + console.error('Failed to fetch release metadata', error) + return of({data: null, error, loading: false}) + }), + ) + + // initially emit loading empty state if first fetch + return iif( + () => isInitialLoad, + fetchData$.pipe(startWith({loading: true, data: null, error: null})), + fetchData$, + ) + } + + const aggregatorListener$ = (releaseIds: string[]) => { + if (!releaseIds?.length || !client) return EMPTY + + return client.observable + .listen( + `*[(${releaseIds.reduce( + (accQuery, releaseId, index) => + `${accQuery}${index === 0 ? '' : ' ||'} _id in path("versions.${releaseId}.*")`, + '', + )})]`, + {}, + { + includeResult: true, + visibility: 'query', + events: ['mutation'], + tag: 'release-docs.listen', + }, + ) + .pipe( + catchError((error) => { + console.error('Failed to listen for release metadata', error) + return EMPTY + }), + bufferTime(1_000), + filter((entriesArray) => entriesArray.length > 0), + switchMap((entriesArray) => { + const mutatedReleaseIds = entriesArray.reduce((accReleaseIds, event) => { + if ('type' in event && event.type === 'mutation') { + const releaseId = event.documentId.split('.')[1] + // de-dup mutated bundle slugs + if (accReleaseIds.includes(releaseId)) return accReleaseIds + + return [...accReleaseIds, releaseId] + } + return accReleaseIds + }, []) + + if (mutatedReleaseIds.length) { + return aggregatorFetch$(mutatedReleaseIds) + } + + return EMPTY + }), + ) + } + + return (releaseIds: string[]) => + merge(aggregatorFetch$(releaseIds, true), aggregatorListener$(releaseIds)) +} diff --git a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts new file mode 100644 index 00000000000..cec8509b5a5 --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts @@ -0,0 +1,292 @@ +import { + type Action, + type EditAction, + type IdentifiedSanityDocumentStub, + type SanityClient, +} from '@sanity/client' + +import {getPublishedId, getVersionId} from '../../util' +import {getReleaseIdFromReleaseDocumentId, type ReleaseDocument} from '../index' +import {type RevertDocument} from '../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' +import {type EditableReleaseDocument} from './types' + +export interface ReleaseOperationsStore { + publishRelease: (releaseId: string, useUnstableAction?: boolean) => Promise + schedule: (releaseId: string, date: Date) => Promise + //todo: reschedule: (releaseId: string, newDate: Date) => Promise + unschedule: (releaseId: string) => Promise + archive: (releaseId: string) => Promise + unarchive: (releaseId: string) => Promise + updateRelease: (release: EditableReleaseDocument) => Promise + createRelease: (release: EditableReleaseDocument) => Promise + deleteRelease: (releaseId: string) => Promise + revertRelease: ( + revertReleaseId: string, + documents: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => Promise + createVersion: ( + releaseId: string, + documentId: string, + initialvalue?: Record, + ) => Promise + discardVersion: (releaseId: string, documentId: string) => Promise + unpublishVersion: (documentId: string) => Promise +} + +const IS_CREATE_VERSION_ACTION_SUPPORTED = false +const METADATA_PROPERTY_NAME = 'metadata' + +export function createReleaseOperationsStore(options: { + client: SanityClient +}): ReleaseOperationsStore { + const {client} = options + const handleCreateRelease = (release: EditableReleaseDocument) => + requestAction(client, { + actionType: 'sanity.action.release.create', + releaseId: getReleaseIdFromReleaseDocumentId(release._id), + [METADATA_PROPERTY_NAME]: release.metadata, + }) + + const handleUpdateRelease = async (release: EditableReleaseDocument) => { + const bundleId = getReleaseIdFromReleaseDocumentId(release._id) + + const unsetKeys = Object.entries(release) + .filter(([_, value]) => value === undefined) + .map(([key]) => `${METADATA_PROPERTY_NAME}.${key}`) + + await requestAction(client, { + actionType: 'sanity.action.release.edit', + releaseId: bundleId, + patch: { + // todo: consider more granular updates here + set: {[METADATA_PROPERTY_NAME]: release.metadata}, + unset: unsetKeys, + }, + }) + } + + const handlePublishRelease = (releaseId: string, useUnstableAction?: boolean) => + requestAction(client, [ + { + actionType: useUnstableAction + ? 'sanity.action.release.publish2' + : 'sanity.action.release.publish', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleScheduleRelease = (releaseId: string, publishAt: Date) => + requestAction(client, [ + { + actionType: 'sanity.action.release.schedule', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + publishAt: publishAt.toISOString(), + }, + ]) + + const handleUnscheduleRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleArchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.archive', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleUnarchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleDeleteRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.delete', + releaseId: getReleaseIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleCreateVersion = async ( + releaseId: string, + documentId: string, + initialValue?: Record, + ) => { + // the documentId will show you where the document is coming from and which + // document should it copy from + + // fetch original document + const document = await client.getDocument(documentId) + + if (!document && !initialValue) { + throw new Error(`Document with id ${documentId} not found and no initial value provided`) + } + + const versionDocument = { + ...(document || {}), + ...(initialValue || {}), + _id: getVersionId(documentId, releaseId), + } as IdentifiedSanityDocumentStub + + await (IS_CREATE_VERSION_ACTION_SUPPORTED + ? requestAction(client, [ + { + actionType: 'sanity.action.document.createVersion', + releaseId, + attributes: versionDocument, + }, + ]) + : client.create(versionDocument)) + } + + const handleDiscardVersion = (releaseId: string, documentId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.document.discard', + draftId: getVersionId(documentId, releaseId), + }, + ]) + + const handleUnpublishVersion = (documentId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.document.version.unpublish', + draftId: documentId, + publishedId: getPublishedId(documentId), + }, + ]) + + const handleRevertRelease = async ( + revertReleaseId: string, + releaseDocuments: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => { + await handleCreateRelease({ + _id: revertReleaseId, + metadata: { + title: releaseMetadata.title, + description: releaseMetadata.description, + releaseType: 'asap', + }, + }) + await Promise.allSettled( + releaseDocuments.map((document) => + handleCreateVersion( + getReleaseIdFromReleaseDocumentId(revertReleaseId), + document._id, + document, + ), + ), + ) + + if (revertType === 'immediate') { + await handlePublishRelease(revertReleaseId) + } + } + + return { + archive: handleArchiveRelease, + unarchive: handleUnarchiveRelease, + schedule: handleScheduleRelease, + unschedule: handleUnscheduleRelease, + createRelease: handleCreateRelease, + updateRelease: handleUpdateRelease, + publishRelease: handlePublishRelease, + deleteRelease: handleDeleteRelease, + revertRelease: handleRevertRelease, + createVersion: handleCreateVersion, + discardVersion: handleDiscardVersion, + unpublishVersion: handleUnpublishVersion, + } +} + +interface ScheduleApiAction { + actionType: 'sanity.action.release.schedule' + releaseId: string + publishAt: string +} + +interface PublishApiAction { + actionType: 'sanity.action.release.publish' | 'sanity.action.release.publish2' + releaseId: string +} + +interface ArchiveApiAction { + actionType: 'sanity.action.release.archive' + releaseId: string +} + +interface UnarchiveApiAction { + actionType: 'sanity.action.release.unarchive' + releaseId: string +} + +interface UnscheduleApiAction { + actionType: 'sanity.action.release.unschedule' + releaseId: string +} + +interface CreateReleaseApiAction { + actionType: 'sanity.action.release.create' + releaseId: string + [METADATA_PROPERTY_NAME]?: Partial +} + +interface CreateVersionReleaseApiAction { + actionType: 'sanity.action.document.createVersion' + releaseId: string + attributes: IdentifiedSanityDocumentStub +} + +interface UnpublishVersionReleaseApiAction { + actionType: 'sanity.action.document.version.unpublish' + draftId: string + publishedId: string +} + +interface EditReleaseApiAction { + actionType: 'sanity.action.release.edit' + releaseId: string + patch: EditAction['patch'] +} + +interface DeleteApiAction { + actionType: 'sanity.action.release.delete' + releaseId: string +} + +type ReleaseAction = + | Action + | ScheduleApiAction + | PublishApiAction + | CreateReleaseApiAction + | EditReleaseApiAction + | UnscheduleApiAction + | ArchiveApiAction + | UnarchiveApiAction + | DeleteApiAction + | CreateVersionReleaseApiAction + | UnpublishVersionReleaseApiAction + +export function requestAction(client: SanityClient, actions: ReleaseAction | ReleaseAction[]) { + const {dataset} = client.config() + return client.request({ + uri: `/data/actions/${dataset}`, + method: 'POST', + body: { + actions: Array.isArray(actions) ? actions : [actions], + }, + }) +} diff --git a/packages/sanity/src/core/releases/store/createReleaseStore.ts b/packages/sanity/src/core/releases/store/createReleaseStore.ts new file mode 100644 index 00000000000..51938bfcedb --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseStore.ts @@ -0,0 +1,142 @@ +import {type SanityClient} from '@sanity/client' +import { + BehaviorSubject, + catchError, + concat, + concatWith, + filter, + merge, + type Observable, + of, + scan, + shareReplay, + Subject, + switchMap, + tap, +} from 'rxjs' +import {map, startWith} from 'rxjs/operators' + +import {type DocumentPreviewStore} from '../../preview' +import {listenQuery} from '../../store/_legacy' +import {RELEASE_DOCUMENT_TYPE, RELEASE_DOCUMENTS_PATH} from './constants' +import {createReleaseMetadataAggregator} from './createReleaseMetadataAggregator' +import {releasesReducer, type ReleasesReducerAction, type ReleasesReducerState} from './reducer' +import {type ReleaseDocument, type ReleaseStore} from './types' + +type ActionWrapper = {action: ReleasesReducerAction} +type ResponseWrapper = {response: ReleaseDocument[]} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTER = `_type=="${RELEASE_DOCUMENT_TYPE}" && _id in path("${RELEASE_DOCUMENTS_PATH}.*")` + +// TODO: Extend the projection with the fields needed +const QUERY_PROJECTION = `{ + ..., +}` + +// Newest releases first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTER}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +const INITIAL_STATE: ReleasesReducerState = { + releases: new Map(), + state: 'initialising' as const, +} + +/** + * The releases store is initialised lazily when first subscribed to. Upon subscription, it will + * fetch a list of releases and create a listener to keep the locally held state fresh. + * + * The store is not disposed of when all subscriptions are closed. After it has been initialised, + * it will keep listening for the duration of the app's lifecycle. Subsequent subscriptions will be + * given the latest state upon subscription. + */ +export function createReleaseStore(context: { + previewStore: DocumentPreviewStore + client: SanityClient +}): ReleaseStore { + const {client} = context + + const dispatch$ = new Subject() + const fetchPending$ = new BehaviorSubject(false) + + function dispatch(action: ReleasesReducerAction): void { + dispatch$.next(action) + } + + const listFetch$ = of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: true, + error: undefined, + }, + }, + }).pipe( + // Ignore invocations while the list fetch is pending. + filter(() => !fetchPending$.value), + tap(() => fetchPending$.next(true)), + concatWith( + listenQuery(client, QUERY, {}, {tag: 'releases.listen'}).pipe( + tap(() => fetchPending$.next(false)), + map((releases) => + releases.map( + (releaseDoc: ReleaseDocument): ReleaseDocument => ({ + ...releaseDoc, + metadata: {...(releaseDoc as any).userMetadata, ...releaseDoc.metadata}, + }), + ), + ), + map((releases) => ({response: releases})), + ), + ), + + catchError((error) => + of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error, + }, + }, + }), + ), + switchMap>( + (entry) => { + if ('action' in entry) { + return of(entry.action) + } + + return of( + {type: 'RELEASES_SET', payload: entry.response}, + { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error: undefined, + }, + }, + ) + }, + ), + ) + + const state$ = concat(merge(listFetch$, dispatch$)).pipe( + filter((action): action is ReleasesReducerAction => typeof action !== 'undefined'), + scan((state, action) => releasesReducer(state, action), INITIAL_STATE), + startWith(INITIAL_STATE), + shareReplay(1), + ) + + const getMetadataStateForSlugs$ = createReleaseMetadataAggregator(client) + + return { + state$, + getMetadataStateForSlugs$, + dispatch, + } +} diff --git a/packages/sanity/src/core/releases/store/index.ts b/packages/sanity/src/core/releases/store/index.ts new file mode 100644 index 00000000000..9f17c8a5b01 --- /dev/null +++ b/packages/sanity/src/core/releases/store/index.ts @@ -0,0 +1,6 @@ +export * from './types' +export * from './useActiveReleases' +export * from './useAllReleases' +export * from './useArchivedReleases' +export * from './useReleaseOperations' +export * from './useReleasesIds' diff --git a/packages/sanity/src/core/releases/store/reducer.ts b/packages/sanity/src/core/releases/store/reducer.ts new file mode 100644 index 00000000000..2590dc7bf39 --- /dev/null +++ b/packages/sanity/src/core/releases/store/reducer.ts @@ -0,0 +1,102 @@ +import {type ReleaseDocument} from './types' + +interface BundleDeletedAction { + id: string + currentUserId?: string + deletedByUserId: string + type: 'BUNDLE_DELETED' +} + +interface BundleUpdatedAction { + payload: ReleaseDocument + type: 'BUNDLE_UPDATED' +} + +interface ReleasesSetAction { + payload: ReleaseDocument[] | null + type: 'RELEASES_SET' +} + +interface BundleReceivedAction { + payload: ReleaseDocument + type: 'BUNDLE_RECEIVED' +} + +interface LoadingStateChangedAction { + payload: { + loading: boolean + error: Error | undefined + } + type: 'LOADING_STATE_CHANGED' +} + +export type ReleasesReducerAction = + | BundleDeletedAction + | BundleUpdatedAction + | ReleasesSetAction + | BundleReceivedAction + | LoadingStateChangedAction + +export interface ReleasesReducerState { + releases: Map + state: 'initialising' | 'loading' | 'loaded' | 'error' + error?: Error +} + +function createReleasesSet(releases: ReleaseDocument[] | null) { + return (releases ?? []).reduce((acc, bundle) => { + acc.set(bundle._id, bundle) + return acc + }, new Map()) +} + +export function releasesReducer( + state: ReleasesReducerState, + action: ReleasesReducerAction, +): ReleasesReducerState { + switch (action.type) { + case 'LOADING_STATE_CHANGED': { + return { + ...state, + state: action.payload.loading ? 'loading' : 'loaded', + error: action.payload.error, + } + } + + case 'RELEASES_SET': { + // Create an object with the BUNDLE id as key + const releasesById = createReleasesSet(action.payload) + + return { + ...state, + releases: releasesById, + } + } + + case 'BUNDLE_RECEIVED': { + const receivedBundle = action.payload as ReleaseDocument + const currentReleases = new Map(state.releases) + currentReleases.set(receivedBundle._id, receivedBundle) + + return { + ...state, + releases: currentReleases, + } + } + + case 'BUNDLE_UPDATED': { + const updatedBundle = action.payload + const id = updatedBundle._id as string + const currentReleases = new Map(state.releases) + currentReleases.set(id, updatedBundle) + + return { + ...state, + releases: currentReleases, + } + } + + default: + return state + } +} diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts new file mode 100644 index 00000000000..36dcdc21e1c --- /dev/null +++ b/packages/sanity/src/core/releases/store/types.ts @@ -0,0 +1,83 @@ +import {type SanityDocument} from '@sanity/types' +import {type Dispatch} from 'react' +import {type Observable} from 'rxjs' + +import {type PartialExcept} from '../../util' +import {RELEASE_DOCUMENT_TYPE} from './constants' +import {type MetadataWrapper} from './createReleaseMetadataAggregator' +import {type ReleasesReducerAction, type ReleasesReducerState} from './reducer' + +/** @internal */ +export type ReleaseType = 'asap' | 'scheduled' | 'undecided' + +/** + *@internal + */ +export type ReleaseState = 'active' | 'archived' | 'published' | 'scheduled' | 'scheduling' +/** + *@internal + */ +export type ReleaseFinalDocumentState = { + /** Document ID */ + id: string + revisionId: string +} + +/** + * TODO: When made `beta`, update the PublishDocumentVersionEvent to use this type + * @internal + */ +export interface ReleaseDocument extends SanityDocument { + /** + * typically + * _.releases. + */ + _id: string + _type: typeof RELEASE_DOCUMENT_TYPE + _createdAt: string + _updatedAt: string + _rev: string + state: ReleaseState + finalDocumentStates?: ReleaseFinalDocumentState[] + /** + * If defined, it takes precedence over the intendedPublishAt, the state should be 'scheduled' + */ + publishAt?: string + metadata: { + title: string + description?: string + + intendedPublishAt?: string + // todo: the below properties should probably live at the system document + releaseType: ReleaseType + } +} + +/** + * @internal + */ +export type EditableReleaseDocument = Omit< + PartialExcept, + 'metadata' | '_type' +> & { + _id: string + metadata: Partial +} + +/** + * @internal + */ +export function isReleaseDocument(doc: unknown): doc is ReleaseDocument { + return ( + typeof doc === 'object' && doc !== null && '_type' in doc && doc._type === RELEASE_DOCUMENT_TYPE + ) +} + +/** + * @internal + */ +export interface ReleaseStore { + state$: Observable + getMetadataStateForSlugs$: (slugs: string[]) => Observable + dispatch: Dispatch +} diff --git a/packages/sanity/src/core/releases/store/useActiveReleases.ts b/packages/sanity/src/core/releases/store/useActiveReleases.ts new file mode 100644 index 00000000000..0ac93ba2788 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useActiveReleases.ts @@ -0,0 +1,46 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {sortReleases} from '../hooks/utils' +import {ARCHIVED_RELEASE_STATES} from '../util/const' +import {type ReleasesReducerAction} from './reducer' +import {type ReleaseDocument} from './types' +import {useReleasesStore} from './useReleasesStore' + +interface ReleasesState { + /** + * Sorted array of releases, excluding archived releases + */ + data: ReleaseDocument[] + error?: Error + loading: boolean + dispatch: (event: ReleasesReducerAction) => void +} + +/** + * Hook to get the (non archived, non published) active releases + * @internal + */ +export function useActiveReleases(): ReleasesState { + const {state$, dispatch} = useReleasesStore() + const state = useObservable(state$)! + const releasesAsArray = useMemo( + () => + sortReleases( + Array.from(state.releases.values()).filter( + (release) => !ARCHIVED_RELEASE_STATES.includes(release.state), + ), + ).reverse(), + [state.releases], + ) + + return useMemo( + () => ({ + data: releasesAsArray, + dispatch, + error: state.error, + loading: ['loading', 'initialising'].includes(state.state), + }), + [releasesAsArray, state.error, state.state, dispatch], + ) +} diff --git a/packages/sanity/src/core/releases/store/useAllReleases.ts b/packages/sanity/src/core/releases/store/useAllReleases.ts new file mode 100644 index 00000000000..fe5c3853099 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useAllReleases.ts @@ -0,0 +1,28 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {sortReleases} from '../hooks/utils' +import {type ReleaseDocument} from './types' +import {useReleasesStore} from './useReleasesStore' + +/** + * Gets all releases including archived and published releases + * @internal + */ +export function useAllReleases(): { + data: ReleaseDocument[] + error?: Error + loading: boolean +} { + const {state$} = useReleasesStore() + const {releases, error, state} = useObservable(state$)! + + return useMemo( + () => ({ + data: sortReleases(Array.from(releases.values())), + error: error, + loading: ['loading', 'initialising'].includes(state), + }), + [error, releases, state], + ) +} diff --git a/packages/sanity/src/core/releases/store/useArchivedReleases.ts b/packages/sanity/src/core/releases/store/useArchivedReleases.ts new file mode 100644 index 00000000000..970ce015151 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useArchivedReleases.ts @@ -0,0 +1,29 @@ +import {useMemo} from 'react' + +import {ARCHIVED_RELEASE_STATES} from '../util/const' +import {type ReleaseDocument} from './types' +import {useAllReleases} from './useAllReleases' + +/** + * @internal + */ +export function useArchivedReleases(): { + data: ReleaseDocument[] + error?: Error + loading: boolean +} { + const {data: releases, error, loading} = useAllReleases() + + const archivedReleases = useMemo( + () => + Array.from(releases.values()).filter((release) => { + return ARCHIVED_RELEASE_STATES.includes(release.state) + }), + [releases], + ) + + return useMemo( + () => ({data: archivedReleases, error, loading}), + [archivedReleases, error, loading], + ) +} diff --git a/packages/sanity/src/core/releases/store/useReleaseOperations.ts b/packages/sanity/src/core/releases/store/useReleaseOperations.ts new file mode 100644 index 00000000000..07e92ac66aa --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleaseOperations.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react' + +import {useClient} from '../../hooks' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createReleaseOperationsStore} from './createReleaseOperationStore' + +/** + * @internal + */ +export function useReleaseOperations() { + const studioClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + return useMemo( + () => + createReleaseOperationsStore({ + client: studioClient, + }), + [studioClient], + ) +} diff --git a/packages/sanity/src/core/releases/store/useReleasesIds.ts b/packages/sanity/src/core/releases/store/useReleasesIds.ts new file mode 100644 index 00000000000..cb9cfd0d32f --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesIds.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react' + +import {getReleaseIdFromReleaseDocumentId} from '../util/getReleaseIdFromReleaseDocumentId' +import {type ReleaseDocument} from './types' + +/** + * Gets all the releases ids + * @internal + */ +export function useReleasesIds(releases: ReleaseDocument[]): { + releasesIds: string[] +} { + const releasesIds = useMemo( + () => releases.map((release) => getReleaseIdFromReleaseDocumentId(release._id)), + [releases], + ) + + return useMemo(() => ({releasesIds}), [releasesIds]) +} diff --git a/packages/sanity/src/core/releases/store/useReleasesMetadata.ts b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts new file mode 100644 index 00000000000..10b08eb86f9 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts @@ -0,0 +1,54 @@ +import {useEffect, useState} from 'react' + +import {useReleasesMetadataProvider} from '../contexts/ReleasesMetadataProvider' + +export interface ReleasesMetadata { + /** + * The number of documents with the release version as a prefix + */ + documentCount: number + /** + * The last time a document in the release was edited + */ + updatedAt: string | null +} + +export const useReleasesMetadata = (releaseIds: string[]) => { + const { + addReleaseIdsToListener: addBundleIdsToListener, + removeReleaseIdsFromListener: removeBundleIdsFromListener, + state, + } = useReleasesMetadataProvider() + const [responseData, setResponseData] = useState | null>(null) + + useEffect(() => { + if (releaseIds.length) addBundleIdsToListener([...new Set(releaseIds)]) + + return () => removeBundleIdsFromListener([...new Set(releaseIds)]) + }, [addBundleIdsToListener, releaseIds, removeBundleIdsFromListener]) + + const {data, loading} = state + + useEffect(() => { + if (!data) return + + const hasUpdatedMetadata = + !responseData || Object.entries(responseData).some(([key, value]) => value !== data[key]) + + if (hasUpdatedMetadata) { + const nextResponseData = Object.fromEntries( + releaseIds.map((releaseId) => [releaseId, data[releaseId]]), + ) + + setResponseData(nextResponseData) + } + }, [releaseIds, data, responseData]) + + return { + error: state.error, + // loading is only for initial load + // changing listened to release IDs will not cause a re-load + loading, + data: responseData, + } +} diff --git a/packages/sanity/src/core/releases/store/useReleasesStore.ts b/packages/sanity/src/core/releases/store/useReleasesStore.ts new file mode 100644 index 00000000000..04a37cedcb0 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesStore.ts @@ -0,0 +1,36 @@ +import {useMemo} from 'react' + +import {useClient} from '../../hooks' +import {useDocumentPreviewStore, useResourceCache} from '../../store' +import {useWorkspace} from '../../studio' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createReleaseStore} from './createReleaseStore' +import {type ReleaseStore} from './types' + +/** @internal */ +export function useReleasesStore(): ReleaseStore { + const resourceCache = useResourceCache() + const workspace = useWorkspace() + const previewStore = useDocumentPreviewStore() + const studioClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + + return useMemo(() => { + const releaseStore = + resourceCache.get({ + dependencies: [workspace, previewStore], + namespace: 'ReleasesStore', + }) || + createReleaseStore({ + client: studioClient, + previewStore, + }) + + resourceCache.set({ + dependencies: [workspace, previewStore], + namespace: 'ReleasesStore', + value: releaseStore, + }) + + return releaseStore + }, [resourceCache, workspace, studioClient, previewStore]) +} diff --git a/packages/sanity/src/core/releases/tool/ReleasesTool.tsx b/packages/sanity/src/core/releases/tool/ReleasesTool.tsx new file mode 100644 index 00000000000..0017e91bf5c --- /dev/null +++ b/packages/sanity/src/core/releases/tool/ReleasesTool.tsx @@ -0,0 +1,13 @@ +import {useRouter} from 'sanity/router' + +import {ReleaseDetail} from './detail/ReleaseDetail' +import {ReleasesOverview} from './overview/ReleasesOverview' + +export function ReleasesTool() { + const router = useRouter() + + const {releaseId} = router.state + if (releaseId) return + + return +} diff --git a/packages/sanity/src/core/releases/tool/components/Chip.tsx b/packages/sanity/src/core/releases/tool/components/Chip.tsx new file mode 100644 index 00000000000..a8f718e5aae --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Chip.tsx @@ -0,0 +1,29 @@ +import {Box, Card, Flex, Text} from '@sanity/ui' +import {type ReactNode} from 'react' + +export function Chip(props: {avatar?: ReactNode; text: ReactNode; icon?: ReactNode}) { + const {avatar, text, icon} = props + + return ( + + + {icon && ( + + {icon} + + )} + {avatar && ( + + {avatar} + + )} + + + + {text} + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx new file mode 100644 index 00000000000..f87a4157deb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx @@ -0,0 +1,94 @@ +import {type PreviewValue} from '@sanity/types' +import {Card} from '@sanity/ui' +import {type ForwardedRef, forwardRef, useMemo} from 'react' +import {IntentLink} from 'sanity/router' + +import {type PreviewLayoutKey} from '../../../components/previews/types' +import {DocumentPreviewPresence} from '../../../presence' +import {SanityDefaultPreview} from '../../../preview/components/SanityDefaultPreview' +import {getPublishedId} from '../../../util/draftUtils' +import {type ReleaseState, useDocumentPresence} from '../../index' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' + +interface ReleaseDocumentPreviewProps { + documentId: string + documentTypeName: string + releaseId: string + previewValues: PreviewValue + isLoading: boolean + releaseState?: ReleaseState + documentRevision?: string + hasValidationError?: boolean + layout?: PreviewLayoutKey +} + +export function ReleaseDocumentPreview({ + documentId, + documentTypeName, + releaseId, + previewValues, + isLoading, + releaseState, + documentRevision, + layout, + hasValidationError, +}: ReleaseDocumentPreviewProps) { + const documentPresence = useDocumentPresence(documentId) + + const intentParams = useMemo(() => { + if (releaseState !== 'published' && releaseState !== 'archived') return {} + + const rev = releaseState === 'archived' ? '@lastEdited' : '@lastPublished' + + return { + rev, + inspect: 'sanity/structure/history', + historyEvent: documentRevision, + historyVersion: getReleaseIdFromReleaseDocumentId(releaseId), + } + }, [documentRevision, releaseId, releaseState]) + + const LinkComponent = useMemo( + () => + // eslint-disable-next-line @typescript-eslint/no-shadow + forwardRef(function LinkComponent(linkProps, ref: ForwardedRef) { + return ( + + ) + }), + [documentId, documentTypeName, intentParams, releaseId, releaseState], + ) + + const previewPresence = useMemo( + () => documentPresence?.length > 0 && , + [documentPresence], + ) + + return ( + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx new file mode 100644 index 00000000000..dcd6e35b6b3 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenu.tsx @@ -0,0 +1,106 @@ +import {ArchiveIcon, CloseCircleIcon, TrashIcon, UnarchiveIcon} from '@sanity/icons' +import { + type Dispatch, + type MouseEventHandler, + type SetStateAction, + useCallback, + useMemo, +} from 'react' + +import {MenuItem} from '../../../../../ui-components' +import {useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseAction} from './releaseActions' +import {type ReleaseMenuButtonProps} from './ReleaseMenuButton' + +export type ReleaseMenuProps = Omit & { + disabled: boolean + setSelectedAction: Dispatch> +} + +export const ReleaseMenu = ({ + ignoreCTA, + disabled, + release, + setSelectedAction, +}: ReleaseMenuProps) => { + const releaseMenuDisabled = !release || disabled + const {t} = useTranslation(releasesLocaleNamespace) + + const handleOnInitiateAction = useCallback>( + (event) => { + const action = event.currentTarget.getAttribute('data-value') as ReleaseAction + + setSelectedAction(action) + }, + [setSelectedAction], + ) + + const archiveUnarchiveMenuItem = useMemo(() => { + if (release.state === 'published') return null + + if (release.state === 'archived') + return ( + + ) + + return ( + + ) + }, [handleOnInitiateAction, disabled, release.state, t]) + + const deleteMenuItem = useMemo(() => { + if (release.state !== 'archived' && release.state !== 'published') return null + + return ( + + ) + }, [handleOnInitiateAction, release.state, releaseMenuDisabled, t]) + + const unscheduleMenuItem = useMemo(() => { + if (ignoreCTA || (release.state !== 'scheduled' && release.state !== 'scheduling')) return null + + return ( + + ) + }, [handleOnInitiateAction, ignoreCTA, release.state, releaseMenuDisabled, t]) + + return ( + <> + {unscheduleMenuItem} + {archiveUnarchiveMenuItem} + {deleteMenuItem} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx new file mode 100644 index 00000000000..cb56c1602c8 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx @@ -0,0 +1,188 @@ +import {EllipsisHorizontalIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Menu, Spinner, Text, useToast} from '@sanity/ui' +import {useCallback, useEffect, useMemo, useState} from 'react' +import {useRouter} from 'sanity/router' + +import {Button, Dialog, MenuButton} from '../../../../../ui-components' +import {Translate, useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseDocument} from '../../../store/types' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {RELEASE_ACTION_MAP, type ReleaseAction} from './releaseActions' +import {ReleaseMenu} from './ReleaseMenu' + +export type ReleaseMenuButtonProps = { + /** defaults to false + * set true if release primary CTA options should not + * be shown in the menu eg. unschedule, publish + */ + ignoreCTA?: boolean + release: ReleaseDocument + documentsCount: number +} + +export const ReleaseMenuButton = ({ignoreCTA, release, documentsCount}: ReleaseMenuButtonProps) => { + const toast = useToast() + const router = useRouter() + const {archive, unarchive, deleteRelease, unschedule} = useReleaseOperations() + + const [isPerformingOperation, setIsPerformingOperation] = useState(false) + const [selectedAction, setSelectedAction] = useState() + + const releaseMenuDisabled = !release + const {t} = useTranslation(releasesLocaleNamespace) + const {t: tCore} = useTranslation() + const telemetry = useTelemetry() + const releaseTitle = release.metadata.title || tCore('release.placeholder-untitled-release') + + const handleDelete = useCallback(async () => { + await deleteRelease(release._id) + + // return to release overview list now that release is deleted + router.navigate({}) + }, [deleteRelease, release._id, router]) + + const handleAction = useCallback( + async (action: ReleaseAction) => { + if (releaseMenuDisabled) return + + const actionLookup = { + delete: handleDelete, + archive, + unarchive, + unschedule, + } + const actionValues = RELEASE_ACTION_MAP[action] + + try { + setIsPerformingOperation(true) + await actionLookup[action](release._id) + telemetry.log(actionValues.telemetry) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (actionError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(actionError) + } finally { + setIsPerformingOperation(false) + setSelectedAction(undefined) + } + }, + [ + releaseMenuDisabled, + handleDelete, + archive, + unarchive, + unschedule, + release._id, + telemetry, + toast, + t, + releaseTitle, + ], + ) + + /** in some instanced, immediately execute the action without requiring confirmation */ + useEffect(() => { + if (!selectedAction) return + + if (!RELEASE_ACTION_MAP[selectedAction].confirmDialog) handleAction(selectedAction) + }, [documentsCount, handleAction, selectedAction]) + + const confirmActionDialog = useMemo(() => { + if (!selectedAction) return null + + const {confirmDialog} = RELEASE_ACTION_MAP[selectedAction] + + if (!confirmDialog) return null + + return ( + setSelectedAction(undefined)} + footer={{ + confirmButton: { + text: t(confirmDialog.dialogConfirmButtonI18nKey), + tone: 'positive', + onClick: () => handleAction(selectedAction), + loading: isPerformingOperation, + disabled: isPerformingOperation, + }, + }} + > + + { + + } + + + ) + }, [selectedAction, documentsCount, t, releaseTitle, isPerformingOperation, handleAction]) + + return ( + <> + + } + id="release-menu" + menu={ + + + + } + popover={{ + constrainSize: false, + fallbackPlacements: ['top-end'], + placement: 'bottom', + portal: true, + tone: 'default', + }} + /> + {confirmActionDialog} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx new file mode 100644 index 00000000000..058deb10564 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx @@ -0,0 +1,339 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {act} from 'react' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import { + activeScheduledRelease, + archivedScheduledRelease, + publishedASAPRelease, + scheduledRelease, +} from '../../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {type ReleaseDocument, type ReleaseState} from '../../../../index' +import { + mockUseReleaseOperations, + useReleaseOperationsMockReturn, +} from '../../../../store/__tests__/__mocks/useReleaseOperations.mock' +import {useReleaseOperations} from '../../../../store/useReleaseOperations' +import { + mockUseBundleDocuments, + useBundleDocumentsMockReturn, + useBundleDocumentsMockReturnWithResults, +} from '../../../detail/__tests__/__mocks__/useBundleDocuments.mock' +import {ReleaseMenuButton, type ReleaseMenuButtonProps} from '../ReleaseMenuButton' + +vi.mock('../../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../../../detail/useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturnWithResults), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: vi.fn().mockReturnValue({state: {}, navigate: vi.fn()}), +})) + +const renderTest = async ({release, documentsCount, ignoreCTA = false}: ReleaseMenuButtonProps) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return render( + , + {wrapper}, + ) +} + +describe('ReleaseMenuButton', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseBundleDocuments.mockRestore() + }) + + describe('archive release', () => { + const openConfirmArchiveDialog = async () => { + await renderTest({release: activeScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + screen.getByTestId('confirm-archive-dialog') + } + + test('does not allow for archiving of archived releases', async () => { + await renderTest({release: archivedScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('archive-release-menu-item')).not.toBeInTheDocument() + }) + + test('does not allow for published of archived releases', async () => { + await renderTest({release: publishedASAPRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('archive-release-menu-item-menu-item')).not.toBeInTheDocument() + }) + + test('requires confirmation when no documents in release', async () => { + mockUseBundleDocuments.mockReturnValue(useBundleDocumentsMockReturn) + + await renderTest({release: activeScheduledRelease, documentsCount: 0}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).toBeInTheDocument() + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + expect(useReleaseOperationsMockReturn.archive).toHaveBeenCalledWith( + activeScheduledRelease._id, + ) + }) + + test('can reject archiving', async () => { + await openConfirmArchiveDialog() + + await act(() => { + fireEvent.click(screen.getByTestId('cancel-button')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + + describe('when archiving is successful', () => { + beforeEach(async () => { + await openConfirmArchiveDialog() + }) + + test('will archive an active release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + + describe('when archiving fails', () => { + beforeEach(async () => { + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + archive: vi.fn().mockRejectedValue(new Error('some rejection reason')), + }) + + await openConfirmArchiveDialog() + }) + + test('will not archive the release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + }) + + describe('delete release', () => { + const openConfirmDeleteDialog = async () => { + await renderTest({release: archivedScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('delete-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('delete-release-menu-item')) + }) + + screen.getByTestId('confirm-delete-dialog') + } + + test('does not allow for deleting an active release', async () => { + await renderTest({release: activeScheduledRelease, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('delete-release-menu-item')).not.toBeInTheDocument() + }) + + test('requires confirmation when no documents in release', async () => { + mockUseBundleDocuments.mockReturnValue(useBundleDocumentsMockReturn) + + // verifying that delete supported for published releases too + await renderTest({release: publishedASAPRelease, documentsCount: 0}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('delete-release-menu-item') + + await act(() => { + fireEvent.click(screen.getByTestId('delete-release-menu-item')) + }) + + expect(screen.queryByTestId('confirm-delete-dialog')).toBeInTheDocument() + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + expect(useReleaseOperationsMockReturn.deleteRelease).toHaveBeenCalledWith( + publishedASAPRelease._id, + ) + }) + + test('can reject deleting', async () => { + await openConfirmDeleteDialog() + + await act(() => { + fireEvent.click(screen.getByTestId('cancel-button')) + }) + + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + + describe('when deleting is successful', () => { + beforeEach(async () => { + await openConfirmDeleteDialog() + }) + + test('will delete an active release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().deleteRelease).toHaveBeenCalledWith( + archivedScheduledRelease._id, + ) + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + }) + + describe('when deleting fails', () => { + beforeEach(async () => { + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + deleteRelease: vi.fn().mockRejectedValue(new Error('some rejection reason')), + }) + + await openConfirmDeleteDialog() + }) + + test('will not delete the release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().deleteRelease).toHaveBeenCalledWith( + archivedScheduledRelease._id, + ) + expect(screen.queryByTestId('confirm-delete-dialog')).not.toBeInTheDocument() + }) + }) + }) + + describe('unschedule release', () => { + test.each([ + {state: 'archived', fixture: archivedScheduledRelease}, + {state: 'active', fixture: activeScheduledRelease}, + {state: 'published', fixture: publishedASAPRelease}, + ])('will not allow for unscheduling of $state releases', async ({fixture}) => { + await renderTest({release: fixture, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument() + }) + + test.each([ + {state: 'scheduled', fixture: scheduledRelease}, + {state: 'scheduling', fixture: {...scheduledRelease, state: 'scheduling' as ReleaseState}}, + ])('will unschedule a $state release', async ({fixture}) => { + await renderTest({release: fixture, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + fireEvent.click(screen.getByTestId('unschedule-release-menu-item')) + + // does not require confirmation + expect(useReleaseOperations().unschedule).toHaveBeenCalledWith(fixture._id) + }) + }) + + test.todo('will unarchive an archived release', async () => { + /** @todo update once unarchive has been implemented */ + const archivedRelease: ReleaseDocument = {...activeScheduledRelease, state: 'archived'} + + await renderTest({release: archivedRelease, documentsCount: 1}) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release-menu-item')) + }) + + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith({ + ...archivedRelease, + archivedAt: undefined, + }) + }) + + test('will hide CTAs when ignoreCTA is true', async () => { + await renderTest({release: scheduledRelease, ignoreCTA: true, documentsCount: 1}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts new file mode 100644 index 00000000000..3a4986731f0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/releaseActions.ts @@ -0,0 +1,65 @@ +import {type DefinedTelemetryLog} from '@sanity/telemetry/react' + +import { + ArchivedRelease, + DeletedRelease, + UnarchivedRelease, + UnscheduledRelease, +} from '../../../__telemetry__/releases.telemetry' + +export type ReleaseAction = 'archive' | 'unarchive' | 'delete' | 'unschedule' + +interface BaseReleaseActionsMap { + toastSuccessI18nKey: string + toastFailureI18nKey: string + telemetry: DefinedTelemetryLog +} + +interface DialogActionsMap extends BaseReleaseActionsMap { + confirmDialog: { + dialogId: string + dialogHeaderI18nKey: string + dialogDescriptionI18nKey: string + dialogConfirmButtonI18nKey: string + } +} + +export const RELEASE_ACTION_MAP: Record< + ReleaseAction, + DialogActionsMap | (BaseReleaseActionsMap & {confirmDialog: false}) +> = { + delete: { + confirmDialog: { + dialogId: 'confirm-delete-dialog', + dialogHeaderI18nKey: 'delete-dialog.confirm-delete.header', + dialogDescriptionI18nKey: 'delete-dialog.confirm-delete-description', + dialogConfirmButtonI18nKey: 'delete-dialog.confirm-delete-button', + }, + toastSuccessI18nKey: 'toast.delete.success', + toastFailureI18nKey: 'toast.delete.error', + telemetry: DeletedRelease, + }, + archive: { + confirmDialog: { + dialogId: 'confirm-archive-dialog', + dialogHeaderI18nKey: 'archive-dialog.confirm-archive-header', + dialogDescriptionI18nKey: 'archive-dialog.confirm-archive-description', + dialogConfirmButtonI18nKey: 'archive-dialog.confirm-archive-button', + }, + toastSuccessI18nKey: 'toast.archive.success', + toastFailureI18nKey: 'toast.archive.error', + telemetry: ArchivedRelease, + }, + unarchive: { + confirmDialog: false, + toastSuccessI18nKey: 'toast.unarchive.success', + toastFailureI18nKey: 'toast.unarchive.error', + telemetry: UnarchivedRelease, + }, + unschedule: { + confirmDialog: false, + toastSuccessI18nKey: 'toast.unschedule.success', + toastFailureI18nKey: 'toast.unschedule.error', + telemetry: UnscheduledRelease, + }, +} diff --git a/packages/sanity/src/core/releases/tool/components/StatusItem.tsx b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx new file mode 100644 index 00000000000..ebacb13ca36 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx @@ -0,0 +1,23 @@ +import {Box, Card, Flex, Text} from '@sanity/ui' +import {type ReactNode} from 'react' + +export function StatusItem(props: {avatar?: ReactNode; text: ReactNode; testId?: string}) { + const {avatar, text, testId} = props + + return ( + + + {avatar && ( + + {avatar} + + )} + + + {text} + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx new file mode 100644 index 00000000000..c02563c0dbd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx @@ -0,0 +1,306 @@ +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 + +import {Box, Card, type CardProps, Flex, rem, Stack, Text, useTheme} from '@sanity/ui' +import { + defaultRangeExtractor, + type Range, + useVirtualizer, + type VirtualItem, +} from '@tanstack/react-virtual' +import {isValid} from 'date-fns' +import {get} from 'lodash' +import { + type CSSProperties, + Fragment, + type HTMLProps, + type MutableRefObject, + type RefAttributes, + type RefObject, + useMemo, + useRef, +} from 'react' + +import {TooltipDelayGroupProvider} from '../../../../../ui-components' +import {LoadingBlock} from '../../../../components' +import {TableHeader} from './TableHeader' +import {TableProvider, type TableSort, useTableContext} from './TableProvider' +import {type Column} from './types' + +type RowDatum = AdditionalRowTableData extends undefined + ? TableData + : TableData & AdditionalRowTableData + +export type TableRowProps = Omit< + CardProps & Omit, 'height' | 'as'>, + 'ref' +> & + RefAttributes + +export interface TableProps { + columnDefs: Column>[] + searchFilter?: (data: TableData[], searchTerm: string) => TableData[] + data: TableData[] + emptyState: (() => React.JSX.Element) | string + loading?: boolean + /** + * Should be the dot separated path to the unique identifier of the row. e.g. document._id + */ + rowId: string + rowActions?: ({ + datum, + }: { + datum: RowDatum | unknown + }) => React.ReactNode + rowProps?: (datum: TableData) => Partial + scrollContainerRef: RefObject + hideTableInlinePadding?: boolean +} + +const ITEM_HEIGHT = 59 + +/** + * This function modifies the rangeExtractor to account for the offset of the virtualizer + * in this case, the parent with overflow (the element over which the scroll happens) and the start of the virtualizer + * don't match, because there are some elements rendered on top of the virtualizer. + * This, will take care of adding more elements to the start of the virtualizer to account for the offset. + */ +const withVirtualizerOffset = ({ + scrollContainerRef, + virtualizerContainerRef, + range, +}: { + scrollContainerRef: MutableRefObject + virtualizerContainerRef: MutableRefObject + range: Range +}) => { + const parentOffset = scrollContainerRef.current?.offsetTop ?? 0 + const virtualizerOffset = virtualizerContainerRef.current?.offsetTop ?? 0 + const virtualizerScrollMargin = virtualizerOffset - parentOffset + const topItemsOffset = Math.ceil(virtualizerScrollMargin / ITEM_HEIGHT) + const startIndexWithOffset = range.startIndex - topItemsOffset + const result = defaultRangeExtractor({ + ...range, + // By modifying the startIndex, we are adding more elements to the start of the virtualizer + startIndex: startIndexWithOffset > 0 ? startIndexWithOffset : 0, + }) + return result +} +const TableInner = ({ + columnDefs, + data, + emptyState, + searchFilter, + rowId, + rowActions, + loading = false, + rowProps = () => ({}), + scrollContainerRef, + hideTableInlinePadding = false, +}: TableProps) => { + const {searchTerm, sort} = useTableContext() + const virtualizerContainerRef = useRef(null) + const filteredData = useMemo(() => { + const filteredResult = searchTerm && searchFilter ? searchFilter(data, searchTerm) : data + if (!sort) return filteredResult + + const sortColumn = columnDefs.find((column) => column.id === sort.column) + return [...filteredResult].sort((a, b) => { + let order: number + + const [aValue, bValue]: (number | string)[] = [a, b].map( + (sortValue) => + sortColumn?.sortTransform?.(sortValue as RowDatum) ?? + get(sortValue, sort.column), + ) + if ( + typeof aValue === 'string' && + typeof bValue === 'string' && + !isValid(aValue) && + !isValid(bValue) + ) { + order = aValue.toLowerCase().localeCompare(bValue.toLowerCase()) + } else { + const parseDate = (datum: number | string) => { + if (sortColumn?.sortTransform && typeof datum === 'number') return datum + + return typeof datum === 'string' ? Date.parse(datum) : 0 + } + + const [aDate, bDate] = [aValue, bValue].map(parseDate) + + order = aDate - bDate + } + + if (sort.direction === 'asc') return order + return -order + }) + }, [columnDefs, data, searchFilter, searchTerm, sort]) + + const rowVirtualizer = useVirtualizer({ + count: filteredData.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + rangeExtractor: (range) => + withVirtualizerOffset({scrollContainerRef, virtualizerContainerRef, range}), + }) + + const rowActionColumnDef: Column = useMemo( + () => ({ + id: 'actions', + sorting: false, + width: 50, + header: ({headerProps: {id}}) => ( + + + + + + ), + cell: ({datum, cellProps: {id}}) => ( + + {rowActions?.({datum}) || } + + ), + }), + [rowActions], + ) + + const amalgamatedColumnDefs = useMemo( + () => (rowActions ? [...columnDefs, rowActionColumnDef] : columnDefs), + [columnDefs, rowActionColumnDef, rowActions], + ) + + const renderRow = useMemo( + () => + function TableRow( + datum: (TableData | (TableData & AdditionalRowTableData)) & { + virtualRow: VirtualItem + index: number + isFirst: boolean + isLast: boolean + }, + ) { + const cardRowProps = rowProps(datum as TableData) + + return ( + + {amalgamatedColumnDefs.map(({cell: Cell, style, width, id, sorting = false}) => ( + + } + cellProps={{ + as: 'td', + id: String(id), + style: {...style, width: width || undefined}, + }} + sorting={sorting} + /> + + ))} + + ) + }, + [amalgamatedColumnDefs, rowId, rowProps], + ) + + const emptyContent = useMemo(() => { + if (typeof emptyState === 'string') { + return ( + + + {emptyState} + + + ) + } + return emptyState() + }, [emptyState]) + + const headers = useMemo( + () => + amalgamatedColumnDefs.map(({cell, sortTransform, ...header}) => ({ + ...header, + id: String(header.id), + })), + [amalgamatedColumnDefs], + ) + + const theme = useTheme() + + if (loading) { + return + } + + const maxInlineSize = (!hideTableInlinePadding && theme.sanity.v2?.container[3]) || 0 + + return ( + + + + + + {filteredData.length === 0 + ? emptyContent + : rowVirtualizer.getVirtualItems().map((virtualRow, index) => { + const datum = filteredData[virtualRow.index] + return renderRow({ + ...datum, + virtualRow, + index, + isFirst: virtualRow.index === 0, + isLast: virtualRow.index === filteredData.length - 1, + }) + })} + + + + + ) +} + +export const Table = ({ + defaultSort, + ...props +}: TableProps & {defaultSort?: TableSort}) => { + return ( + + + {...props} /> + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx new file mode 100644 index 00000000000..56ce3a754f0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx @@ -0,0 +1,112 @@ +import {ArrowUpIcon, SearchIcon} from '@sanity/icons' +import {Box, Card, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {motion} from 'framer-motion' +import {useMemo} from 'react' + +import {Button, type ButtonProps} from '../../../../../ui-components' +import {useTableContext} from './TableProvider' +import {type HeaderProps, type TableHeaderProps} from './types' + +const MotionIcon = motion.create(ArrowUpIcon) + +const BasicHeader = ({text}: {text: string}) => ( + + + {text} + + +) + +const SortHeaderButton = ({ + header, + text, +}: Omit & + HeaderProps & { + text: string + }) => { + const {sort, setSortColumn} = useTableContext() + const sortIcon = useMemo( + () => ( + + ), + [sort?.direction], + ) + + return ( + setSortColumn(String(header.id))} + mode="bleed" + size="default" + text={text} + /> + ) +} + +const TableHeaderSearch = ({ + headerProps, + searchDisabled, + placeholder, +}: HeaderProps & {placeholder?: string}) => { + const {setSearchTerm, searchTerm} = useTableContext() + + return ( + + setSearchTerm(event.currentTarget.value)} + onClear={() => setSearchTerm('')} + clearButton={!!searchTerm} + /> + + ) +} + +/** + * + * @internal + */ +export const TableHeader = ({headers, searchDisabled}: TableHeaderProps) => { + return ( + + + {headers.map(({header: Header, style, width, id, sorting}) => ( + + ))} + + + ) +} + +export const Headers = { + SortHeaderButton, + TableHeaderSearch, + BasicHeader, +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/TableProvider.tsx b/packages/sanity/src/core/releases/tool/components/Table/TableProvider.tsx new file mode 100644 index 00000000000..c11d365dded --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/TableProvider.tsx @@ -0,0 +1,53 @@ +import {type ComponentType, type PropsWithChildren, useCallback, useContext, useState} from 'react' +import {TableContext} from 'sanity/_singletons' + +export interface TableSort { + column: string + direction: 'asc' | 'desc' +} + +/** + * @internal + */ +export interface TableContextValue { + searchTerm: string | null + setSearchTerm: (searchTerm: string) => void + sort: TableSort | null + setSortColumn: (column: string) => void +} + +/** + * @internal + */ +export const TableProvider: ComponentType = ({ + children, + defaultSort, +}) => { + const [searchTerm, setSearchTerm] = useState(null) + const [sort, setSort] = useState(defaultSort || null) + + const setSortColumn = useCallback((newColumn: string) => { + setSort((s) => { + if (s?.column === newColumn) { + return {...s, direction: s.direction === 'asc' ? 'desc' : 'asc'} + } + + return {column: String(newColumn), direction: 'desc'} + }) + }, []) + + const contextValue = {searchTerm, setSearchTerm, sort, setSortColumn} + + return {children} +} + +/** + * @internal + */ +export const useTableContext = (): TableContextValue => { + const context = useContext(TableContext) + if (!context) { + throw new Error('useTableContext must be used within a TableProvider') + } + return context +} diff --git a/packages/sanity/src/core/releases/tool/components/Table/types.ts b/packages/sanity/src/core/releases/tool/components/Table/types.ts new file mode 100644 index 00000000000..3dd1be4a661 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/Table/types.ts @@ -0,0 +1,31 @@ +import {type CSSProperties} from 'react' + +export interface InjectedTableProps { + as?: React.ElementType | keyof React.JSX.IntrinsicElements + id: string + style: {width?: number} +} + +export interface Column { + header: (props: HeaderProps) => React.JSX.Element + cell: (props: { + datum: TableData + cellProps: InjectedTableProps + sorting: boolean + }) => React.ReactNode + id: keyof TableData | string + width: number | null + style?: CSSProperties + sorting?: boolean + sortTransform?: (value: TableData) => number | string +} + +export interface TableHeaderProps { + headers: Omit[] + searchDisabled?: boolean +} + +export type HeaderProps = Omit & { + headerProps: InjectedTableProps + header: Pick +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleasePublishAllButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleasePublishAllButton.tsx new file mode 100644 index 00000000000..1cd2d2a314b --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleasePublishAllButton.tsx @@ -0,0 +1,203 @@ +import {ErrorOutlineIcon, PublishIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Flex, Text, useToast} from '@sanity/ui' +import {useCallback, useMemo, useState} from 'react' + +import {Button, Dialog} from '../../../../../ui-components' +import {ToneIcon} from '../../../../../ui-components/toneIcon/ToneIcon' +import {Translate, useTranslation} from '../../../../i18n' +import {usePerspective} from '../../../../perspective/usePerspective' +import {useSetPerspective} from '../../../../perspective/useSetPerspective' +import {supportsLocalStorage} from '../../../../util/supportsLocalStorage' +import {PublishedRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {isReleaseDocument, type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {type DocumentInRelease} from '../../detail/useBundleDocuments' + +interface ReleasePublishAllButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +export const ReleasePublishAllButton = ({ + release, + documents, + disabled, +}: ReleasePublishAllButtonProps) => { + const toast = useToast() + const {publishRelease} = useReleaseOperations() + const {t} = useTranslation(releasesLocaleNamespace) + const perspective = usePerspective() + const setPerspective = useSetPerspective() + const telemetry = useTelemetry() + const publish2 = useMemo(() => { + if (supportsLocalStorage) { + return localStorage.getItem('publish2') === 'true' + } + return false + }, []) + + const [publishBundleStatus, setPublishBundleStatus] = useState< + 'idle' | 'confirm' | 'confirm-2' | 'publishing' + >('idle') + + const isValidatingDocuments = documents.some(({validation}) => validation.isValidating) + const hasDocumentValidationErrors = documents.some(({validation}) => validation.hasError) + + const isPublishButtonDisabled = disabled || isValidatingDocuments || hasDocumentValidationErrors + + const handleConfirmPublishAll = useCallback(async () => { + if (!release) return + + try { + const useUnstableAction = publishBundleStatus === 'confirm-2' + setPublishBundleStatus('publishing') + await publishRelease(release._id, useUnstableAction) + telemetry.log(PublishedRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + if ( + isReleaseDocument(perspective.selectedPerspective) && + perspective.selectedPerspective?._id === release._id + ) { + setPerspective('drafts') + } + } catch (publishingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(publishingError) + } finally { + setPublishBundleStatus('idle') + } + }, [ + release, + publishBundleStatus, + publishRelease, + telemetry, + toast, + t, + perspective.selectedPerspective, + setPerspective, + ]) + + const confirmPublishDialog = useMemo(() => { + if (publishBundleStatus === 'idle') return null + + return ( + setPublishBundleStatus('idle')} + footer={{ + confirmButton: { + text: t('action.publish-all-documents'), + tone: 'positive', + onClick: handleConfirmPublishAll, + loading: publishBundleStatus === 'publishing', + disabled: publishBundleStatus === 'publishing', + }, + }} + > + + { + + } + + + ) + }, [publishBundleStatus, t, handleConfirmPublishAll, release, documents.length]) + + const publishTooltipContent = useMemo(() => { + if (!hasDocumentValidationErrors && !isValidatingDocuments) return null + + const tooltipText = () => { + if (isValidatingDocuments) { + return t('publish-dialog.validation.loading') + } + + if (hasDocumentValidationErrors) { + return t('publish-dialog.validation.error') + } + + return null + } + + // TODO: this is a duplicate of logic in ReleaseScheduleButton + return ( + + + + {tooltipText()} + + + ) + }, [hasDocumentValidationErrors, isValidatingDocuments, t]) + + return ( + <> + {publish2 && ( + setPublishBundleStatus('confirm-2')} + loading={publishBundleStatus === 'publishing'} + data-testid="publish-all-button" + tone="suggest" + /> + )} + setPublishBundleStatus('confirm')} + loading={publishBundleStatus === 'publishing'} + data-testid="publish-all-button" + tone="positive" + /> + {confirmPublishDialog} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx new file mode 100644 index 00000000000..825cc07203f --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx @@ -0,0 +1,238 @@ +import {RestoreIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Card, Checkbox, Flex, Text, useToast} from '@sanity/ui' +import {useCallback, useState} from 'react' +import {useRouter} from 'sanity/router' + +import {Button} from '../../../../../../ui-components/button/Button' +import {Dialog} from '../../../../../../ui-components/dialog' +import {Translate, useTranslation} from '../../../../../i18n' +import {RevertRelease} from '../../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../../i18n' +import {type ReleaseDocument} from '../../../../store/types' +import {useReleaseOperations} from '../../../../store/useReleaseOperations' +import {createReleaseId} from '../../../../util/createReleaseId' +import {getReleaseIdFromReleaseDocumentId} from '../../../../util/getReleaseIdFromReleaseDocumentId' +import {type DocumentInRelease} from '../../../detail/useBundleDocuments' +import {useDocumentRevertStates} from './useDocumentRevertStates' +import {usePostPublishTransactions} from './usePostPublishTransactions' + +interface ReleasePublishAllButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +type RevertReleaseStatus = 'idle' | 'confirm' | 'reverting' + +const ConfirmReleaseDialog = ({ + revertReleaseStatus, + documents, + setRevertReleaseStatus, + release, +}: { + revertReleaseStatus: RevertReleaseStatus + documents: DocumentInRelease[] + setRevertReleaseStatus: (status: RevertReleaseStatus) => void + release: ReleaseDocument +}) => { + const {t} = useTranslation(releasesLocaleNamespace) + const hasPostPublishTransactions = usePostPublishTransactions(documents) + const getDocumentRevertStates = useDocumentRevertStates(documents) + const [stageNewRevertRelease, setStageNewRevertRelease] = useState(true) + const toast = useToast() + const telemetry = useTelemetry() + const {revertRelease} = useReleaseOperations() + const router = useRouter() + + const navigateToRevertRelease = useCallback( + (revertReleaseId: string) => () => + router.navigate({releaseId: getReleaseIdFromReleaseDocumentId(revertReleaseId)}), + [router], + ) + + const handleRevertRelease = useCallback(async () => { + setRevertReleaseStatus('reverting') + const documentRevertStates = await getDocumentRevertStates() + + const revertReleaseId = createReleaseId() + + try { + if (!documentRevertStates) { + throw new Error('Unable to find documents to revert') + } + + await revertRelease( + revertReleaseId, + documentRevertStates, + { + title: t('revert-release.title', {title: release.metadata.title}), + description: t('revert-release.description', {title: release.metadata.title}), + releaseType: 'asap', + }, + stageNewRevertRelease ? 'staged' : 'immediate', + ) + + if (stageNewRevertRelease) { + telemetry.log(RevertRelease, {revertType: 'staged'}) + toast.push({ + closable: true, + status: 'success', + title: ( + + ( + + {t('toast.revert-stage.success-link')} + + ), + }} + t={t} + i18nKey="toast.revert-stage.success" + values={{title: release.metadata.title}} + /> + + ), + }) + } else { + telemetry.log(RevertRelease, {revertType: 'immediate'}) + + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } + } catch (revertError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(revertError) + } finally { + setRevertReleaseStatus('idle') + } + }, [ + setRevertReleaseStatus, + getDocumentRevertStates, + revertRelease, + t, + release.metadata.title, + stageNewRevertRelease, + telemetry, + toast, + navigateToRevertRelease, + ]) + + const description = + documents.length > 1 + ? 'revert-dialog.confirm-revert-description_other' + : 'revert-dialog.confirm-revert-description_one' + + return ( + setRevertReleaseStatus('idle')} + footer={{ + confirmButton: { + text: t( + stageNewRevertRelease + ? 'action.create-revert-release' + : 'action.immediate-revert-release', + ), + tone: 'positive', + onClick: handleRevertRelease, + loading: revertReleaseStatus === 'reverting', + disabled: revertReleaseStatus === 'reverting', + }, + }} + > + + { + + } + + + setStageNewRevertRelease((current) => !current)} + id="stage-release" + style={{display: 'block'}} + checked={stageNewRevertRelease} + /> + + + + {t('revert-dialog.confirm-revert.stage-revert-checkbox-label')} + + + + + {hasPostPublishTransactions && !stageNewRevertRelease && ( + + + {t('revert-dialog.confirm-revert.warning-card')} + + + )} + + ) +} + +export const ReleaseRevertButton = ({ + release, + documents, + disabled, +}: ReleasePublishAllButtonProps) => { + const {t} = useTranslation(releasesLocaleNamespace) + const [revertReleaseStatus, setRevertReleaseStatus] = useState('idle') + + return ( + <> + setRevertReleaseStatus('confirm')} + text={t('action.revert')} + tone="critical" + disabled={disabled} + /> + {revertReleaseStatus !== 'idle' && ( + + )} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/useDocumentRevertStates.test.ts b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/useDocumentRevertStates.test.ts new file mode 100644 index 00000000000..cf053263739 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/useDocumentRevertStates.test.ts @@ -0,0 +1,248 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {renderHook, waitFor} from '@testing-library/react' +import {of} from 'rxjs' +import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {useClient} from '../../../../../../hooks/useClient' +import {getTransactionsLogs} from '../../../../../../store/translog/getTransactionsLogs' +import {type DocumentInRelease} from '../../../../detail/useBundleDocuments' +import {useDocumentRevertStates} from '../useDocumentRevertStates' + +vi.mock('../../../../../../hooks/useClient', () => ({ + useClient: vi.fn(), +})) + +vi.mock('../../../../../../store/translog/getTransactionsLogs', () => ({ + getTransactionsLogs: vi.fn(), +})) + +describe('useDocumentRevertStates', () => { + const mockDocuments = [ + {document: {_id: 'versions.r1.doc1', _rev: 'rev1'}}, + {document: {_id: 'versions.r1.doc2', _rev: 'rev2'}}, + ] as DocumentInRelease[] + + /** @todo improve the useClient mock */ + const mockClient = { + getUrl: vi.fn(), + config: vi.fn().mockReturnValue({dataset: 'test-dataset'}), + observable: { + request: vi.fn(), + }, + } as unknown as SanityClient & { + observable: { + request: Mock + } + } + + const mockUseClient = useClient as Mock + const mockGetTransactionsLogs = getTransactionsLogs as Mock + + beforeEach(() => { + vi.clearAllMocks() + + mockUseClient.mockReturnValue(mockClient) + + mockGetTransactionsLogs.mockResolvedValue([ + {id: 'trans0_doc1', documentIDs: ['doc1'], timestamp: new Date().toISOString()}, + {id: 'trans1_doc1', documentIDs: ['doc1'], timestamp: new Date().toISOString()}, + {id: 'trans0_doc2', documentIDs: ['doc2'], timestamp: new Date().toISOString()}, + {id: 'trans1_doc2', documentIDs: ['doc2'], timestamp: new Date().toISOString()}, + ] as TransactionLogEventWithEffects[]) + + mockClient.observable.request.mockImplementation(({url}) => { + if (url!.includes('doc1')) { + return of({ + documents: [ + { + _id: 'doc1', + _rev: 'observable-rev-1', + title: 'Reverted Document 1', + }, + ], + }) + } + if (url!.includes('doc2')) { + return of({ + documents: [ + { + _id: 'doc2', + _rev: 'observable-rev-2', + title: 'Reverted Document 2', + }, + ], + }) + } + return of({documents: []}) + }) + }) + + it('should return a function', () => { + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + expect(typeof result.current).toBe('function') + }) + + it('should fetch adjacent transactions and resolve to revert states', async () => { + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + + await waitFor(async () => { + const resolvedResult = await result.current() + expect(resolvedResult).toEqual([ + { + _id: 'doc1', + _rev: 'observable-rev-1', + title: 'Reverted Document 1', + }, + { + _id: 'doc2', + _rev: 'observable-rev-2', + title: 'Reverted Document 2', + }, + ]) + }) + + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + toTransaction: 'rev1', + reverse: true, + }) + + expect(mockClient.observable.request).toHaveBeenNthCalledWith(1, { + url: '/data/history/test-dataset/documents/doc1?revision=trans1_doc1', + }) + expect(mockClient.observable.request).toHaveBeenNthCalledWith(2, { + url: '/data/history/test-dataset/documents/doc2?revision=trans1_doc2', + }) + }) + + it('should handle missing revisions and mark for deletion', async () => { + mockGetTransactionsLogs.mockResolvedValue([ + // publish transaction for release + { + id: 'trans0_doc1', + effects: {}, + author: 'author', + documentIDs: ['doc1'], + timestamp: new Date().toISOString(), + }, + ]) + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + + await waitFor(async () => { + const resolvedResult = await result.current() + expect(resolvedResult).toEqual([ + { + _id: 'doc1', + _rev: 'rev1', + _system: {delete: true}, + }, + { + _id: 'doc2', + _rev: 'rev2', + _system: {delete: true}, + }, + ]) + }) + + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + toTransaction: 'rev1', + reverse: true, + }) + }) + + it('should return null if no transactions exist', async () => { + mockGetTransactionsLogs.mockResolvedValue([]) // No transactions + + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + + await waitFor(async () => { + const resolvedResult = await result.current() + expect(resolvedResult).toBeUndefined() + }) + + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + toTransaction: 'rev1', + reverse: true, + }) + + expect(mockClient.observable.request).not.toHaveBeenCalled() // No API calls for empty transactions + }) + + it('should handle errors gracefully and return undefined', async () => { + mockGetTransactionsLogs.mockRejectedValue(new Error('Failed to fetch transactions')) + + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + + await waitFor(async () => { + const resolvedResult = await result.current() + expect(resolvedResult).toBeUndefined() + }) + + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + toTransaction: 'rev1', + reverse: true, + }) + + expect(mockClient.observable.request).not.toHaveBeenCalled() // No API calls on failure + }) + + it('should handle a mix of existing and missing revisions', async () => { + mockGetTransactionsLogs.mockResolvedValue([ + // publish transaction for release + { + id: 'trans0_doc1', + effects: {}, + author: 'author', + documentIDs: ['doc1'], + timestamp: new Date().toISOString(), + }, + { + id: 'trans0_doc2', + effects: {}, + author: 'author', + documentIDs: ['doc2'], + timestamp: new Date().toISOString(), + }, + { + id: 'trans1_doc1', + effects: {}, + author: 'author', + documentIDs: ['doc1'], + timestamp: new Date().toISOString(), + }, + { + id: 'trans2_doc1', + effects: {}, + author: 'author', + documentIDs: ['doc1'], + timestamp: new Date().toISOString(), + }, + ]) + + const {result} = renderHook(() => useDocumentRevertStates(mockDocuments)) + + await waitFor(async () => { + const resolvedResult = await result.current() + expect(resolvedResult).toEqual([ + { + _id: 'doc1', + _rev: 'observable-rev-1', + title: 'Reverted Document 1', + }, + { + _id: 'doc2', + _rev: 'rev2', + _system: {delete: true}, + }, + ]) + }) + + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + toTransaction: 'rev1', + reverse: true, + }) + expect(mockClient.observable.request).toHaveBeenNthCalledWith(1, { + url: '/data/history/test-dataset/documents/doc1?revision=trans1_doc1', + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/usePostPublishTransactions.test.ts b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/usePostPublishTransactions.test.ts new file mode 100644 index 00000000000..81a094760c0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/__tests__/usePostPublishTransactions.test.ts @@ -0,0 +1,121 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {act, renderHook, waitFor} from '@testing-library/react' +import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {useClient} from '../../../../../../hooks/useClient' +import {getTransactionsLogs} from '../../../../../../store/translog/getTransactionsLogs' +import {type DocumentInRelease} from '../../../../detail/useBundleDocuments' +import {usePostPublishTransactions} from '../usePostPublishTransactions' + +vi.mock('../../../../../../hooks/useClient', () => ({ + useClient: vi.fn(), +})) + +vi.mock('../../../../../../store/translog/getTransactionsLogs', () => ({ + getTransactionsLogs: vi.fn(), +})) + +const processMicroTasks = () => + act(async () => { + await Promise.resolve() + }) + +describe('usePostPublishTransactions', () => { + const mockDocuments = [ + {document: {_id: 'doc1', _rev: 'rev1'}}, + {document: {_id: 'doc2', _rev: 'rev2'}}, + ] as DocumentInRelease[] + + const mockClient = {getUrl: vi.fn(), config: vi.fn()} + + const mockUseClient = useClient as Mock + const mockGetTransactionsLogs = getTransactionsLogs as Mock + + beforeEach(() => { + vi.clearAllMocks() + + mockUseClient.mockReturnValue(mockClient) + }) + + const mockTransactionLogs = [ + { + id: 'trans1', + author: 'author1', + documentIDs: ['doc1'], + timestamp: '2024-01-01T00:00:00Z', + }, + { + id: 'trans2', + author: 'author1', + documentIDs: ['doc1'], + timestamp: '2024-01-01T01:00:00Z', + }, + ] as TransactionLogEventWithEffects[] + + it('should return null initially', () => { + mockGetTransactionsLogs.mockResolvedValue(undefined) + + const {result} = renderHook(() => usePostPublishTransactions(mockDocuments)) + + expect(result.current).toBeNull() + }) + + it('should return false when no documents provided', async () => { + const {result} = renderHook(() => usePostPublishTransactions([])) + + expect(result.current).toBe(false) + }) + + it('should call getTransactionsLogs with the correct params', async () => { + mockGetTransactionsLogs.mockResolvedValue(mockTransactionLogs) + + const {result} = renderHook(() => usePostPublishTransactions(mockDocuments)) + + await processMicroTasks() + + expect(mockGetTransactionsLogs).toHaveBeenCalledTimes(1) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, ['doc1', 'doc2'], { + fromTransaction: 'rev1', + limit: 2, + }) + expect(result.current).toBe(true) + }) + + it('should return true if there are post-publish transactions', async () => { + mockGetTransactionsLogs.mockResolvedValue(mockTransactionLogs) + + const {result} = renderHook(() => usePostPublishTransactions(mockDocuments)) + + await waitFor(() => { + expect(result.current).toBe(true) + }) + }) + + it('should return false if there are no post-publish transactions', async () => { + mockGetTransactionsLogs.mockResolvedValue([ + { + id: 'trans1', + author: 'author1', + documentIDs: ['doc1'], + effects: {}, + timestamp: '2024-01-01T00:00:00Z', + }, + ]) + + const {result} = renderHook(() => usePostPublishTransactions(mockDocuments)) + + await waitFor(() => { + expect(result.current).toBe(false) + }) + }) + + it('should return null when getTransactionsLogs throws an error', async () => { + mockGetTransactionsLogs.mockRejectedValue(new Error('Failed to fetch transaction logs')) + + const {result} = renderHook(() => usePostPublishTransactions(mockDocuments)) + + await waitFor(() => { + expect(result.current).toBeNull() + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates.ts b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates.ts new file mode 100644 index 00000000000..ca7fdb8ea5f --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates.ts @@ -0,0 +1,139 @@ +import {type SanityDocument} from '@sanity/types' +import {useCallback, useEffect, useMemo, useRef} from 'react' +import {useObservable} from 'react-rx' +import {catchError, forkJoin, from, map, type Observable, of, switchMap} from 'rxjs' + +import {useClient} from '../../../../../hooks/useClient' +import {getTransactionsLogs} from '../../../../../store/translog/getTransactionsLogs' +import {API_VERSION} from '../../../../../tasks/constants' +import {getPublishedId} from '../../../../../util/draftUtils' +import {type DocumentInRelease} from '../../../detail/useBundleDocuments' + +export type RevertDocument = SanityDocument & { + _system?: { + delete: true + } +} + +type RevertDocuments = RevertDocument[] + +type DocumentRevertStates = RevertDocuments | null | undefined + +export const useDocumentRevertStates = (releaseDocuments: DocumentInRelease[]) => { + const client = useClient({apiVersion: API_VERSION}) + const observableClient = client.observable + const transactionId = releaseDocuments[0]?.document._rev + const {dataset} = client.config() + + const resultPromiseRef = useRef | null>(null) + const resolvedDocumentRevertStatesPromiseRef = useRef< + ((value: DocumentRevertStates) => void) | null + >(null) + const resolvedDocumentRevertStatesResultRef = useRef(null) + + useEffect(() => { + if (!resultPromiseRef.current) { + const {promise, resolve} = Promise.withResolvers() + + resultPromiseRef.current = promise + resolvedDocumentRevertStatesPromiseRef.current = resolve + } + }, []) + + const memoDocumentRevertStates = useMemo(() => { + if (!releaseDocuments.length) return of(undefined) + + const publishedDocuments = releaseDocuments.map(({document}) => ({ + ...document, + _id: getPublishedId(document._id), + })) + + const documentRevertStates$: Observable = from( + getTransactionsLogs( + client, + publishedDocuments.map((document) => document._id), + { + toTransaction: transactionId, + // reverse order so most recent publish before release is second element + // (first is the release publish itself) + reverse: true, + }, + ), + ).pipe( + map((transactions) => { + if (transactions.length === 0) throw new Error('No transactions found.') + + const getDocumentTransaction = (docId: string) => + // second element is the transaction before the release + transactions.filter(({documentIDs}) => documentIDs.includes(docId))[1]?.id + + return publishedDocuments.map((document) => ({ + docId: document._id, + revisionId: getDocumentTransaction(document._id), + })) + }), + switchMap((docRevisionPairs) => { + if (!docRevisionPairs) return of(undefined) + + return forkJoin( + docRevisionPairs.map(({docId, revisionId}) => { + if (!revisionId) { + const {publishedDocumentExists, ...unpublishDocument} = + publishedDocuments.find((document) => document._id === docId) || {} + + return of({ + ...unpublishDocument, + _system: {delete: true}, + } as RevertDocument) + } + + return observableClient + .request<{ + documents: RevertDocuments + }>({ + url: `/data/history/${dataset}/documents/${docId}?revision=${revisionId}`, + }) + .pipe( + map(({documents: [revertDocument]}) => revertDocument), + catchError((err) => { + console.error(`Error fetching document ${docId}:`, err) + return of(undefined) + }), + ) + }), + ) + }), + map((results) => results?.filter((result) => result !== undefined)), + catchError((err) => { + console.error('Error in document revert states pipeline:', err) + return of(undefined) + }), + ) + + return documentRevertStates$ + }, [client, releaseDocuments, transactionId, observableClient, dataset]) + + const documentRevertStatesResult = useObservable(memoDocumentRevertStates, null) + + useEffect(() => { + if (documentRevertStatesResult !== null) { + resolvedDocumentRevertStatesResultRef.current = documentRevertStatesResult + + // Resolve promise if it exists + if (resolvedDocumentRevertStatesPromiseRef.current) { + resolvedDocumentRevertStatesPromiseRef.current(documentRevertStatesResult) + resolvedDocumentRevertStatesPromiseRef.current = null + resultPromiseRef.current = null // Reset resultPromiseRef for future fetches + } + } + }, [documentRevertStatesResult]) + + return useCallback(() => { + if (resolvedDocumentRevertStatesResultRef.current) { + // Return resolved value immediately if available + return Promise.resolve(resolvedDocumentRevertStatesResultRef.current) + } + + return resultPromiseRef.current + }, []) +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/usePostPublishTransactions.ts b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/usePostPublishTransactions.ts new file mode 100644 index 00000000000..e1cb3aa7a75 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/usePostPublishTransactions.ts @@ -0,0 +1,37 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {catchError, from, map, of} from 'rxjs' + +import {useClient} from '../../../../../hooks/useClient' +import {getTransactionsLogs} from '../../../../../store/translog/getTransactionsLogs' +import {API_VERSION} from '../../../../../tasks/constants' +import {getPublishedId} from '../../../../../util/draftUtils' +import {type DocumentInRelease} from '../../../detail/useBundleDocuments' + +export const usePostPublishTransactions = (documents: DocumentInRelease[]) => { + const client = useClient({apiVersion: API_VERSION}) + const transactionId = documents[0]?.document._rev + + const memoHasPostPublishTransactions = useMemo(() => { + if (!documents.length) return of(false) + + return from( + getTransactionsLogs( + client, + documents.map(({document}) => getPublishedId(document._id)), + { + fromTransaction: transactionId, + // publish transaction + at least one post publish transaction + limit: 2, + }, + ), + ).pipe( + // the transaction of published is also returned + // so post publish transactions will result in more than 1 transaction + map((transactions) => transactions.length > 1), + catchError(() => of(null)), + ) + }, [client, documents, transactionId]) + + return useObservable(memoHasPostPublishTransactions, null) +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseScheduleButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseScheduleButton.tsx new file mode 100644 index 00000000000..5c82865e546 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseScheduleButton.tsx @@ -0,0 +1,260 @@ +import {ClockIcon, ErrorOutlineIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Card, Flex, Stack, Text, useToast} from '@sanity/ui' +import {format, isBefore, isValid, parse, startOfMinute} from 'date-fns' +import {useCallback, useMemo, useState} from 'react' + +import {Button, Dialog} from '../../../../../ui-components' +import {ToneIcon} from '../../../../../ui-components/toneIcon/ToneIcon' +import {MONTH_PICKER_VARIANT} from '../../../../components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../../components/inputs/DateInputs/calendar/types' +import {DateTimeInput} from '../../../../components/inputs/DateInputs/DateTimeInput' +import {getCalendarLabels} from '../../../../form/inputs/DateInputs/utils' +import {Translate, useTranslation} from '../../../../i18n' +import {ScheduledRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {isReleaseScheduledOrScheduling, type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {type DocumentInRelease} from '../../detail/useBundleDocuments' + +interface ReleaseScheduleButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +export const ReleaseScheduleButton = ({ + release, + disabled, + documents, +}: ReleaseScheduleButtonProps) => { + const toast = useToast() + const {schedule} = useReleaseOperations() + const {t} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + const [status, setStatus] = useState<'idle' | 'confirm' | 'scheduling'>('idle') + const [publishAt, setPublishAt] = useState() + /** + * This state supports the scenario of: + * publishAt is set to a valid future date; but at time of submit it is in the past + * Without an update on this state, ReleaseScheduledButton would not rerender + * and so date in past warning ui elements wouldn't show + */ + const [rerenderDialog, setRerenderDialog] = useState(0) + + const isValidatingDocuments = documents.some(({validation}) => validation.isValidating) + const hasDocumentValidationErrors = documents.some(({validation}) => validation.hasError) + const isScheduleButtonDisabled = disabled || isValidatingDocuments + + const isScheduledDateInPast = useCallback(() => { + return isBefore(publishAt || new Date(), new Date()) + }, [publishAt]) + + const handleConfirmSchedule = useCallback(async () => { + if (!publishAt) return + + if (isScheduledDateInPast()) { + // rerender dialog to recalculate isScheduledDateInPast + setRerenderDialog((cur) => cur + 1) + return + } + + try { + setStatus('scheduling') + await schedule(release._id, publishAt) + telemetry.log(ScheduledRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (schedulingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(schedulingError) + } finally { + setStatus('idle') + } + }, [ + publishAt, + isScheduledDateInPast, + schedule, + release._id, + release.metadata.title, + telemetry, + toast, + t, + ]) + + const {t: coreT} = useTranslation() + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(coreT), [coreT]) + + const handleBundlePublishAtCalendarChange = useCallback((date: Date | null) => { + if (!date) return + + setPublishAt(startOfMinute(date)) + }, []) + + const handleBundleInputChange = useCallback((event: React.FocusEvent) => { + const date = event.currentTarget.value + const parsedDate = parse(date, 'PP HH:mm', new Date()) + + if (isValid(parsedDate)) { + setPublishAt(parsedDate) + } + }, []) + + const confirmScheduleDialog = useMemo(() => { + if (status === 'idle') return null + + const _isScheduledDateInPast = isScheduledDateInPast() + + return ( + setStatus('idle')} + footer={{ + confirmButton: { + text: t('schedule-dialog.confirm-button'), + tone: 'default', + onClick: handleConfirmSchedule, + loading: status === 'scheduling', + disabled: _isScheduledDateInPast || status === 'scheduling', + }, + }} + > + + {_isScheduledDateInPast && ( + + {t('schedule-dialog.publish-date-in-past-warning')} + + )} + + + + {t('schedule-dialog.select-publish-date-label')} + + + + + + + + + + ) + }, [ + status, + isScheduledDateInPast, + t, + documents.length, + handleConfirmSchedule, + handleBundlePublishAtCalendarChange, + handleBundleInputChange, + publishAt, + calendarLabels, + release.metadata.title, + rerenderDialog, + ]) + + const handleOnInitialSchedule = useCallback(() => { + setPublishAt( + release.metadata.intendedPublishAt + ? new Date(release.metadata.intendedPublishAt) + : new Date(), + ) + setStatus('confirm') + }, [release.metadata.intendedPublishAt]) + + const tooltipText = useMemo(() => { + if (isValidatingDocuments) { + return t('schedule-button-tooltip.validation.loading') + } + + if (hasDocumentValidationErrors) { + return t('schedule-button-tooltip.validation.error') + } + + if (isReleaseScheduledOrScheduling(release)) { + return t('schedule-button-tooltip.already-scheduled') + } + return null + }, [hasDocumentValidationErrors, isValidatingDocuments, release, t]) + + // TODO: this is a duplicate of logic in ReleasePublishAllButton + const scheduleTooltipContent = useMemo(() => { + return ( + + + + {tooltipText} + + + ) + }, [isValidatingDocuments, tooltipText]) + + return ( + <> + + {confirmScheduleDialog} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseUnscheduleButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseUnscheduleButton.tsx new file mode 100644 index 00000000000..3f8136a5462 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseUnscheduleButton.tsx @@ -0,0 +1,120 @@ +import {CloseCircleIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Text, useToast} from '@sanity/ui' +import {useCallback, useMemo, useState} from 'react' + +import {Button, Dialog} from '../../../../../ui-components' +import {Translate, useTranslation} from '../../../../i18n' +import {UnscheduledRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {type DocumentInRelease} from '../../detail/useBundleDocuments' + +interface ReleaseScheduleButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +export const ReleaseUnscheduleButton = ({ + release, + disabled, + documents, +}: ReleaseScheduleButtonProps) => { + const toast = useToast() + const {unschedule} = useReleaseOperations() + const {t} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + const [status, setStatus] = useState<'idle' | 'confirm' | 'unscheduling'>('idle') + + const isValidatingDocuments = documents.some(({validation}) => validation.isValidating) + const hasDocumentValidationErrors = documents.some(({validation}) => validation.hasError) + const isScheduleButtonDisabled = disabled || isValidatingDocuments || hasDocumentValidationErrors + + const handleConfirmSchedule = useCallback(async () => { + try { + setStatus('unscheduling') + await unschedule(release._id) + telemetry.log(UnscheduledRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (schedulingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(schedulingError) + } finally { + setStatus('idle') + } + }, [unschedule, release._id, release.metadata.title, telemetry, toast, t]) + + const confirmScheduleDialog = useMemo(() => { + if (status === 'idle') return null + + return ( + setStatus('idle')} + footer={{ + confirmButton: { + text: t('action.unschedule'), + tone: 'default', + onClick: handleConfirmSchedule, + loading: status === 'unscheduling', + disabled: status === 'unscheduling', + }, + }} + > + + { + + } + + + ) + }, [release.metadata.title, documents.length, handleConfirmSchedule, status, t]) + + return ( + <> + setStatus('confirm')} + loading={status === 'unscheduling'} + data-testid="schedule-button" + /> + {confirmScheduleDialog} + > + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/AddDocumentSearch.tsx b/packages/sanity/src/core/releases/tool/detail/AddDocumentSearch.tsx new file mode 100644 index 00000000000..531fadbceeb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/AddDocumentSearch.tsx @@ -0,0 +1,78 @@ +import {useTelemetry} from '@sanity/telemetry/react' +import {type SanityDocumentLike} from '@sanity/types' +import {LayerProvider, PortalProvider, useToast} from '@sanity/ui' +import {useCallback} from 'react' + +import {SearchPopover} from '../../../studio/components/navbar/search/components/SearchPopover' +import {SearchProvider} from '../../../studio/components/navbar/search/contexts/search/SearchProvider' +import {getDocumentVariantType} from '../../../util/getDocumentVariantType' +import {AddedVersion} from '../../__telemetry__/releases.telemetry' +import {useReleaseOperations} from '../../store/useReleaseOperations' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {useBundleDocuments} from './useBundleDocuments' + +export function AddDocumentSearch({ + open, + onClose, + releaseId, +}: { + open: boolean + onClose: () => void + releaseId: string +}): React.JSX.Element { + const {createVersion} = useReleaseOperations() + const toast = useToast() + const telemetry = useTelemetry() + + const {results} = useBundleDocuments(getReleaseIdFromReleaseDocumentId(releaseId)) + const idsInRelease: string[] = results.map((doc) => doc.document._id) + + const addDocument = useCallback( + async (item: Pick) => { + try { + await createVersion(getReleaseIdFromReleaseDocumentId(releaseId), item._id) + + toast.push({ + closable: true, + status: 'success', + title: `${item.title} added to release`, + }) + + const origin = getDocumentVariantType(item._id) + + telemetry.log(AddedVersion, { + documentOrigin: origin, + }) + } catch (error) { + /* empty */ + + toast.push({ + closable: true, + status: 'error', + title: error.message, + }) + } + }, + [createVersion, releaseId, telemetry, toast], + ) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + return ( + + {/* eslint-disable-next-line @sanity/i18n/no-attribute-string-literals*/} + + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseActivityList.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityList.tsx new file mode 100644 index 00000000000..8f5adafee84 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityList.tsx @@ -0,0 +1,147 @@ +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 + +import {Box} from '@sanity/ui' +import {useVirtualizer} from '@tanstack/react-virtual' +import {AnimatePresence} from 'framer-motion' +import {useEffect, useMemo, useRef} from 'react' +import {styled} from 'styled-components' + +import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' +import { + isAddDocumentToReleaseEvent, + isDiscardDocumentFromReleaseEvent, + isEventsAPIEvent, + isTranslogEvent, + type ReleaseEvent, +} from './events/types' +import {ReleaseActivityListItem} from './ReleaseActivityListItem' + +const estimateSize = (event: ReleaseEvent | undefined) => { + if (!event) { + return 40 // Is the loader row + } + if (isAddDocumentToReleaseEvent(event) || isDiscardDocumentFromReleaseEvent(event)) { + return 70 + } + return 56 +} +const VirtualContainer = styled(Box)` + height: 100%; + overflow: scroll; +` + +interface ReleaseActivityListProps { + events: ReleaseEvent[] + releaseTitle: string + releaseId: string + hasMore: boolean + loadMore: () => void + isLoading: boolean +} +export const ReleaseActivityList = ({ + events, + releaseTitle, + releaseId, + hasMore, + loadMore, + isLoading, +}: ReleaseActivityListProps) => { + const virtualizerContainerRef = useRef(null) + + const listEvents: ReleaseEvent[] = useMemo(() => { + /** + * This list combines: + * - API events, which are loaded incrementally (paginated) + * - Translog events, which are fully available (non-paginated) + * + * We want to display all events up to the oldest API event and include any translog events + * that occurred before that API event. By doing so, as we load older batches of API events, + * they will show at the bottom of the list + */ + + // If all events are loaded (no more pages) and we’re not loading, just return all events. + if (!hasMore && !isLoading) return events + + const lastEventFromEventsAPI = [...events].reverse().find(isEventsAPIEvent) + // If no API events are found (e.g., events api is not enabled) and we're not loading, return all translog events. + if (!lastEventFromEventsAPI && !isLoading) return events + + // If we haven’t found any API events yet and are still loading, show nothing for now. + if (!lastEventFromEventsAPI) return [] + + // Include only those translog events that occur before the newest API event. + const lastEventDate = new Date(lastEventFromEventsAPI.timestamp) + return events.filter((event) => { + if (isTranslogEvent(event)) { + return new Date(event.timestamp) > lastEventDate + } + return true + }) + }, [events, hasMore, isLoading]) + + const virtualizer = useVirtualizer({ + // If we have more events, or the events are loading, we add a loader row at the end + count: hasMore || isLoading ? listEvents.length + 1 : listEvents.length, + getScrollElement: () => virtualizerContainerRef.current, + estimateSize: (i) => estimateSize(events[i]), + overscan: 10, + paddingEnd: 24, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems.at(-1) + if (!lastItem) return + if (lastItem.index >= listEvents.length - 1 && hasMore) { + loadMore() + } + }, [listEvents.length, hasMore, loadMore, virtualItems]) + + return ( + + + + {virtualizer.getVirtualItems().map((virtualRow) => { + const event = listEvents[virtualRow.index] + const isLoaderRow = !event + + return ( + + {isLoaderRow ? ( + + + + ) : ( + + )} + + ) + })} + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx new file mode 100644 index 00000000000..762388eab98 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx @@ -0,0 +1,157 @@ +import {Card, Flex, Stack, Text} from '@sanity/ui' +import {motion} from 'framer-motion' +import {memo, type ReactNode, useMemo} from 'react' +import {styled} from 'styled-components' + +import {RelativeTime} from '../../../components/RelativeTime' +import {UserAvatar} from '../../../components/userAvatar/UserAvatar' +import {useDateTimeFormat} from '../../../hooks/useDateTimeFormat' +import {Translate, useTranslation} from '../../../i18n' +import {useDocumentPreviewValues} from '../../../tasks/hooks' +import {releasesLocaleNamespace} from '../../i18n' +import {ReleaseDocumentPreview} from '../components/ReleaseDocumentPreview' +import { + type AddDocumentToReleaseEvent, + type DiscardDocumentFromReleaseEvent, + isAddDocumentToReleaseEvent, + isCreateReleaseEvent, + isDiscardDocumentFromReleaseEvent, + isEditReleaseEvent, + isScheduleReleaseEvent, + type ReleaseEvent, +} from './events/types' + +const StatusText = styled(Text)` + strong { + font-weight: 500; + color: var(--card-fg-color); + } + time { + white-space: nowrap; + } +` +const ACTIVITY_TEXT_118N: Record = { + addDocumentToRelease: 'activity.event.add-document', + archiveRelease: 'activity.event.archive', + createRelease: 'activity.event.create', + discardDocumentFromRelease: 'activity.event.discard-document', + publishRelease: 'activity.event.publish', + scheduleRelease: 'activity.event.schedule', + unarchiveRelease: 'activity.event.unarchive', + unscheduleRelease: 'activity.event.unschedule', + editRelease: 'activity.event.edit', +} + +const ReleaseEventDocumentPreview = ({ + event, + releaseId, +}: { + releaseId: string + event: AddDocumentToReleaseEvent | DiscardDocumentFromReleaseEvent +}) => { + const {value, isLoading} = useDocumentPreviewValues({ + documentId: event.documentId, + documentType: event.documentType, + }) + return ( + + + + ) +} + +const ScheduleTarget = ({children, event}: {children: ReactNode; event: ReleaseEvent}) => { + const dateTimeFormat = useDateTimeFormat({dateStyle: 'full', timeStyle: 'medium'}) + const {t} = useTranslation(releasesLocaleNamespace) + + const formattedDate = useMemo(() => { + if (isEditReleaseEvent(event)) { + if (event.change.releaseType === 'asap') return t('activity.event.edit-time-asap') + if (event.change.releaseType === 'undecided') return t('activity.event.edit-time-undecided') + } + + let dateString: string | undefined + if (isScheduleReleaseEvent(event)) { + dateString = event.publishAt + } else if (isCreateReleaseEvent(event)) { + dateString = event.change?.intendedPublishDate + } else if (isEditReleaseEvent(event)) { + dateString = event.change.intendedPublishDate + } + + if (!dateString) return null + return dateTimeFormat.format(new Date(dateString)) + }, [dateTimeFormat, event, t]) + + if (!formattedDate && isCreateReleaseEvent(event)) return null + return ( + + {children} {formattedDate || '---'} + + ) +} + +const FadeInCard = motion.create(Card) +export const ReleaseActivityListItem = memo( + ({ + event, + releaseId, + releaseTitle, + }: { + event: ReleaseEvent + releaseId: string + releaseTitle: string + }) => { + const {t} = useTranslation(releasesLocaleNamespace) + + return ( + component wrapping the list) + initial={{opacity: 0}} + animate={{opacity: 1}} + transition={{type: 'spring', bounce: 0, duration: 0.4}} + > + + + + + + ( + {children} + ), + }} + values={{releaseTitle}} + i18nKey={ACTIVITY_TEXT_118N[event.type]} + />{' '} + · + + + {isAddDocumentToReleaseEvent(event) || isDiscardDocumentFromReleaseEvent(event) ? ( + + ) : null} + + + + ) + }, + (prevProps, nextProps) => { + return prevProps.event.id === nextProps.event.id && prevProps.releaseId === nextProps.releaseId + }, +) + +ReleaseActivityListItem.displayName = 'ReleaseActivityListItem' diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx new file mode 100644 index 00000000000..8ddae44a909 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx @@ -0,0 +1,86 @@ +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 + +import {Box, Card, Flex, Text} from '@sanity/ui' +import {AnimatePresence, motion} from 'framer-motion' +import {styled} from 'styled-components' + +import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' +import {Resizable} from '../../../components/resizer/Resizable' +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {type ReleaseEvents} from './events/useReleaseEvents' +import {ReleaseActivityList} from './ReleaseActivityList' + +interface ReleaseDashboardActivityPanelProps { + events: ReleaseEvents + release: ReleaseDocument + show: boolean +} +const MotionFlex = motion.create(Flex) +const FillHeight = styled.div` + height: 100%; + display: flex; + flex-direction: column; +` +export function ReleaseDashboardActivityPanel({ + events, + release, + show, +}: ReleaseDashboardActivityPanelProps) { + const {t} = useTranslation(releasesLocaleNamespace) + return ( + + {show && ( + <> + + + + + + + {t('activity.panel.title')} + + + {events.error && !events.events.length && ( + + + {t('activity.panel.error')} + + + )} + {events.loading && !events.events.length && ( + + )} + + + + + > + )} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx new file mode 100644 index 00000000000..443b942f2b5 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx @@ -0,0 +1,65 @@ +import {PinFilledIcon, PinIcon} from '@sanity/icons' +import { + Box, + // Custom button with full radius used here + // eslint-disable-next-line no-restricted-imports + Button, + Container, + Flex, + Stack, +} from '@sanity/ui' +import {useCallback} from 'react' + +import {useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' +import {useSetPerspective} from '../../../perspective/useSetPerspective' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {getReleaseTone} from '../../util/getReleaseTone' +import {ReleaseDetailsEditor} from './ReleaseDetailsEditor' +import {ReleaseTypePicker} from './ReleaseTypePicker' + +export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) { + const {state} = release + const releaseId = getReleaseIdFromReleaseDocumentId(release._id) + + const {t: tRelease} = useTranslation(releasesLocaleNamespace) + const {selectedReleaseId} = usePerspective() + const setPerspective = useSetPerspective() + const isSelected = releaseId === selectedReleaseId + + const handlePinRelease = useCallback(() => { + if (isSelected) { + setPerspective('drafts') + } else { + setPerspective(releaseId) + } + }, [isSelected, releaseId, setPerspective]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx new file mode 100644 index 00000000000..7585789f1e9 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx @@ -0,0 +1,80 @@ +/* eslint-disable no-nested-ternary */ +import {Card, Flex} from '@sanity/ui' +import {useMemo} from 'react' + +import {isReleaseScheduledOrScheduling, type ReleaseDocument} from '../../index' +import {ReleasePublishAllButton} from '../components/releaseCTAButtons/ReleasePublishAllButton' +import {ReleaseRevertButton} from '../components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton' +import {ReleaseScheduleButton} from '../components/releaseCTAButtons/ReleaseScheduleButton' +import {ReleaseUnscheduleButton} from '../components/releaseCTAButtons/ReleaseUnscheduleButton' +import {ReleaseMenuButton} from '../components/ReleaseMenuButton/ReleaseMenuButton' +import {type ReleaseEvent} from './events/types' +import {ReleaseStatusItems} from './ReleaseStatusItems' +import {type DocumentInRelease} from './useBundleDocuments' + +export function ReleaseDashboardFooter(props: { + documents: DocumentInRelease[] + release: ReleaseDocument + events: ReleaseEvent[] +}) { + const {documents, release, events} = props + + const releaseActionButton = useMemo(() => { + if (release.state === 'archived') return null + + if (isReleaseScheduledOrScheduling(release)) { + return ( + + ) + } + + if (release.state === 'active') { + if (release.metadata.releaseType === 'scheduled') { + return ( + + ) + } + + return ( + + ) + } + + if (release.state === 'published') { + return ( + + ) + } + + return null + }, [documents, release]) + + return ( + + + + + + + + + + {releaseActionButton} + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardHeader.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardHeader.tsx new file mode 100644 index 00000000000..6c81b4da6aa --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardHeader.tsx @@ -0,0 +1,87 @@ +import {ChevronRightIcon, RestoreIcon} from '@sanity/icons' +import { + Box, + Breadcrumbs, + // eslint-disable-next-line no-restricted-imports + Button, // Custom button with a different textWeight, consider adding textWeight to the shared + Flex, + Text, +} from '@sanity/ui' +import {type Dispatch, type SetStateAction, useCallback} from 'react' +import {useRouter} from 'sanity/router' + +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../index' +import {type ReleaseInspector} from './ReleaseDetail' + +export function ReleaseDashboardHeader(props: { + inspector: ReleaseInspector | undefined + release: ReleaseDocument + setInspector: Dispatch> +}) { + const {inspector, release, setInspector} = props + const title = release.metadata.title + const {t} = useTranslation(releasesLocaleNamespace) + const {t: tCore} = useTranslation() + const router = useRouter() + const handleNavigateToReleasesList = useCallback(() => { + router.navigate({}) + }, [router]) + + const handleActivityClick = useCallback(() => { + setInspector((prev) => (prev === 'activity' ? undefined : 'activity')) + }, [setInspector]) + + const handleTitleClick = useCallback(() => { + // TODO: Focus on the title when clicked once it's editable + }, []) + + return ( + + + + + + + + } + > + + + + + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx new file mode 100644 index 00000000000..4cc220f12ad --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx @@ -0,0 +1,164 @@ +import {Card, Container, Flex, Heading, Stack} from '@sanity/ui' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {type RouterContextValue, useRouter} from 'sanity/router' + +import {LoadingBlock} from '../../../components' +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {useActiveReleases} from '../../store/useActiveReleases' +import {useArchivedReleases} from '../../store/useArchivedReleases' +import {type ReleasesRouterState} from '../../types/router' +import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' +import {useReleaseHistory} from './documentTable/useReleaseHistory' +import {useReleaseEvents} from './events/useReleaseEvents' +import {ReleaseDashboardActivityPanel} from './ReleaseDashboardActivityPanel' +import {ReleaseDashboardDetails} from './ReleaseDashboardDetails' +import {ReleaseDashboardFooter} from './ReleaseDashboardFooter' +import {ReleaseDashboardHeader} from './ReleaseDashboardHeader' +import {ReleaseReview} from './ReleaseReview' +import {ReleaseSummary} from './ReleaseSummary' +import {useBundleDocuments} from './useBundleDocuments' + +export type ReleaseInspector = 'activity' + +const SUPPORTED_SCREENS = ['summary', 'review'] as const +export type ReleaseView = (typeof SUPPORTED_SCREENS)[number] + +const getActiveView = (router: RouterContextValue): ReleaseView => { + const activeView = Object.fromEntries(router.state._searchParams || []).screen as ReleaseView + if (typeof activeView !== 'string' || !activeView || !SUPPORTED_SCREENS.includes(activeView)) { + return 'summary' + } + return activeView +} + +export const ReleaseDetail = () => { + const router = useRouter() + const [inspector, setInspector] = useState(undefined) + const {t} = useTranslation(releasesLocaleNamespace) + const activeView = getActiveView(router) + + const {releaseId: releaseIdRaw}: ReleasesRouterState = router.state + const releaseId = decodeURIComponent(releaseIdRaw || '') + const {data, loading} = useActiveReleases() + const {data: archivedReleases} = useArchivedReleases() + + const {loading: documentsLoading, results} = useBundleDocuments(releaseId) + const releaseEvents = useReleaseEvents(releaseId) + + const documentIds = results.map((result) => result.document?._id) + const history = useReleaseHistory(documentIds, releaseId) + + const releaseInDetail = data + .concat(archivedReleases) + .find((candidate) => getReleaseIdFromReleaseDocumentId(candidate._id) === releaseId) + + const navigateToReview = useCallback(() => { + router.navigate({ + ...router.state, + _searchParams: [['screen', 'review']], + }) + }, [router]) + + const navigateToSummary = useCallback(() => { + router.navigate({ + ...router.state, + _searchParams: [], + }) + }, [router]) + + // review screen will not be available once published + // so redirect to summary screen + useEffect(() => { + if (activeView === 'review' && releaseInDetail?.publishAt) { + navigateToSummary() + } + }, [activeView, releaseInDetail?.publishAt, navigateToSummary]) + + const scrollContainerRef = useRef(null) + + const detailContent = useMemo(() => { + if (documentsLoading) { + return + } + if (!releaseInDetail) return null + + if (activeView === 'summary') { + return ( + + ) + } + if (activeView === 'review') { + // This screen needs to be confirmed, is not part of the prototype yet, maybe it could be removed... + return ( + + ) + } + return null + }, [activeView, releaseInDetail, documentsLoading, history.documentsHistory, results, t]) + + if (loading) { + return ( + + ) + } + + if (releaseInDetail) { + return ( + + + + + + + + + + {detailContent} + + + + + + + + + ) + } + + return ( + + + + {t('not-found', {releaseId})} + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDetailsEditor.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDetailsEditor.tsx new file mode 100644 index 00000000000..614f62e4c01 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDetailsEditor.tsx @@ -0,0 +1,25 @@ +import {useCallback, useState} from 'react' + +import {TitleDescriptionForm} from '../../components/dialog/TitleDescriptionForm' +import {type EditableReleaseDocument, type ReleaseDocument, useReleaseOperations} from '../../index' + +export function ReleaseDetailsEditor({release}: {release: ReleaseDocument}): React.JSX.Element { + const {updateRelease} = useReleaseOperations() + const [timer, setTimer] = useState(undefined) + + const handleOnChange = useCallback( + (changedValue: EditableReleaseDocument) => { + clearTimeout(timer) + + /** @todo I wasn't able to get this working with the debouncer that we use in other parts */ + const newTimer = setTimeout(() => { + updateRelease(changedValue) + }, 200) + + setTimer(newTimer) + }, + [timer, updateRelease], + ) + + return +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseReview.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseReview.tsx new file mode 100644 index 00000000000..97d140de824 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseReview.tsx @@ -0,0 +1,121 @@ +import {SearchIcon} from '@sanity/icons' +import {Box, Container, Flex, Text, TextInput} from '@sanity/ui' +import {useVirtualizer} from '@tanstack/react-virtual' +import {type RefObject, useCallback, useMemo, useState} from 'react' +import {styled} from 'styled-components' + +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {type DocumentHistory} from './documentTable/useReleaseHistory' +import {DocumentDiffContainer} from './review/DocumentDiffContainer' +import {type DocumentInRelease} from './useBundleDocuments' + +const InputContainer = styled(Container)` + margin: 0; +` +const VirtualizerRoot = styled.div` + position: relative; + width: 100%; +` + +const VirtualizerTrack = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; +` + +// Estimation of a document with 1 change +const REVIEW_ITEM_ESTIMATED_HEIGHT = 140 + +export function ReleaseReview({ + documents, + release, + documentsHistory, + scrollContainerRef, +}: { + documents: DocumentInRelease[] + release: ReleaseDocument + documentsHistory: Record + scrollContainerRef: RefObject +}) { + const [searchTerm, setSearchTerm] = useState('') + const [expandedItems, setIsExpandedItems] = useState>({}) + + const toggleIsExpanded = useCallback((documentId: string) => { + setIsExpandedItems((prev) => { + if (typeof prev[documentId] === 'boolean') { + return {...prev, [documentId]: !prev[documentId]} + } + return {...prev, [documentId]: false} + }) + }, []) + + const filteredList = useMemo(() => { + return documents.filter(({previewValues, document}) => { + const fallbackTitle = typeof document.title === 'string' ? document.title : 'Untitled' + const title = + typeof previewValues.values.title === 'string' ? previewValues.values.title : fallbackTitle + return title.toLowerCase().includes(searchTerm.toLowerCase()) + }) + }, [searchTerm, documents]) + + const virtualizer = useVirtualizer({ + count: filteredList.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => REVIEW_ITEM_ESTIMATED_HEIGHT, + overscan: 4, + }) + const items = virtualizer.getVirtualItems() + const {t} = useTranslation(releasesLocaleNamespace) + + return ( + + + + {t('changes-published-docs.title')} + + + setSearchTerm(event.currentTarget.value)} + onClear={() => setSearchTerm('')} + clearButton={!!searchTerm} + /> + + + + + {items.map((virtualRow) => { + const item = filteredList[virtualRow.index] + const documentId = item.document._id + + return ( + + toggleIsExpanded(documentId)} + /> + + ) + })} + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx new file mode 100644 index 00000000000..b7c62b1024e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx @@ -0,0 +1,75 @@ +import {Flex} from '@sanity/ui' +import {useMemo} from 'react' + +import {AvatarSkeleton, RelativeTime, UserAvatar} from '../../../components' +import {useTranslation} from '../../../i18n' +import {isNonNullable} from '../../../util/isNonNullable' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {StatusItem} from '../components/StatusItem' +import { + isArchiveReleaseEvent, + isCreateReleaseEvent, + isPublishReleaseEvent, + isUnarchiveReleaseEvent, + type ReleaseEvent, +} from './events/types' + +const STATUS_TITLE_I18N = { + createRelease: 'footer.status.created', + publishRelease: 'footer.status.published', + archiveRelease: 'footer.status.archived', + unarchiveRelease: 'footer.status.unarchived', +} +export function ReleaseStatusItems({ + events, + release, +}: { + events: ReleaseEvent[] + release: ReleaseDocument +}) { + const {t} = useTranslation(releasesLocaleNamespace) + const footerEvents = useMemo(() => { + const createEvent = events.find(isCreateReleaseEvent) + const extraEvent = events.find( + (event) => + isPublishReleaseEvent(event) || + isArchiveReleaseEvent(event) || + isUnarchiveReleaseEvent(event), + ) + return [createEvent, extraEvent].filter(isNonNullable) + }, [events]) + + if (!footerEvents.length) { + return ( + + } + text={ + <> + {t(STATUS_TITLE_I18N.createRelease)}{' '} + + > + } + /> + + ) + } + return ( + + {footerEvents.map((event) => ( + } + text={ + <> + {t(STATUS_TITLE_I18N[event.type])}{' '} + + > + } + /> + ))} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseSummary.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseSummary.tsx new file mode 100644 index 00000000000..a3a62620774 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseSummary.tsx @@ -0,0 +1,111 @@ +import {AddIcon} from '@sanity/icons' +import {Card, Container} from '@sanity/ui' +import {type RefObject, useCallback, useMemo, useState} from 'react' + +import {Button} from '../../../../ui-components' +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {Table} from '../components/Table/Table' +import {AddDocumentSearch} from './AddDocumentSearch' +import {DocumentActions} from './documentTable/DocumentActions' +import {getDocumentTableColumnDefs} from './documentTable/DocumentTableColumnDefs' +import {type DocumentHistory} from './documentTable/useReleaseHistory' +import {type DocumentInRelease} from './useBundleDocuments' + +export type DocumentWithHistory = DocumentInRelease & { + history: DocumentHistory | undefined + // TODO: Get this value from the document, it can be calculated by checking if there is a corresponding document with no version attached + isAdded?: boolean +} +export type BundleDocumentRow = DocumentWithHistory + +export interface ReleaseSummaryProps { + documents: DocumentInRelease[] + documentsHistory: Record + scrollContainerRef: RefObject + release: ReleaseDocument +} + +export function ReleaseSummary(props: ReleaseSummaryProps) { + const {documents, documentsHistory, release, scrollContainerRef} = props + const [openAddDocumentDialog, setAddDocumentDialog] = useState(false) + + const {t} = useTranslation(releasesLocaleNamespace) + + const aggregatedData = useMemo( + () => + documents.map((document) => ({ + ...document, + history: documentsHistory[document.document._id], + })), + [documents, documentsHistory], + ) + + const renderRowActions = useCallback( + (rowProps: {datum: BundleDocumentRow | unknown}) => { + if (release.state !== 'active') return null + + return ( + + ) + }, + [release.metadata.title, release.state], + ) + + const documentTableColumnDefs = useMemo( + () => getDocumentTableColumnDefs(release._id, release.state, t), + [release._id, release.state, t], + ) + + const filterRows = useCallback( + (data: DocumentWithHistory[], searchTerm: string) => + data.filter(({previewValues}) => { + const title = + typeof previewValues.values.title === 'string' ? previewValues.values.title : 'Untitled' + return title.toLowerCase().includes(searchTerm.toLowerCase()) + }), + [], + ) + + const closeAddDialog = useCallback(() => { + setAddDocumentDialog(false) + }, []) + + return ( + + + data={aggregatedData} + emptyState={t('summary.no-documents')} + // eslint-disable-next-line @sanity/i18n/no-attribute-string-literals + rowId="document._id" + columnDefs={documentTableColumnDefs} + rowActions={renderRowActions} + searchFilter={filterRows} + scrollContainerRef={scrollContainerRef} + defaultSort={{column: 'search', direction: 'asc'}} + /> + {release.state === 'active' && ( + + + setAddDocumentDialog(true)} + text={t('action.add-document')} + /> + + + )} + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseTypePicker.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseTypePicker.tsx new file mode 100644 index 00000000000..a237cca6464 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseTypePicker.tsx @@ -0,0 +1,296 @@ +import {LockIcon} from '@sanity/icons' +import {Card, Flex, Spinner, Stack, TabList, Text, useClickOutsideEvent, useToast} from '@sanity/ui' +import {format, isBefore, isValid, parse, startOfMinute} from 'date-fns' +import {isEqual} from 'lodash' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' + +import {Button, Popover, Tab} from '../../../../ui-components' +import {MONTH_PICKER_VARIANT} from '../../../components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../components/inputs/DateInputs/calendar/types' +import {DatePicker} from '../../../components/inputs/DateInputs/DatePicker' +import {LazyTextInput} from '../../../components/inputs/DateInputs/LazyTextInput' +import {getCalendarLabels} from '../../../form/inputs/DateInputs/utils' +import {useTranslation} from '../../../i18n/hooks/useTranslation' +import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' +import {ReleaseAvatar} from '../../components/ReleaseAvatar' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument, type ReleaseType} from '../../store' +import {useReleaseOperations} from '../../store/useReleaseOperations' +import {getIsScheduledDateInPast} from '../../util/getIsScheduledDateInPast' +import {getReleaseTone} from '../../util/getReleaseTone' +import {getPublishDateFromRelease, isReleaseScheduledOrScheduling} from '../../util/util' + +const dateInputFormat = 'PP HH:mm' + +export function ReleaseTypePicker(props: {release: ReleaseDocument}): React.JSX.Element { + const {release} = props + + const popoverRef = useRef(null) + const buttonRef = useRef(null) + const inputRef = useRef(null) + const datePickerRef = useRef(null) + + const {t: tRelease} = useTranslation(releasesLocaleNamespace) + const {t} = useTranslation() + const {updateRelease} = useReleaseOperations() + const toast = useToast() + + const [open, setOpen] = useState(false) + const [releaseType, setReleaseType] = useState(release.metadata.releaseType) + const publishDate = useMemo(() => getPublishDateFromRelease(release), [release]) + const [isUpdating, setIsUpdating] = useState(false) + const [isIntendedScheduleDateInPast, setIsIntendedScheduleDateInPast] = useState( + publishDate && isBefore(new Date(publishDate), new Date()), + ) + + const [intendedPublishAt, setIntendedPublishAt] = useState( + publishDate ? new Date(publishDate) : undefined, + ) + const updatedDate = intendedPublishAt?.toISOString() + + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const [currentTimezone, setCurrentTimezone] = useState(timeZone.name) + + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(t), [t]) + + const close = useCallback(() => { + // a bit of a hack to make sure the timezone dialog is not immediately closed on out + // the dialog itself is in the Calendar component who is basically unrealted to this one + const dialog = document.querySelector('#time-zone') + + if (open && !dialog) { + const newRelease = { + ...release, + metadata: {...release.metadata, intendedPublishAt: updatedDate, releaseType}, + } + + if (!isEqual(newRelease, release)) { + /** + * If in past, the reset type and intendedPublish to the actual release values + * and discard the changes made + */ + if (getIsScheduledDateInPast(newRelease)) { + setReleaseType(release.metadata.releaseType) + setIntendedPublishAt( + release.metadata.intendedPublishAt + ? new Date(release.metadata.intendedPublishAt) + : undefined, + ) + + toast.push({ + closable: true, + status: 'warning', + title: tRelease('schedule-dialog.publish-date-in-past-warning'), + }) + } else { + setIsUpdating(true) + updateRelease(newRelease).then(() => { + setIsUpdating(false) + }) + } + } + + setOpen(false) + } + }, [open, release, updatedDate, releaseType, toast, tRelease, updateRelease]) + + useClickOutsideEvent(close, () => [ + popoverRef.current, + buttonRef.current, + inputRef.current, + datePickerRef.current, + ]) + + useEffect(() => { + /** makes sure to wait for the useTimezone has enough time to update + * and based on that it will update the input value to the current timezone + */ + if (timeZone.name !== currentTimezone) { + setCurrentTimezone(timeZone.name) + if (updatedDate && isValid(new Date(updatedDate))) { + const currentZoneDate = utcToCurrentZoneDate(new Date(updatedDate)) + setIntendedPublishAt(currentZoneDate) + } + } + }, [currentTimezone, intendedPublishAt, timeZone, updatedDate, utcToCurrentZoneDate]) + + const isPublishDateInPast = !!publishDate && isBefore(new Date(publishDate), new Date()) + const isReleaseScheduled = isReleaseScheduledOrScheduling(release) + + const publishDateLabel = useMemo(() => { + if (release.state === 'published') { + if (isPublishDateInPast && release.publishAt) + return tRelease('dashboard.details.published-on', { + date: format(new Date(publishDate), 'MMM d, yyyy, pp'), + }) + + return tRelease('dashboard.details.published-asap') + } + + if (releaseType === 'asap') return t('release.type.asap') + if (releaseType === 'undecided') return t('release.type.undecided') + const labelDate = publishDate || intendedPublishAt + if (!labelDate) return null + + return format(new Date(labelDate), `PPpp`) + }, [ + intendedPublishAt, + isPublishDateInPast, + publishDate, + release.publishAt, + release.state, + releaseType, + t, + tRelease, + ]) + + const handleButtonReleaseTypeChange = useCallback((pickedReleaseType: ReleaseType) => { + setReleaseType(pickedReleaseType) + const nextPublishAt = pickedReleaseType === 'scheduled' ? startOfMinute(new Date()) : undefined + setIntendedPublishAt(nextPublishAt) + setIsIntendedScheduleDateInPast(true) + }, []) + + const handlePublishAtCalendarChange = useCallback((date: Date | null) => { + if (!date) return + + const cleanDate = startOfMinute(new Date(date)) + setIsIntendedScheduleDateInPast(isBefore(cleanDate, new Date())) + setIntendedPublishAt(cleanDate) + }, []) + + const handlePublishAtInputChange = useCallback((event: React.FocusEvent) => { + const parsedDate = parse(event.currentTarget.value, dateInputFormat, new Date()) + + if (isValid(parsedDate)) { + setIsIntendedScheduleDateInPast(isBefore(parsedDate, new Date())) + + setIntendedPublishAt(startOfMinute(parsedDate)) + } + }, []) + + const handleOnPickerClick = () => { + if (open) close() + else setOpen(true) + } + + const PopoverContent = () => { + return ( + + + handleButtonReleaseTypeChange('asap')} + label={t('release.type.asap')} + selected={releaseType === 'asap'} + /> + handleButtonReleaseTypeChange('scheduled')} + selected={releaseType === 'scheduled'} + label={t('release.type.scheduled')} + /> + handleButtonReleaseTypeChange('undecided')} + selected={releaseType === 'undecided'} + label={t('release.type.undecided')} + /> + + {releaseType === 'scheduled' && ( + <> + {isIntendedScheduleDateInPast && ( + + {tRelease('schedule-dialog.publish-date-in-past-warning')} + + )} + + + > + )} + + ) + } + + const tone = + release.state === 'published' + ? 'positive' + : getReleaseTone({...release, metadata: {...release.metadata, releaseType}}) + + const labelContent = useMemo( + () => ( + + {isUpdating ? ( + + ) : ( + + )} + + + {publishDateLabel} + + + {isReleaseScheduled && ( + + + + )} + + ), + [isReleaseScheduled, isUpdating, publishDateLabel, tone], + ) + + return ( + } + open={open} + padding={1} + placement="bottom-start" + ref={popoverRef} + > + {release.state === 'published' ? ( + + {labelContent} + + ) : ( + + {labelContent} + + )} + + ) +} 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)} + /> + + + {t('menu.group.when-releasing')} + + 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 ( + + + + + + + + {history?.createdBy && ( + } + text={ + + ( + + ), + }} + /> + + } + /> + )} + {history?.lastEditedBy && ( + } + text={ + + ( + + ), + }} + /> + + } + /> + )} + + + {history?.editors?.map((userId) => )} + + + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/useBundleDocuments.ts b/packages/sanity/src/core/releases/tool/detail/useBundleDocuments.ts new file mode 100644 index 00000000000..bd4465b3284 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/useBundleDocuments.ts @@ -0,0 +1,294 @@ +import { + isValidationErrorMarker, + type PreviewValue, + type SanityDocument, + type Schema, +} from '@sanity/types' +import {uuid} from '@sanity/uuid' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {combineLatest, from, type Observable, of} from 'rxjs' +import { + distinctUntilChanged, + filter, + map, + mergeMap, + startWith, + switchAll, + switchMap, + take, + toArray, +} from 'rxjs/operators' +import {mergeMapArray} from 'rxjs-mergemap-array' + +import {useSchema} from '../../../hooks' +import {type LocaleSource} from '../../../i18n/types' +import { + type DocumentPreviewStore, + getPreviewValueWithFallback, + prepareForPreview, +} from '../../../preview' +import {useSource} from '../../../studio' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' +import {getPublishedId} from '../../../util/draftUtils' +import {validateDocumentWithReferences, type ValidationStatus} from '../../../validation' +import { + getReleaseIdFromReleaseDocumentId, + type ReleaseDocument, + useDocumentPreviewStore, +} from '../../index' +import {useReleasesStore} from '../../store/useReleasesStore' +import {getReleaseDocumentIdFromReleaseId} from '../../util/getReleaseDocumentIdFromReleaseId' + +export interface DocumentValidationStatus extends ValidationStatus { + hasError: boolean +} + +export interface DocumentInRelease { + memoKey: string + document: SanityDocument & {publishedDocumentExists: boolean} + validation: DocumentValidationStatus + previewValues: {isLoading: boolean; values: ReturnType} +} + +type ReleaseDocumentsObservableResult = Observable<{loading: boolean; results: DocumentInRelease[]}> + +const getActiveReleaseDocumentsObservable = ({ + schema, + documentPreviewStore, + i18n, + getClient, + releaseId, +}: { + schema: Schema + documentPreviewStore: DocumentPreviewStore + i18n: LocaleSource + getClient: ReturnType['getClient'] + releaseId: string +}): ReleaseDocumentsObservableResult => { + const client = getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const observableClient = client.observable + + const groqFilter = `_id in path("versions.${releaseId}.*")` + + return documentPreviewStore.unstable_observeDocumentIdSet(groqFilter).pipe( + map((state) => (state.documentIds || []) as string[]), + mergeMapArray((id) => { + const ctx = { + observeDocument: documentPreviewStore.unstable_observeDocument, + observeDocumentPairAvailability: + documentPreviewStore.unstable_observeDocumentPairAvailability, + i18n, + getClient, + schema, + } + + const document$ = documentPreviewStore.unstable_observeDocument(id).pipe( + filter(Boolean), + switchMap((doc) => + observableClient + .fetch( + `*[_id in path("${getPublishedId(doc._id)}")]{_id}`, + {}, + {tag: 'release-documents.check-existing'}, + ) + .pipe( + switchMap((publishedDocumentExists) => + of({ + ...doc, + publishedDocumentExists: !!publishedDocumentExists.length, + }), + ), + ), + ), + ) + const validation$ = validateDocumentWithReferences(ctx, document$).pipe( + map((validationStatus) => ({ + ...validationStatus, + hasError: validationStatus.validation.some((marker) => isValidationErrorMarker(marker)), + })), + ) + + const previewValues$ = document$.pipe( + map((document) => { + const schemaType = schema.get(document._type) + if (!schemaType) { + console.error( + `Schema type not found for document type ${document._type} (document ID: ${document._id})`, + ) + return of({ + isLoading: false, + values: { + _id: document._id, + title: `Document type "${document._type}" not found`, + _createdAt: document._createdAt, + _updatedAt: document._updatedAt, + } satisfies PreviewValue, + }) + } + + return documentPreviewStore.observeForPreview(document, schemaType).pipe( + map((version) => ({ + isLoading: false, + values: prepareForPreview( + getPreviewValueWithFallback({ + value: document, + version: version.snapshot, + perspective: releaseId, + }), + schemaType, + ), + })), + startWith({isLoading: true, values: {}}), + ) + }), + switchAll(), + ) + + return combineLatest([document$, validation$, previewValues$]).pipe( + map(([document, validation, previewValues]) => ({ + document, + validation, + previewValues, + memoKey: uuid(), + })), + ) + }), + map((results) => ({loading: false, results})), + ) +} + +const getPublishedArchivedReleaseDocumentsObservable = ({ + getClient, + schema, + documentPreviewStore, + release, +}: { + getClient: ReturnType['getClient'] + schema: Schema + documentPreviewStore: DocumentPreviewStore + release: ReleaseDocument +}): ReleaseDocumentsObservableResult => { + const client = getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const observableClient = client.observable + const dataset = client.config().dataset + + if (!release.finalDocumentStates?.length) return of({loading: false, results: []}) + + return from(release.finalDocumentStates || []).pipe( + mergeMap(({id: documentId}) => { + const document$ = observableClient + .request<{documents: DocumentInRelease['document'][]}>({ + url: `/data/history/${dataset}/documents/${documentId}?lastRevision=true`, + }) + .pipe(map(({documents: [document]}) => document)) + + const previewValues$ = document$.pipe( + switchMap((document) => { + const schemaType = schema.get(document._type) + if (!schemaType) { + throw new Error(`Schema type not found for document type ${document._type}`) + } + + return documentPreviewStore.observeForPreview(document, schemaType).pipe( + take(1), + map((version) => ({ + isLoading: false, + values: prepareForPreview( + getPreviewValueWithFallback({ + value: document, + version: version.snapshot || document, + perspective: getReleaseIdFromReleaseDocumentId(release._id), + }), + schemaType, + ), + })), + startWith({isLoading: true, values: {}}), + ) + }), + filter(({isLoading}) => !isLoading), + ) + + return combineLatest([document$, previewValues$]).pipe( + map(([document, previewValues]) => ({ + document, + previewValues, + memoKey: uuid(), + validation: {validation: [], hasError: false, isValidating: false}, + })), + ) + }), + toArray(), + map((results) => ({ + loading: false, + results, + })), + ) +} + +const getReleaseDocumentsObservable = ({ + schema, + documentPreviewStore, + getClient, + releaseId, + i18n, + releasesState$, +}: { + schema: Schema + documentPreviewStore: DocumentPreviewStore + getClient: ReturnType['getClient'] + releaseId: string + i18n: LocaleSource + releasesState$: ReturnType['state$'] +}): ReleaseDocumentsObservableResult => + releasesState$.pipe( + map((releasesState) => + releasesState.releases.get(getReleaseDocumentIdFromReleaseId(releaseId)), + ), + filter(Boolean), + distinctUntilChanged((prev, next) => prev._rev === next._rev), + switchMap((release) => { + if (release.state === 'published' || release.state === 'archived') { + return getPublishedArchivedReleaseDocumentsObservable({ + schema, + documentPreviewStore, + getClient, + release, + }) + } + + return getActiveReleaseDocumentsObservable({ + schema, + documentPreviewStore, + i18n, + getClient, + releaseId, + }) + }), + startWith({loading: true, results: []}), + ) + +export function useBundleDocuments(releaseId: string): { + loading: boolean + results: DocumentInRelease[] +} { + const documentPreviewStore = useDocumentPreviewStore() + const {getClient, i18n} = useSource() + const schema = useSchema() + const {state$: releasesState$} = useReleasesStore() + + const releaseDocumentsObservable = useMemo( + () => + getReleaseDocumentsObservable({ + schema, + documentPreviewStore, + getClient, + releaseId, + i18n, + releasesState$, + }), + [schema, documentPreviewStore, getClient, releaseId, i18n, releasesState$], + ) + + return useObservable(releaseDocumentsObservable, {loading: true, results: []}) +} diff --git a/packages/sanity/src/core/releases/tool/overview/ReleaseCalendarFilter.tsx b/packages/sanity/src/core/releases/tool/overview/ReleaseCalendarFilter.tsx new file mode 100644 index 00000000000..fd3a8559c40 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/ReleaseCalendarFilter.tsx @@ -0,0 +1,75 @@ +import {CloseIcon} from '@sanity/icons' +import {format} from 'date-fns' +import {AnimatePresence, motion} from 'framer-motion' +import {useMemo, useState} from 'react' + +import {Button} from '../../../../ui-components' +import {CalendarDay} from '../../../components/inputs/DateFilters/calendar/CalendarDay' +import {type CalendarProps} from '../../../components/inputs/DateFilters/calendar/CalendarFilter' +import {useActiveReleases} from '../../store/useActiveReleases' +import {useTimezoneAdjustedDateTimeRange} from './useTimezoneAdjustedDateTimeRange' + +export const ReleaseCalendarFilterDay: CalendarProps['renderCalendarDay'] = (props) => { + const {data: releases} = useActiveReleases() + const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange() + + const {date} = props + + const [startOfDayForTimeZone, endOfDayForTimeZone] = getTimezoneAdjustedDateTimeRange(date) + + const dayHasReleases = releases?.some((release) => { + const releasePublishAt = release.publishAt || release.metadata.intendedPublishAt + if (!releasePublishAt) return false + + const publishDateUTC = new Date(releasePublishAt) + + return ( + release.metadata.releaseType === 'scheduled' && + publishDateUTC >= startOfDayForTimeZone && + publishDateUTC <= endOfDayForTimeZone + ) + }) + + return +} + +const MotionButton = motion.create(Button) + +export const DateFilterButton = ({ + filterDate, + onClear, +}: { + filterDate: Date + onClear: () => void +}) => { + const [isExiting, setIsExiting] = useState(false) + + const handleOnExitComplete = useMemo( + () => () => { + setIsExiting(false) + onClear() + }, + [onClear], + ) + + if (!filterDate) return null + + return ( + + {!isExiting && ( + setIsExiting(true)} + selected + text={format(filterDate, 'PPP')} + /> + )} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx new file mode 100644 index 00000000000..d3814406139 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx @@ -0,0 +1,347 @@ +import {AddIcon, ChevronDownIcon, EarthGlobeIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports +import {Box, Button, type ButtonMode, Card, Container, Flex, Stack, Text} from '@sanity/ui' +import {format, isSameDay} from 'date-fns' +import {AnimatePresence, motion} from 'framer-motion' +import {type MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {type SearchParam, useRouter} from 'sanity/router' + +import {Button as StudioButton, Tooltip} from '../../../../ui-components' +import {CalendarFilter} from '../../../components/inputs/DateFilters/calendar/CalendarFilter' +import {useTranslation} from '../../../i18n' +import {usePerspective} from '../../../perspective/usePerspective' +import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone' +import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' +import {CreateReleaseDialog} from '../../components/dialog/CreateReleaseDialog' +import {releasesLocaleNamespace} from '../../i18n' +import {isReleaseDocument, type ReleaseDocument} from '../../store/types' +import {useActiveReleases} from '../../store/useActiveReleases' +import {useArchivedReleases} from '../../store/useArchivedReleases' +import {type ReleasesMetadata, useReleasesMetadata} from '../../store/useReleasesMetadata' +import {getReleaseTone} from '../../util/getReleaseTone' +import {ReleaseMenuButton} from '../components/ReleaseMenuButton/ReleaseMenuButton' +import {Table, type TableRowProps} from '../components/Table/Table' +import {type TableSort} from '../components/Table/TableProvider' +import { + DATE_SEARCH_PARAM_KEY, + getInitialFilterDate, + getInitialReleaseGroupMode, + GROUP_SEARCH_PARAM_KEY, + type Mode, +} from './queryParamUtils' +import {DateFilterButton, ReleaseCalendarFilterDay} from './ReleaseCalendarFilter' +import {releasesOverviewColumnDefs} from './ReleasesOverviewColumnDefs' +import {useTimezoneAdjustedDateTimeRange} from './useTimezoneAdjustedDateTimeRange' + +const MotionStudioButton = motion.create(StudioButton) +const MotionUiButton = motion.create(Button) + +const DATE_SEARCH_PARAM_VALUE_FORMAT = 'yyyy-MM-dd' + +export interface TableRelease extends ReleaseDocument { + documentsMetadata?: ReleasesMetadata + isDeleted?: boolean +} + +const DEFAULT_RELEASES_OVERVIEW_SORT: TableSort = {column: 'publishAt', direction: 'asc'} + +export function ReleasesOverview() { + const {data: releases, loading: loadingReleases} = useActiveReleases() + const {data: archivedReleases} = useArchivedReleases() + + const router = useRouter() + const [releaseGroupMode, setReleaseGroupMode] = useState(getInitialReleaseGroupMode(router)) + const [releaseFilterDate, setReleaseFilterDate] = useState( + getInitialFilterDate(router), + ) + const [isCreateReleaseDialogOpen, setIsCreateReleaseDialogOpen] = useState(false) + const releaseIds = useMemo(() => releases.map((release) => release._id), [releases]) + const {data: releasesMetadata, loading: loadingReleasesMetadata} = useReleasesMetadata(releaseIds) + const loading = loadingReleases || (loadingReleasesMetadata && !releasesMetadata) + const loadingTableData = loading || (!releasesMetadata && Boolean(releaseIds.length)) + const {t} = useTranslation(releasesLocaleNamespace) + const {t: tCore} = useTranslation() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const {selectedPerspective} = usePerspective() + const {DialogTimeZone, dialogProps, dialogTimeZoneShow} = useDialogTimeZone() + const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange() + + const getRowProps = useCallback( + (datum: TableRelease): Partial => + datum.isDeleted + ? {tone: 'transparent'} + : { + tone: + isReleaseDocument(selectedPerspective) && selectedPerspective._id === datum._id + ? getReleaseTone(datum) + : 'default', + }, + [selectedPerspective], + ) + + const scrollContainerRef = useRef(null) + + const hasReleases = releases.length > 0 || archivedReleases.length > 0 + const loadingOrHasReleases = loading || hasReleases + + const tableReleases = useMemo(() => { + if (!hasReleases || !releasesMetadata) return [] + + return [ + ...releases.map((release) => ({ + ...release, + publishAt: release.publishAt || release.metadata.intendedPublishAt, + documentsMetadata: releasesMetadata[release._id] || {}, + })), + ] + }, [hasReleases, releasesMetadata, releases]) + + // switch to open mode if on archived mode and there are no archived releases + useEffect(() => { + if (releaseGroupMode === 'archived' && !loadingReleases && !archivedReleases.length) { + setReleaseGroupMode('open') + } + }, [releaseGroupMode, archivedReleases.length, loadingReleases]) + + const handleReleaseGroupModeChange = useCallback>( + ({currentTarget: {value: groupMode}}) => { + setReleaseGroupMode(groupMode as Mode) + }, + [], + ) + + const handleSelectFilterDate = useCallback( + (date?: Date) => + setReleaseFilterDate((prevFilterDate) => { + if (!date) return undefined + + const timeZoneAdjustedDate = utcToCurrentZoneDate(date) + + return prevFilterDate && isSameDay(prevFilterDate, timeZoneAdjustedDate) + ? undefined + : timeZoneAdjustedDate + }), + [utcToCurrentZoneDate], + ) + + const clearFilterDate = useCallback(() => { + setReleaseFilterDate(undefined) + setReleaseGroupMode('open') + }, []) + + useEffect(() => { + const getSearchParams: () => SearchParam[] = () => { + if (releaseFilterDate) + return [[DATE_SEARCH_PARAM_KEY, format(releaseFilterDate, DATE_SEARCH_PARAM_VALUE_FORMAT)]] + if (releaseGroupMode) return [[GROUP_SEARCH_PARAM_KEY, releaseGroupMode]] + return [] + } + + router.navigate({ + _searchParams: getSearchParams(), + }) + }, [releaseFilterDate, releaseGroupMode, router]) + + const [hasMounted, setHasMounted] = useState(false) + + useEffect(() => { + setHasMounted(true) + }, []) + + const currentArchivedPicker = useMemo(() => { + const groupModeButtonBaseProps = { + disabled: loading || !hasReleases, + mode: 'bleed' as ButtonMode, + padding: 2, + ...(hasMounted + ? { + initial: {opacity: 0}, + animate: {opacity: 1}, + transition: {duration: 0.4, ease: 'easeInOut'}, + } + : {}), + } + return ( + + + + + + + + + ) + }, [ + loading, + hasReleases, + hasMounted, + handleReleaseGroupModeChange, + releaseGroupMode, + t, + archivedReleases.length, + ]) + + const createReleaseButton = useMemo( + () => ( + setIsCreateReleaseDialogOpen(true)} + text={tCore('release.action.create-new')} + /> + ), + [isCreateReleaseDialogOpen, tCore], + ) + + const handleOnCreateRelease = useCallback( + (createdReleaseId: string) => { + setIsCreateReleaseDialogOpen(false) + router.navigate({releaseId: createdReleaseId}) + }, + [router], + ) + + const renderCreateReleaseDialog = () => { + if (!isCreateReleaseDialogOpen) return null + + return ( + setIsCreateReleaseDialogOpen(false)} + onSubmit={handleOnCreateRelease} + origin="release-plugin" + /> + ) + } + + const renderRowActions = useCallback( + ({datum}: {datum: TableRelease | unknown}) => { + const release = datum as TableRelease + + if (release.isDeleted) return null + + const documentsCount = + (releaseGroupMode === 'open' + ? release.documentsMetadata?.documentCount + : release.finalDocumentStates?.length) ?? 0 + + return + }, + [releaseGroupMode], + ) + + const filteredReleases = useMemo(() => { + if (!releaseFilterDate) return releaseGroupMode === 'open' ? tableReleases : archivedReleases + + const [startOfDayForTimeZone, endOfDayForTimeZone] = + getTimezoneAdjustedDateTimeRange(releaseFilterDate) + + return tableReleases.filter((release) => { + if (!release.publishAt || release.metadata.releaseType !== 'scheduled') return false + + const publishDateUTC = new Date(release.publishAt) + return publishDateUTC >= startOfDayForTimeZone && publishDateUTC <= endOfDayForTimeZone + }) + }, [ + releaseFilterDate, + releaseGroupMode, + tableReleases, + archivedReleases, + getTimezoneAdjustedDateTimeRange, + ]) + + return ( + + + + + + + + + + + + + {t('overview.title')} + + + + {loadingOrHasReleases && + (releaseFilterDate ? ( + + ) : ( + currentArchivedPicker + ))} + + + + {DialogTimeZone && } + {loadingOrHasReleases && createReleaseButton} + + + + + {!loading && !hasReleases ? ( + + + + {t('overview.description')} + + {createReleaseButton} + + + ) : ( + + // for resetting filter and sort on table when filer changed + key={releaseFilterDate ? 'by_date' : releaseGroupMode} + defaultSort={DEFAULT_RELEASES_OVERVIEW_SORT} + loading={loadingTableData} + data={filteredReleases} + columnDefs={releasesOverviewColumnDefs(t)} + emptyState={t('no-releases')} + // eslint-disable-next-line @sanity/i18n/no-attribute-string-literals + rowId="_id" + rowActions={renderRowActions} + rowProps={getRowProps} + scrollContainerRef={scrollContainerRef} + hideTableInlinePadding + /> + )} + + {renderCreateReleaseDialog()} + + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx new file mode 100644 index 00000000000..90b29971c34 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx @@ -0,0 +1,113 @@ +import {LockIcon} from '@sanity/icons' +import {Flex, Text} from '@sanity/ui' +import {type TFunction} from 'i18next' + +import {RelativeTime} from '../../../components' +import {getPublishDateFromRelease, isReleaseScheduledOrScheduling} from '../../util/util' +import {Headers} from '../components/Table/TableHeader' +import {type Column} from '../components/Table/types' +import {ReleaseDocumentsCounter} from './columnCells/ReleaseDocumentsCounter' +import {ReleaseNameCell} from './columnCells/ReleaseName' +import {ReleaseTime} from './columnCells/ReleaseTime' +import {type TableRelease} from './ReleasesOverview' + +export const releasesOverviewColumnDefs: ( + t: TFunction<'releases', undefined>, +) => Column[] = (t) => { + return [ + { + id: 'title', + sorting: false, + width: null, + style: {minWidth: '50%', maxWidth: '50%'}, + header: ({headerProps}) => ( + + + + ), + cell: ReleaseNameCell, + }, + { + id: 'publishAt', + sorting: true, + sortTransform: (release) => { + if (release.metadata.releaseType === 'undecided') return Infinity + + const publishDate = getPublishDateFromRelease(release) + + if (release.metadata.releaseType === 'asap' || !publishDate) return 0 + return new Date(publishDate).getTime() + }, + width: 250, + header: (props) => ( + + + + ), + cell: ({cellProps, datum: release}) => ( + + + {isReleaseScheduledOrScheduling(release) && ( + + + + )} + + ), + }, + { + id: 'documentsMetadata.updatedAt', + sorting: true, + width: 150, + header: (props) => ( + + + + ), + cell: ({datum: {documentsMetadata, _updatedAt}, cellProps}) => { + const updatedAtDate = documentsMetadata?.updatedAt ?? _updatedAt + return ( + + + {updatedAtDate ? ( + + ) : ( + '-' + )} + + + ) + }, + }, + { + id: 'documentCount', + sorting: false, + width: 100, + header: ({headerProps}) => ( + + + + ), + cell: ({datum: {isDeleted, state, finalDocumentStates, documentsMetadata}, cellProps}) => ( + + {!isDeleted && ( + + )} + + ), + }, + ] +} diff --git a/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx b/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx new file mode 100644 index 00000000000..48e94c7a588 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx @@ -0,0 +1,493 @@ +import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react' +import {format, set} from 'date-fns' +import {useState} from 'react' +import {useRouter} from 'sanity/router' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getByDataUi, queryByDataUi} from '../../../../../../test/setup/customQueries' +import {setupVirtualListEnv} from '../../../../../../test/testUtils/setupVirtualListEnv' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import { + mockUsePerspective, + usePerspectiveMockReturn, +} from '../../../../perspective/__mocks__/usePerspective.mock' +import { + getLocalTimeZoneMockReturn, + mockGetLocaleTimeZone, + mockUseTimeZone, + useTimeZoneMockReturn, +} from '../../../../scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock' +import { + activeASAPRelease, + activeScheduledRelease, + activeUndecidedRelease, + archivedScheduledRelease, + publishedASAPRelease, + scheduledRelease, +} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import { + mockUseActiveReleases, + useActiveReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useActiveReleases.mock' +import { + mockUseAllReleases, + useAllReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useAllReleases.mock' +import { + mockUseArchivedReleases, + useArchivedReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useArchivedReleases.mock' +import { + mockUseReleasesMetadata, + useReleasesMetadataMockReturn, +} from '../../../store/__tests__/__mocks/useReleasesMetadata.mock' +import {type ReleaseDocument} from '../../../store/types' +import {type ReleasesMetadata} from '../../../store/useReleasesMetadata' +import {useBundleDocumentsMockReturnWithResults} from '../../detail/__tests__/__mocks__/useBundleDocuments.mock' +import {ReleasesOverview} from '../ReleasesOverview' + +const TODAY = set(new Date(), { + hours: 22, + minutes: 0, + seconds: 0, + milliseconds: 0, +}) + +vi.mock('sanity', () => ({ + SANITY_VERSION: '0.0.0', + useCurrentUser: vi.fn().mockReturnValue({user: {id: 'user-id'}}), + useTranslation: vi.fn().mockReturnValue({t: vi.fn()}), +})) + +vi.mock('../../../store/useActiveReleases', () => ({ + useActiveReleases: vi.fn(() => useActiveReleasesMockReturn), +})) + +vi.mock('../../../store/useAllReleases', () => ({ + useAllReleases: vi.fn(() => useAllReleasesMockReturn), +})) + +vi.mock('../../../store/useArchivedReleases', () => ({ + useArchivedReleases: vi.fn(() => useArchivedReleasesMockReturn), +})) + +vi.mock('../../../store/useReleasesMetadata', () => ({ + useReleasesMetadata: vi.fn(() => useReleasesMetadataMockReturn), +})) + +vi.mock('../../detail/useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturnWithResults), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: vi.fn().mockReturnValue({state: {}, navigate: vi.fn()}), +})) + +vi.mock('../../../../perspective/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +const mockedSetPerspective = vi.fn() +vi.mock('../../../../perspective/useSetPerspective', () => ({ + useSetPerspective: vi.fn(() => mockedSetPerspective), +})) + +vi.mock('../../../../scheduledPublishing/hooks/useTimeZone', async (importOriginal) => ({ + ...(await importOriginal()), + getLocalTimeZone: vi.fn(() => getLocalTimeZoneMockReturn), + default: vi.fn(() => useTimeZoneMockReturn), +})) + +const getWrapper = () => + createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + +/** + * To resolve issues with size render with Virtual list (as described + * here: https://github.com/TanStack/virtual/issues/641), must rerender + * ReleasesOverview once the exact height wrapper has mounted + */ +const TestComponent = () => { + const [hasWrapperRendered, setHasWrapperRendered] = useState(false) + const updateWrapperRendered = () => setHasWrapperRendered(true) + + return ( + + + + ) +} + +describe('ReleasesOverview', () => { + beforeEach(() => { + mockUseActiveReleases.mockRestore() + }) + + setupVirtualListEnv() + + describe('when loading releases', () => { + beforeEach(async () => { + mockUseActiveReleases.mockReturnValue({...useActiveReleasesMockReturn, loading: true}) + + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + return render(, {wrapper}) + }) + + it('does not show releases table but shows loader', () => { + expect(screen.queryByRole('table')).toBeNull() + queryByDataUi(document.body, 'Spinner') + }) + + it('does not allow for switching between history modes', async () => { + await waitFor( + () => { + screen.getByText('Open') + }, + {timeout: 4000}, + ) + expect(screen.getByText('Open').closest('button')).toBeDisabled() + expect(screen.getByText('Archived').closest('button')).toBeDisabled() + }) + + it('does show the page heading', () => { + screen.getByText('Releases') + }) + + it('allows for creating a new release', () => { + expect(screen.getByText('New release')).not.toBeDisabled() + }) + }) + + describe('when no releases are available', () => { + beforeEach(async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + return render(, {wrapper}) + }) + + it('shows a message about releases', () => { + screen.getByTestId('no-releases-info-text') + }) + + it('does not show the releases table', () => { + expect(screen.queryByRole('table')).toBeNull() + }) + + it('does not show release history mode switch', () => { + expect(screen.queryByText('Open')).toBeNull() + expect(screen.queryByText('Archived')).toBeNull() + }) + + it('shows the page heading', () => { + screen.getByText('Releases') + }) + + it('shows create new releases button', () => { + expect(screen.getByText('New release')).not.toBeDisabled() + }) + }) + + describe('when releases are loaded', () => { + const releases: ReleaseDocument[] = [ + { + ...activeScheduledRelease, + metadata: { + ...activeScheduledRelease.metadata, + intendedPublishAt: TODAY.toISOString(), + }, + }, + activeASAPRelease, + activeUndecidedRelease, + scheduledRelease, + ] + + let activeRender: ReturnType + + const rerender = async () => { + activeRender.unmount() + + const wrapper = await getWrapper() + + return render(, {wrapper}) + } + + beforeEach(async () => { + mockUseTimeZone.mockRestore() + mockUseActiveReleases.mockReturnValue({ + ...useActiveReleasesMockReturn, + data: releases, + }) + mockUseAllReleases.mockReturnValue({ + data: releases, + error: undefined, + loading: false, + }) + mockUseArchivedReleases.mockReturnValue({ + ...useArchivedReleasesMockReturn, + data: [archivedScheduledRelease, publishedASAPRelease], + }) + mockUseReleasesMetadata.mockReturnValue({ + ...useReleasesMetadataMockReturn, + data: Object.fromEntries( + releases.map((release) => [ + release._id, + { + documentCount: 1, + } as ReleasesMetadata, + ]), + ), + }) + + const wrapper = await getWrapper() + + return act(() => { + activeRender = render(, {wrapper}) + }) + }) + + it('shows each open release', () => { + const releaseRows = screen.getAllByTestId('table-row') + expect(releaseRows).toHaveLength(4) + + const [unsortedFirstRelease, unsortedSecondRelease, unsortedThirdRelease] = releaseRows + + within(unsortedFirstRelease).getByText(activeASAPRelease.metadata.title) + within(unsortedSecondRelease).getByText(scheduledRelease.metadata.title) + within(unsortedThirdRelease).getByText(activeScheduledRelease.metadata.title) + }) + + it('shows time as ASAP for asap release types', () => { + const asapReleaseRow = screen.getAllByTestId('table-row')[0] + + within(asapReleaseRow).getByText('ASAP') + }) + + it('shows time as Undecided for undecided release types', () => { + const asapReleaseRow = screen.getAllByTestId('table-row')[3] + + within(asapReleaseRow).getByText('Undecided') + }) + + it('shows time for scheduled releases', () => { + const scheduledReleaseRow = screen.getAllByTestId('table-row')[2] + + const date = format(TODAY, 'MMM d, yyyy') + within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM`) + }) + + it('has release menu actions for each release', () => { + const releaseRows = screen.getAllByTestId('table-row') + releaseRows.forEach((row) => { + within(row).getByTestId('release-menu-button') + }) + }) + + it('shows lock next to scheduled releases', () => { + const scheduledReleaseRow = screen.getAllByTestId('table-row')[1] + within(scheduledReleaseRow).getByTestId('release-avatar-primary') + within(scheduledReleaseRow).getByTestId('release-lock-icon') + }) + + it('allows for switching between history modes', () => { + expect(screen.getByText('Open').closest('button')).not.toBeDisabled() + expect(screen.getByText('Archived').closest('button')).not.toBeDisabled() + }) + + it('allows for pinning perspectives', () => { + fireEvent.click( + within(screen.getAllByTestId('table-row')[0]).getByTestId('pin-release-button'), + ) + + expect(mockedSetPerspective).toHaveBeenCalledWith('rASAP') + }) + + it('will show pinned release in release list', async () => { + mockUsePerspective.mockReturnValue({ + ...usePerspectiveMockReturn, + selectedPerspective: activeASAPRelease, + selectedReleaseId: 'rASAP', + }) + + // re-render to apply the update to global bundle id + await rerender() + + const releaseRows = screen.getAllByTestId('table-row') + const pinnedReleaseRow = releaseRows[0] + + expect(within(pinnedReleaseRow).getByTestId('pin-release-button')).toHaveAttribute( + 'data-selected', + '', + ) + }) + + describe('calendar filter', () => { + const getCalendar = () => getByDataUi(document.body, 'Calendar') + + it('has today in bold to signify that there is a release', () => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByTestId( + 'day-tile-today', + ) + expect(todayTile.firstChild).toHaveStyle('font-weight: 700') + }) + + describe('selecting a release date', () => { + beforeEach(() => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByTestId( + 'day-tile-today', + ) + fireEvent.click(todayTile) + }) + + it('does not show open and archive filter group buttons', () => { + expect(screen.queryByText('Open')).not.toBeInTheDocument() + expect(screen.queryByText('Archived')).not.toBeInTheDocument() + }) + + it('filters releases by date', () => { + const releaseRows = screen.getAllByTestId('table-row') + + expect(releaseRows).toHaveLength(1) + within(releaseRows[0]).getByText(activeScheduledRelease.metadata.title) + }) + + it('clears the filter by clicking the selected date', async () => { + // not ideal, but the easiest way of finding the now selected date + const todayTile = getCalendar().querySelector('[data-selected]') + fireEvent.click(todayTile!) + + await waitFor(() => { + expect(screen.getAllByTestId('table-row')).toHaveLength(4) + }) + }) + + it('clears the filter by clicking the date filter button', async () => { + fireEvent.click(screen.getByTestId('selected-date-filter')) + + await waitFor(() => { + expect(screen.getAllByTestId('table-row')).toHaveLength(4) + }) + }) + }) + }) + + describe('timezone selection', () => { + it('shows the selected timezone', () => { + screen.getByText('SCT (Sanity/Oslo)') + }) + + it('opens the timezone selector', () => { + fireEvent.click(screen.getByText('SCT (Sanity/Oslo)')) + + within(getByDataUi(document.body, 'DialogCard')).getByText('Select time zone') + }) + + it('shows dates with timezone abbreviation when it is not the locale', async () => { + mockGetLocaleTimeZone.mockReturnValue({ + abbreviation: 'NST', // Not Sanity Time + namePretty: 'Not Sanity Time', + offset: '+00:00', + name: 'NST', + alternativeName: 'Not Sanity Time', + mainCities: 'Not Sanity City', + value: 'Not Sanity Time', + }) + + await rerender() + + const scheduledReleaseRow = screen.getAllByTestId('table-row')[2] + + const date = format(TODAY, 'MMM d, yyyy') + within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM (SCT)`) + }) + + describe('when a different timezone is selected', () => { + beforeEach(() => { + mockUseTimeZone.mockReturnValue({ + ...useTimeZoneMockReturn, + // spoof a timezone that is 8 hours ahead of UTC + zoneDateToUtc: vi.fn((date) => set(date, {hours: new Date(date).getHours() - 8})), + }) + }) + + it('shows today as having no releases', () => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByTestId( + 'day-tile-today', + ) + expect(todayTile.parentNode).not.toHaveStyle('font-weight: 700') + }) + + it('shows no releases when filtered by today', () => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByTestId( + 'day-tile-today', + ) + fireEvent.click(todayTile) + + expect(screen.queryAllByTestId('table-row')).toHaveLength(0) + }) + }) + }) + + describe('archived releases', () => { + beforeEach(() => { + fireEvent.click(screen.getByText('Archived')) + }) + + it('shows published releases', async () => { + const archivedReleaseRow = screen.getAllByTestId('table-row')[0] + within(archivedReleaseRow).getByText('published Release') + }) + + it('shows archived releases', async () => { + const publishedReleaseRow = screen.getAllByTestId('table-row')[1] + within(publishedReleaseRow).getByText('archived Release') + within(publishedReleaseRow).getByTestId('release-avatar-default') + }) + + it('does not allow for perspective pinning', () => { + screen.getAllByTestId('table-row').forEach((row) => { + expect(within(row).getByTestId('pin-release-button').closest('button')).toBeDisabled() + }) + }) + }) + + it('sorts the list of releases', () => { + const [unsortedFirstRelease, unsortedSecondRelease, unsortedThirdRelease] = + screen.getAllByTestId('table-row') + + // default sort asap, then scheduled by publish asc + within(unsortedFirstRelease).getByText(activeASAPRelease.metadata.title) + within(unsortedSecondRelease).getByText(scheduledRelease.metadata.title) + within(unsortedThirdRelease).getByText(activeScheduledRelease.metadata.title) + + // sort by asc publish at + fireEvent.click(screen.getByText('Time')) + const [ + // first release is undecided + _, + descPublishSortedFirstRelease, + descPublishSortedSecondRelease, + descPublishSortedThirdRelease, + ] = screen.getAllByTestId('table-row') + within(descPublishSortedFirstRelease).getByText(activeScheduledRelease.metadata.title) + within(descPublishSortedSecondRelease).getByText(scheduledRelease.metadata.title) + within(descPublishSortedThirdRelease).getByText(activeASAPRelease.metadata.title) + }) + + it('should navigate to release when row clicked', async () => { + const releaseRow = screen.getAllByTestId('table-row')[0] + fireEvent.click(within(releaseRow).getByText(activeASAPRelease.metadata.title)) + + expect(useRouter().navigate).toHaveBeenCalledWith({ + releaseId: 'rASAP', + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseDocumentsCounter.test.tsx b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseDocumentsCounter.test.tsx new file mode 100644 index 00000000000..50df5e8baa6 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseDocumentsCounter.test.tsx @@ -0,0 +1,39 @@ +import {render, screen, waitFor} from '@testing-library/react' +import {type ComponentProps} from 'react' +import {describe, expect, it} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {ReleaseDocumentsCounter} from '../../columnCells/ReleaseDocumentsCounter' + +const renderTest = async (props: ComponentProps) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + render(, {wrapper}) + + await waitFor(() => { + expect(screen.queryByTestId('loading-block')).not.toBeInTheDocument() + }) +} + +describe('ReleaseDocumentsCounter', () => { + it('renders "-" when documentCount is undefined', async () => { + await renderTest({documentCount: undefined}) + + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('renders the singular text when documentCount is 1', async () => { + await renderTest({documentCount: 1}) + + expect(screen.getByText('1 document')).toBeInTheDocument() + }) + + it('renders the plural text when documentCount is greater than 1', async () => { + await renderTest({documentCount: 5}) + + expect(screen.getByText('5 documents')).toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseName.test.tsx b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseName.test.tsx new file mode 100644 index 00000000000..a14e1eaa196 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseName.test.tsx @@ -0,0 +1,98 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import {mockUseRouterReturn} from '../../../../../../../test/mocks/useRouter.mock' +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import { + mockUsePerspective, + usePerspectiveMockReturn, +} from '../../../../../perspective/__mocks__/usePerspective.mock' +import {activeASAPRelease, archivedScheduledRelease} from '../../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {type InjectedTableProps} from '../../../components/Table/types' +import {ReleaseNameCell} from '../../columnCells/ReleaseName' +import {type TableRelease} from '../../ReleasesOverview' + +vi.mock('../../../../../perspective/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +const mockedSetPerspective = vi.fn() +vi.mock('../../../../../perspective/useSetPerspective', () => ({ + useSetPerspective: vi.fn(() => mockedSetPerspective), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => mockUseRouterReturn, +})) + +const renderTest = async (release: TableRelease) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + render(, { + wrapper, + }) + + await waitFor(() => { + expect(screen.queryByTestId('loading-block')).not.toBeInTheDocument() + }) +} + +describe('ReleaseNameCell', () => { + it('renders the release title correctly', async () => { + await renderTest(activeASAPRelease) + + expect(screen.getByText('active asap Release')).toBeInTheDocument() + }) + + it('renders the placeholder title for an untitled release', async () => { + const untitledRelease = {...activeASAPRelease, metadata: {title: ''}} as TableRelease + await renderTest(untitledRelease) + + expect(screen.getByText('Untitled release')).toBeInTheDocument() + }) + + it('disables the pin button for archived releases', async () => { + await renderTest(archivedScheduledRelease) + + const pinButton = screen.getByTestId('pin-release-button') + expect(pinButton).toBeDisabled() + }) + + it('enables the pin button for draft releases', async () => { + await renderTest(activeASAPRelease) + + const pinButton = screen.getByTestId('pin-release-button') + expect(pinButton).not.toBeDisabled() + }) + + it('handles pinning a release', async () => { + await renderTest(activeASAPRelease) + + const pinButton = screen.getByTestId('pin-release-button') + fireEvent.click(pinButton) + + expect(mockedSetPerspective).toHaveBeenCalledWith('rASAP') + }) + + it('handles unpinning a release', async () => { + mockUsePerspective.mockReturnValue({...usePerspectiveMockReturn, selectedReleaseId: 'rASAP'}) + await renderTest(activeASAPRelease) + + const pinButton = screen.getByTestId('pin-release-button') + fireEvent.click(pinButton) + + expect(mockedSetPerspective).toHaveBeenCalledWith('drafts') + }) + + it('navigates to the release detail page on click', async () => { + await renderTest(activeASAPRelease) + + fireEvent.click(screen.getByText('active asap Release')) + + expect(mockUseRouterReturn.navigate).toHaveBeenCalledWith({releaseId: 'rASAP'}) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseTime.test.tsx b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseTime.test.tsx new file mode 100644 index 00000000000..451b230147b --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/__tests__/columnCells/ReleaseTime.test.tsx @@ -0,0 +1,66 @@ +import {render, screen, waitFor} from '@testing-library/react' +import {format} from 'date-fns' +import {type ComponentProps} from 'react' +import {describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider' +import {useTimeZoneMockReturn} from '../../../../../scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock' +import { + activeASAPRelease, + activeUndecidedRelease, + scheduledRelease, +} from '../../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {ReleaseTime} from '../../columnCells/ReleaseTime' +import {type TableRelease} from '../../ReleasesOverview' + +vi.mock('../../../../scheduledPublishing/hooks/useTimeZone', () => useTimeZoneMockReturn) + +const renderTest = async (props: ComponentProps) => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const rendered = render(, {wrapper}) + + await waitFor(() => { + expect(screen.queryByTestId('loading-block')).not.toBeInTheDocument() + }) + + return rendered +} + +describe('ReleaseTime', () => { + it('renders "ASAP" when releaseType is "asap"', async () => { + await renderTest({release: activeASAPRelease}) + + expect(screen.getByText('ASAP')).toBeInTheDocument() + }) + + it('renders "Undecided" when releaseType is "undecided"', async () => { + await renderTest({release: activeUndecidedRelease}) + + expect(screen.getByText('Undecided')).toBeInTheDocument() + }) + + it('renders the formatted date with timezone abbreviation when releaseType is scheduled', async () => { + await renderTest({ + release: scheduledRelease, + }) + + expect(screen.getByText('Oct 10, 2023', {exact: false})).toBeInTheDocument() + }) + + it('renders nothing when releaseType is "scheduled" and publishDate is not available', async () => { + await renderTest({ + release: { + ...scheduledRelease, + publishAt: undefined, + metadata: {...scheduledRelease.metadata, intendedPublishAt: undefined}, + } as TableRelease, + }) + + const formattedDate = `${format(new Date(), 'PPpp')}` + expect(screen.getByText(formattedDate, {exact: false})).toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseDocumentsCounter.tsx b/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseDocumentsCounter.tsx new file mode 100644 index 00000000000..4443d378fc4 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseDocumentsCounter.tsx @@ -0,0 +1,25 @@ +import {Text} from '@sanity/ui' +import {useMemo} from 'react' + +import {Translate, useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' + +type Props = { + documentCount: number | undefined +} + +export const ReleaseDocumentsCounter = ({documentCount}: Props) => { + const {t} = useTranslation(releasesLocaleNamespace) + + const count = useMemo(() => { + if (!documentCount) return '-' + + return + }, [documentCount, t]) + + return ( + + {count} + + ) +} diff --git a/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseName.tsx b/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseName.tsx new file mode 100644 index 00000000000..64ccc0971cb --- /dev/null +++ b/packages/sanity/src/core/releases/tool/overview/columnCells/ReleaseName.tsx @@ -0,0 +1,92 @@ +import {PinFilledIcon, PinIcon} from '@sanity/icons' +import {Box, Card, Flex, Stack, Text} from '@sanity/ui' +import {useCallback} from 'react' +import {useTranslation} from 'react-i18next' +import {useRouter} from 'sanity/router' + +import {Button, Tooltip} from '../../../../../ui-components' +import {Translate} from '../../../../i18n' +import {usePerspective} from '../../../../perspective/usePerspective' +import {useSetPerspective} from '../../../../perspective/useSetPerspective' +import {ReleaseAvatar} from '../../../components/ReleaseAvatar' +import {releasesLocaleNamespace} from '../../../i18n' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {getReleaseTone} from '../../../util/getReleaseTone' +import {type TableRowProps} from '../../components/Table/Table' +import {type Column} from '../../components/Table/types' +import {type TableRelease} from '../ReleasesOverview' + +export const ReleaseNameCell: Column['cell'] = ({cellProps, datum: release}) => { + const router = useRouter() + const {t} = useTranslation(releasesLocaleNamespace) + const {t: tCore} = useTranslation() + const {selectedReleaseId} = usePerspective() + const setPerspective = useSetPerspective() + const {state} = release + const releaseId = getReleaseIdFromReleaseDocumentId(release._id) + const isArchived = state === 'archived' + const isReleasePinned = releaseId === selectedReleaseId + + const handlePinRelease = useCallback(() => { + if (isReleasePinned) { + setPerspective('drafts') + } else { + setPerspective(releaseId) + } + }, [isReleasePinned, releaseId, setPerspective]) + + const cardProps: TableRowProps = release.isDeleted + ? {tone: 'transparent'} + : { + as: 'a', + // navigate to release detail + onClick: () => router.navigate({releaseId: releaseId}), + tone: 'inherit', + } + + const pinButtonIcon = isReleasePinned ? PinFilledIcon : PinIcon + const displayTitle = release.metadata.title || tCore('release.placeholder-untitled-release') + + return ( + + + + + } + > + + + + +
{{actualType}}
{{expectedType}}