diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 52785bb452e..1ebfa915f43 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,6 +3,45 @@ 'use strict' const extensions = ['.cjs', '.mjs', '.js', '.jsx', '.ts', '.tsx'] +const sanityNoRestrictedImportsPaths = [ + { + name: '@sanity/ui', + importNames: [ + 'Button', + 'ButtonProps', + 'Dialog', + 'DialogProps', + 'ErrorBoundary', + 'MenuButton', + 'MenuButtonProps', + 'MenuGroup', + 'MenuGroupProps', + 'MenuItem', + 'MenuItemProps', + 'Popover', + 'PopoverProps', + 'Tab', + 'TabProps', + 'Tooltip', + 'TooltipProps', + 'TooltipDelayGroupProvider', + 'TooltipDelayGroupProviderProps', + ], + message: + 'Please use the (more opinionated) exported components in sanity/src/ui-components instead.', + }, + { + name: 'styled-components', + importNames: ['default'], + message: 'Please use `import {styled} from "styled-components"` instead.', + }, + { + name: 'react', + importNames: ['default', 'createContext'], + message: + 'Please use named imports, e.g. `import {useEffect, useMemo, type ComponentType} from "react"` instead.\nPlease place "context" in _singletons', + }, +] /** @type {import('eslint').Linter.Config} */ const config = { @@ -92,6 +131,7 @@ const config = { 'sortOrder', 'status', 'group', + 'textWeight', ], }, }, @@ -180,48 +220,32 @@ const config = { 'packages/sanity/src/_singletons/**', 'packages/sanity/src/_createContext/**', ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: sanityNoRestrictedImportsPaths, + }, + ], + }, + }, + { + files: ['packages/sanity/src/core/**'], + excludedFiles: [ + '**/__workshop__/**', + 'packages/sanity/src/_singletons/**', + 'packages/sanity/src/_createContext/**', + ], rules: { 'no-restricted-imports': [ 'error', { paths: [ - { - name: '@sanity/ui', - importNames: [ - 'Button', - 'ButtonProps', - 'Dialog', - 'DialogProps', - 'ErrorBoundary', - 'MenuButton', - 'MenuButtonProps', - 'MenuGroup', - 'MenuGroupProps', - 'MenuItem', - 'MenuItemProps', - 'Popover', - 'PopoverProps', - 'Tab', - 'TabProps', - 'Tooltip', - 'TooltipProps', - 'TooltipDelayGroupProvider', - 'TooltipDelayGroupProviderProps', - ], - message: - 'Please use the (more opinionated) exported components in sanity/src/ui-components instead.', - }, - { - name: 'styled-components', - importNames: ['default'], - message: 'Please use `import {styled} from "styled-components"` instead.', - }, - { - name: 'react', - importNames: ['default', 'createContext'], - message: - 'Please use named imports, e.g. `import {useEffect, useMemo, type ComponentType} from "react"` instead.\nPlease place "context" in _singletons', - }, + // { + // name: 'sanity', + // message: 'Please import from the relative path instead of the `sanity` package', + // }, + ...sanityNoRestrictedImportsPaths, ], }, ], diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index 175702d92b6..9161618e429 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -64,5 +64,10 @@ "devDependencies": { "chokidar": "^3.6.0", "vite": "^4.5.5" + }, + "overrides": { + "@sanity/preview-url-secret": "^2.0.1-release.4", + "@sanity/react-loader": "^1.10.15-release.4", + "@sanity/visual-editing": "2.4.3-release.4" } } diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index d9e64b6064b..97c3227d51a 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -164,7 +164,8 @@ const defaultWorkspace = { icon: SanityMonogram, // eslint-disable-next-line camelcase __internal_serverDocumentActions: { - enabled: true, + // TODO: Switched off because Actions API doesn't support versions (yet). + enabled: false, }, scheduledPublishing: { enabled: true, @@ -353,7 +354,7 @@ export default defineConfig([ preview: '/preview/index.html', }, }), - assist(), + // assist(), sharedSettings(), ], basePath: '/presentation', diff --git a/package.json b/package.json index 120a382c48b..04817f002a2 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,11 @@ }, "overrides": { "@npmcli/arborist": "^7.5.4", - "@sanity/ui@2": "$@sanity/ui", + "@sanity/client": "6.22.2-bundle-perspective-2", + "@sanity/icons": "3.5.0-corel.0", + "@sanity/insert-menu": "1.0.11-release.4", + "@sanity/presentation": "1.17.8-release.4", + "@sanity/ui": "2.10.0-corel.0", "@typescript-eslint/eslint-plugin": "$@typescript-eslint/eslint-plugin", "@typescript-eslint/parser": "$@typescript-eslint/parser" } diff --git a/packages/@sanity/types/src/schema/preview.ts b/packages/@sanity/types/src/schema/preview.ts index bc72b404992..d297ce1c056 100644 --- a/packages/@sanity/types/src/schema/preview.ts +++ b/packages/@sanity/types/src/schema/preview.ts @@ -10,6 +10,9 @@ export interface PrepareViewOptions { /** @public */ export interface PreviewValue { + _id?: string + _createdAt?: string + _updatedAt?: string title?: string subtitle?: string description?: string diff --git a/packages/sanity/package.json b/packages/sanity/package.json index 8f747aa0282..a09a9f596c1 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -252,6 +252,7 @@ "rimraf": "^3.0.2", "rxjs": "^7.8.0", "rxjs-exhaustmap-with-trailing": "^2.1.1", + "rxjs-mergemap-array": "^0.1.0", "sanity-diff-patch": "^4.0.0", "scroll-into-view-if-needed": "^3.0.3", "semver": "^7.3.5", diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx index 69f98a55a1c..f0508b4893b 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx @@ -1,6 +1,7 @@ import {type SanityClient} from '@sanity/client' import {Card, LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui' import {buildTheme, type RootTheme} from '@sanity/ui/theme' +import {noop} from 'lodash' import {type ReactNode, Suspense, useEffect, useState} from 'react' import { ChangeConnectorRoot, @@ -18,6 +19,8 @@ import { import {Pane, PaneContent, PaneLayout} from 'sanity/structure' import {styled} from 'styled-components' +import {route} from '../../../../src/router' +import {RouterProvider} from '../../../../src/router/RouterProvider' import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient' import {getMockWorkspace} from '../../../../test/testUtils/getMockWorkspaceFromConfig' @@ -36,6 +39,8 @@ const StyledChangeConnectorRoot = styled(ChangeConnectorRoot)` min-width: 0; ` +const router = route.create('/') + /** * @description This component is used to wrap all tests in the providers it needs to be able to run successfully. * It provides a mock Sanity client and a mock workspace. @@ -72,37 +77,39 @@ export const TestWrapper = (props: TestWrapperProps): JSX.Element | null => { return ( - - - - - - - - - - {}} - onSetFocus={() => {}} - > - - - - {children} - - - - - - - - - - - - - + + + + + + + + + + + {}} + onSetFocus={() => {}} + > + + + + {children} + + + + + + + + + + + + + + ) } diff --git a/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts b/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts new file mode 100644 index 00000000000..cbe9a7b726e --- /dev/null +++ b/packages/sanity/src/_singletons/context/ReleasesMetadataContext.ts @@ -0,0 +1,12 @@ +import {createContext} from 'sanity/_createContext' + +import type {ReleasesMetadataContextValue} from '../../core/releases/contexts/ReleasesMetadataProvider' + +/** + * @internal + * @hidden + */ +export const ReleasesMetadataContext = createContext( + 'sanity/_singletons/context/releases-metadata', + null, +) diff --git a/packages/sanity/src/_singletons/context/ReleasesTableContext.ts b/packages/sanity/src/_singletons/context/ReleasesTableContext.ts new file mode 100644 index 00000000000..64f9023bb61 --- /dev/null +++ b/packages/sanity/src/_singletons/context/ReleasesTableContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {TableContextValue} from '../../core/releases/tool/components/Table/TableProvider' + +/** + * @internal + */ +export const TableContext = createContext( + 'sanity/_singletons/context/releases-table', + null, +) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 6679e2e0282..2c0b4c89528 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -43,6 +43,8 @@ export * from './context/PresenceTrackerContexts' export * from './context/PreviewCardContext' export * from './context/ReferenceInputOptionsContext' export * from './context/ReferenceItemRefContext' +export * from './context/ReleasesMetadataContext' +export * from './context/ReleasesTableContext' export * from './context/ResourceCacheContext' export * from './context/ReviewChangesContext' export * from './context/RouterContext' diff --git a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx index b90663cb317..083146ae517 100644 --- a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx +++ b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx @@ -263,6 +263,9 @@ export const CommentsListItem = memo(function CommentsListItem(props: CommentsLi } }, [replies]) + /* TODO - once we understand how to set up with "finished" releases + we need to add a condition to the readOnly prop in this component */ + const renderedReplies = useMemo( () => splicedReplies.map((reply) => ( diff --git a/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx index e15c5a4eb0d..b7d69ad1f53 100644 --- a/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx +++ b/packages/sanity/src/core/comments/context/comments/CommentsProvider.tsx @@ -6,7 +6,7 @@ import {CommentsContext} from 'sanity/_singletons' import {useEditState, useSchema, useUserListWithPermissions} from '../../../hooks' import {useCurrentUser} from '../../../store' import {useAddonDataset, useWorkspace} from '../../../studio' -import {getPublishedId} from '../../../util' +import {getPublishedId, resolveBundlePerspective} from '../../../util' import { type CommentOperationsHookOptions, useCommentOperations, @@ -43,6 +43,7 @@ export interface CommentsProviderProps { children: ReactNode documentId: string documentType: string + perspective?: string type: CommentsType sortOrder: 'asc' | 'desc' @@ -67,7 +68,7 @@ type TransactionId = string export const CommentsProvider = memo(function CommentsProvider(props: CommentsProviderProps) { const { children, - documentId, + documentId: versionOrPublishedId, documentType, isCommentsOpen, onCommentsOpen, @@ -78,20 +79,26 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr selectedCommentId, isConnecting, onPathOpen, + perspective, } = props const commentsEnabled = useCommentsEnabled() const [status, setStatus] = useState('open') const {client, createAddonDataset, isCreatingDataset} = useAddonDataset() - const publishedId = getPublishedId(documentId) - const editState = useEditState(publishedId, documentType, 'low') + + const editState = useEditState( + getPublishedId(versionOrPublishedId), + documentType, + 'default', + resolveBundlePerspective(perspective), + ) const schemaType = useSchema().get(documentType) const currentUser = useCurrentUser() const {name: workspaceName, dataset, projectId} = useWorkspace() const documentValue = useMemo(() => { - return editState.draft || editState.published - }, [editState.draft, editState.published]) + return editState.version || editState.draft || editState.published + }, [editState.version, editState.draft, editState.published]) const documentRevisionId = useMemo(() => documentValue?._rev, [documentValue]) @@ -112,7 +119,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr error, loading, } = useCommentsStore({ - documentId: publishedId, + documentId: versionOrPublishedId, client, transactionsIdMap, onLatestTransactionIdReceived: handleOnLatestTransactionIdReceived, @@ -229,7 +236,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - documentId: publishedId, + documentId: versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -257,7 +264,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr client, currentUser, dataset, - publishedId, + versionOrPublishedId, documentRevisionId, documentType, getComment, @@ -277,7 +284,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr const ctxValue = useMemo( (): CommentsContextValue => ({ - documentId, + documentId: versionOrPublishedId, documentType, isCreatingDataset, @@ -310,7 +317,7 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr mentionOptions, }), [ - documentId, + versionOrPublishedId, documentType, isCreatingDataset, status, diff --git a/packages/sanity/src/core/comments/store/useCommentsStore.ts b/packages/sanity/src/core/comments/store/useCommentsStore.ts index 0dc5858af7a..d2f7e53267b 100644 --- a/packages/sanity/src/core/comments/store/useCommentsStore.ts +++ b/packages/sanity/src/core/comments/store/useCommentsStore.ts @@ -2,7 +2,7 @@ import {type ListenEvent, type ListenOptions, type SanityClient} from '@sanity/c import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import {catchError, of} from 'rxjs' -import {getPublishedId} from '../../util' +import {getPublishedId, isVersionId} from '../../util' import {type CommentDocument, type Loadable} from '../types' import {commentsReducer, type CommentsReducerAction, type CommentsReducerState} from './reducer' @@ -64,7 +64,10 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur const didInitialFetch = useRef(false) - const params = useMemo(() => ({documentId: getPublishedId(documentId)}), [documentId]) + const params = useMemo( + () => ({documentId: isVersionId(documentId) ? documentId : getPublishedId(documentId)}), + [documentId], + ) const initialFetch = useCallback(async () => { if (!client) { diff --git a/packages/sanity/src/core/components/StatusButton.tsx b/packages/sanity/src/core/components/StatusButton.tsx index 427fd34ef82..9a02cc36119 100644 --- a/packages/sanity/src/core/components/StatusButton.tsx +++ b/packages/sanity/src/core/components/StatusButton.tsx @@ -47,6 +47,7 @@ export const StatusButton = forwardRef(function StatusButton( ...restProps } = props const theme = useTheme() + // @ts-expect-error fixme after sanity/ui and sanity/icons release const toneColor = tone && theme.sanity.color.solid[tone] const dotStyle = useMemo(() => ({backgroundColor: toneColor?.enabled.bg}), [toneColor]) const disabled = Boolean(disabledProp) diff --git a/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx b/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx index 16a5190dbf7..bb5b17d3491 100644 --- a/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx +++ b/packages/sanity/src/core/components/documentStatus/DocumentStatus.tsx @@ -1,21 +1,19 @@ 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 {getReleaseTone, ReleaseAvatar, useReleases} from '../../releases' interface DocumentStatusProps { - absoluteDate?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + versions?: VersionsRecord | Record singleLine?: boolean } -const StyledText = styled(Text)` - white-space: nowrap; -` - /** * Displays document status indicating both last published and edited dates in either relative (the default) * or absolute formats. @@ -26,55 +24,87 @@ const StyledText = styled(Text)` * * @internal */ -export function DocumentStatus({absoluteDate, draft, published, singleLine}: DocumentStatusProps) { +export function DocumentStatus({draft, published, versions, singleLine}: DocumentStatusProps) { + const {data: releases} = useReleases() + const versionsList = useMemo(() => Object.entries(versions ?? {}), [versions]) const {t} = useTranslation() - const draftUpdatedAt = draft && '_updatedAt' in draft ? draft._updatedAt : '' - const publishedUpdatedAt = published && '_updatedAt' in published ? published._updatedAt : '' - - const intlDateFormat = useDateTimeFormat({ - dateStyle: 'medium', - timeStyle: 'short', - }) - - const draftDateAbsolute = draftUpdatedAt && intlDateFormat.format(new Date(draftUpdatedAt)) - const publishedDateAbsolute = - publishedUpdatedAt && intlDateFormat.format(new Date(publishedUpdatedAt)) - - const draftUpdatedTimeAgo = useRelativeTime(draftUpdatedAt || '', { - minimal: true, - useTemporalPhrase: true, - }) - const publishedUpdatedTimeAgo = useRelativeTime(publishedUpdatedAt || '', { - minimal: true, - useTemporalPhrase: true, - }) - - const publishedDate = absoluteDate ? publishedDateAbsolute : publishedUpdatedTimeAgo - const updatedDate = absoluteDate ? draftDateAbsolute : draftUpdatedTimeAgo return ( - {!publishedDate && ( - - {t('document-status.not-published')} - - )} - {publishedDate && ( - - {t('document-status.published', {date: publishedDate})} - + {published && ( + )} - {updatedDate && ( - - {t('document-status.edited', {date: updatedDate})} - + {draft && ( + )} + {versionsList.map(([versionName, {snapshot}]) => { + const release = releases?.find((r) => r.name === 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..9cf79e9d1c8 100644 --- a/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx +++ b/packages/sanity/src/core/components/documentStatusIndicator/DocumentStatusIndicator.tsx @@ -1,53 +1,91 @@ -import {DotIcon} from '@sanity/icons' import {type PreviewValue, type SanityDocument} from '@sanity/types' -import {Text} from '@sanity/ui' +import {Flex} from '@sanity/ui' import {useMemo} from 'react' import {styled} from 'styled-components' +import {type VersionsRecord} from '../../preview/utils/getPreviewStateObservable' +import {useReleases} from '../../releases/store/useReleases' + interface DocumentStatusProps { draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + versions: VersionsRecord | undefined } -const Root = styled(Text)` - &[data-status='edited'] { - --card-icon-color: var(--card-badge-caution-dot-color); - } - &[data-status='unpublished'] { +const Dot = styled.div<{$index: number}>` + width: 5px; + height: 5px; + background-color: var(--card-icon-color); + border-radius: 999px; + box-shadow: 0 0 0 1px var(--card-bg-color); + z-index: ${({$index}) => $index}; + &[data-status='not-published'] { --card-icon-color: var(--card-badge-default-dot-color); opacity: 0.5 !important; } + &[data-status='draft'] { + --card-icon-color: var(--card-badge-caution-dot-color); + } + &[data-status='asap'] { + --card-icon-color: var(--card-badge-critical-dot-color); + } + &[data-status='undecided'] { + --card-icon-color: var(--card-badge-explore-dot-color); + } + &[data-status='scheduled'] { + --card-icon-color: var(--card-badge-primary-dot-color); + } ` +type Status = 'not-published' | 'draft' | 'asap' | 'scheduled' | 'undecided' + /** * Renders a dot indicating the current document status. * - * - Yellow (caution) for published documents with edits - * - Gray (default) for unpublished documents (with or without edits) - * - * No dot will be displayed for published documents without edits. - * * @internal */ -export function DocumentStatusIndicator({draft, published}: DocumentStatusProps) { - const $draft = !!draft - const $published = !!published - - const status = useMemo(() => { - if ($draft && !$published) return 'unpublished' - return 'edited' - }, [$draft, $published]) +export function DocumentStatusIndicator({draft, published, versions}: DocumentStatusProps) { + const {data: releases} = useReleases() + const versionsList = useMemo( + () => + versions + ? Object.keys(versions).map((versionName) => { + const release = releases?.find((r) => r.name === 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/loadingBlock/LoadingBlock.tsx b/packages/sanity/src/core/components/loadingBlock/LoadingBlock.tsx index 04ab7b9231f..66f4468f480 100644 --- a/packages/sanity/src/core/components/loadingBlock/LoadingBlock.tsx +++ b/packages/sanity/src/core/components/loadingBlock/LoadingBlock.tsx @@ -121,7 +121,7 @@ const StyledText = styled(Text)` */ export function LoadingBlock({fill, showText, title}: LoadingTestProps) { return ( - + {showText && } diff --git a/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx b/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx new file mode 100644 index 00000000000..7ea03cf6bd5 --- /dev/null +++ b/packages/sanity/src/core/components/perspective/PerspectiveBadge.tsx @@ -0,0 +1,40 @@ +import {Box, Text} from '@sanity/ui' +import {type CSSProperties, useMemo} from 'react' + +export function PerspectiveBadge(props: { + releaseTitle?: string + // TODO: prep work for potentially reusing this on document headers + documentStatus: 'draft' | 'published' | 'version' +}): JSX.Element | null { + const {releaseTitle = 'draft', documentStatus} = props + const isPublished = documentStatus === 'published' + const isDraft = documentStatus === 'draft' + + const displayTitle = useMemo(() => { + if (isPublished) { + return 'published' + } + + if (isDraft) { + return 'edited' + } + + return releaseTitle + }, [isDraft, isPublished, releaseTitle]) + + return ( + + {displayTitle} + + ) +} diff --git a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts index ec8e27faecc..cb4a5cefe93 100644 --- a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts +++ b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts @@ -164,6 +164,7 @@ describe('resolveConfig', () => { {name: 'sanity/tasks'}, {name: 'sanity/scheduled-publishing'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) @@ -191,6 +192,7 @@ describe('resolveConfig', () => { {name: 'sanity/comments'}, {name: 'sanity/tasks'}, {name: 'sanity/create-integration'}, + {name: 'sanity/releases'}, ]) }) }) diff --git a/packages/sanity/src/core/config/resolveDefaultPlugins.ts b/packages/sanity/src/core/config/resolveDefaultPlugins.ts index b567b8f839a..7fb2e887263 100644 --- a/packages/sanity/src/core/config/resolveDefaultPlugins.ts +++ b/packages/sanity/src/core/config/resolveDefaultPlugins.ts @@ -1,5 +1,6 @@ import {comments} from '../comments/plugin' import {createIntegration} from '../create/createIntegrationPlugin' +import {releases, RELEASES_NAME} from '../releases/plugin' import {DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS} from '../scheduledPublishing/constants' import {SCHEDULED_PUBLISHING_NAME, scheduledPublishing} from '../scheduledPublishing/plugin' import {tasks, TASKS_NAME} from '../tasks/plugin' @@ -10,7 +11,7 @@ import { type WorkspaceOptions, } from './types' -const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration()] +const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration(), releases()] export function getDefaultPlugins( options: DefaultPluginsWorkspaceOptions, @@ -24,6 +25,9 @@ export function getDefaultPlugins( if (plugin.name === TASKS_NAME) { return options.tasks.enabled } + if (plugin.name === RELEASES_NAME) { + return options.releases.enabled + } return true }) } @@ -41,5 +45,9 @@ export function getDefaultPluginsOptions( ...DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS, ...workspace.scheduledPublishing, }, + releases: { + enabled: true, + ...workspace.releases, + }, } } diff --git a/packages/sanity/src/core/config/studio/types.ts b/packages/sanity/src/core/config/studio/types.ts index a47f7693fe0..ac12e9d2861 100644 --- a/packages/sanity/src/core/config/studio/types.ts +++ b/packages/sanity/src/core/config/studio/types.ts @@ -18,20 +18,30 @@ export interface LogoProps { renderDefault: (props: LogoProps) => ReactElement } -/** - * @internal - * @beta - * An internal API for defining actions in the navbar. - */ -export interface NavbarAction { +interface NavbarActionBase { icon?: React.ComponentType location: 'topbar' | 'sidebar' name: string +} + +interface ActionWithCustomRender extends NavbarActionBase { + render: () => ReactElement +} + +interface Action extends NavbarActionBase { onAction: () => void selected: boolean title: string + render?: undefined } +/** + * @internal + * @beta + * An internal API for defining actions in the navbar. + */ +export type NavbarAction = Action | ActionWithCustomRender + /** * @hidden * @beta */ diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 8fc8064afd4..79b9d49d4a3 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -480,6 +480,10 @@ export interface WorkspaceOptions extends SourceOptions { * @internal */ tasks?: DefaultPluginsWorkspaceOptions['tasks'] + /** + * @internal + */ + releases?: DefaultPluginsWorkspaceOptions['releases'] /** * @hidden @@ -539,6 +543,13 @@ export interface ResolveProductionUrlContext extends ConfigContext { document: SanityDocumentLike } +/** + * @hidden + * @beta + */ + +export type DocumentActionsPerspective = 'published' | 'draft' | 'revision' | 'version' + /** * @hidden * @beta @@ -546,6 +557,10 @@ export interface ResolveProductionUrlContext extends ConfigContext { export interface DocumentActionsContext extends ConfigContext { documentId?: string schemaType: string + /** bundleId of the open document, it's undefined if it's published or the draft */ + bundleId?: string + /** the perspective (version) of the open document */ + perspective?: DocumentActionsPerspective } /** @@ -935,6 +950,7 @@ export type { export type DefaultPluginsWorkspaceOptions = { tasks: {enabled: boolean} scheduledPublishing: ScheduledPublishingPluginOptions + releases: {enabled: boolean} } /** diff --git a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx index 0fb5b59c251..2b98c8c39af 100644 --- a/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx +++ b/packages/sanity/src/core/field/__workshop__/ChangeListStory.tsx @@ -88,6 +88,7 @@ export default function ChangeListStory() { rootDiff: diff, schemaType, value: {name: 'Test'}, + showFromValue: true, }), [diff, documentId, FieldWrapper, schemaType], ) diff --git a/packages/sanity/src/core/form/FormBuilderContext.ts b/packages/sanity/src/core/form/FormBuilderContext.ts index be5b2f561f6..22c4b6358b0 100644 --- a/packages/sanity/src/core/form/FormBuilderContext.ts +++ b/packages/sanity/src/core/form/FormBuilderContext.ts @@ -58,4 +58,5 @@ export interface FormBuilderContextValue { renderItem: RenderItemCallback renderPreview: RenderPreviewCallback schemaType: ObjectSchemaType + version?: string } diff --git a/packages/sanity/src/core/form/FormBuilderProvider.tsx b/packages/sanity/src/core/form/FormBuilderProvider.tsx index b14969c90a0..50d7fefa30a 100644 --- a/packages/sanity/src/core/form/FormBuilderProvider.tsx +++ b/packages/sanity/src/core/form/FormBuilderProvider.tsx @@ -65,6 +65,7 @@ export interface FormBuilderProviderProps { schemaType: ObjectSchemaType unstable?: Source['form']['unstable'] validation: ValidationMarker[] + version?: string } const missingPatchChannel: PatchChannel = { @@ -113,6 +114,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { schemaType, unstable, validation, + version, } = props const __internal: FormBuilderContextValue['__internal'] = useMemo( @@ -171,6 +173,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, }), [ __internal, @@ -191,6 +194,7 @@ export function FormBuilderProvider(props: FormBuilderProviderProps) { renderItem, renderPreview, schemaType, + version, ], ) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index 2df13d15d09..89c960265a6 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -25,6 +25,7 @@ import {useDidUpdate} from '../../hooks/useDidUpdate' import {useScrollIntoViewOnFocusWithin} from '../../hooks/useScrollIntoViewOnFocusWithin' import {set, unset} from '../../patch' import {type ObjectFieldProps, type RenderPreviewCallback} from '../../types' +import {useFormBuilder} from '../../useFormBuilder' import {PreviewReferenceValue} from './PreviewReferenceValue' import {ReferenceFinalizeAlertStrip} from './ReferenceFinalizeAlertStrip' import {ReferenceLinkCard} from './ReferenceLinkCard' @@ -62,6 +63,7 @@ export function ReferenceField(props: ReferenceFieldProps) { const elementRef = useRef(null) const {schemaType, path, open, inputId, children, inputProps} = props const {readOnly, focused, renderPreview, onChange} = props.inputProps + const {version} = useFormBuilder() const [fieldActionsNodes, setFieldActionNodes] = useState([]) const documentId = usePublishedId() @@ -74,6 +76,7 @@ export function ReferenceField(props: ReferenceFieldProps) { path, schemaType, value, + version, }) // this is here to make sure the item is visible if it's being edited behind a modal diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx index 2703be12ed6..a254fd43fc2 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceInput.tsx @@ -52,6 +52,7 @@ export function ReferenceInput(props: ReferenceInputProps) { id, onPathFocus, value, + version, renderPreview, path, elementProps, @@ -62,6 +63,7 @@ export function ReferenceInput(props: ReferenceInputProps) { path, schemaType, value, + version, }) const [searchState, setSearchState] = useState(INITIAL_SEARCH_STATE) @@ -187,6 +189,7 @@ export function ReferenceInput(props: ReferenceInputProps) { const renderOption = useCallback( (option: AutocompleteOption) => { + // TODO: Account for checked-out version. const documentId = option.hit.draft?._id || option.hit.published?._id || option.value return ( @@ -205,6 +208,7 @@ export function ReferenceInput(props: ReferenceInputProps) { const renderValue = useCallback(() => { return ( + loadableReferenceInfo.result?.preview.version?.title || loadableReferenceInfo.result?.preview.draft?.title || loadableReferenceInfo.result?.preview.published?.title || '' @@ -212,6 +216,7 @@ export function ReferenceInput(props: ReferenceInputProps) { }, [ loadableReferenceInfo.result?.preview.draft?.title, loadableReferenceInfo.result?.preview.published?.title, + loadableReferenceInfo.result?.preview.version?.title, ]) const handleFocus = useCallback(() => onPathFocus(['_ref']), [onPathFocus]) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx index 9371f6275a4..41daf411011 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferencePreview.tsx @@ -27,6 +27,7 @@ export function ReferencePreview(props: { const documentPresence = useDocumentPresence(id) const previewId = + preview.version?._id || preview.draft?._id || preview.published?._id || // note: during publish of the referenced document we might have both a missing draft and a missing published version @@ -44,8 +45,6 @@ export function ReferencePreview(props: { [previewId, refType.name], ) - const {draft, published} = preview - const previewProps = useMemo( () => ({ children: ( @@ -57,23 +56,32 @@ export function ReferencePreview(props: { )} - + ), layout, schemaType: refType, - tooltip: , + tooltip: ( + + ), value: previewStub, }), [ documentPresence, - draft, layout, preview.draft, preview.published, + preview.versions, previewStub, - published, refType, showTypeLabel, ], diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts index ed129607c31..76a5b3fbaec 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/types.ts @@ -9,6 +9,7 @@ import {type ComponentType, type ReactNode} from 'react' import {type Observable} from 'rxjs' import {type DocumentAvailability} from '../../../preview' +import {type VersionsRecord} from '../../../preview/utils/getPreviewStateObservable' import {type ObjectInputProps} from '../../types' export type PreviewDocumentValue = PreviewValue & { @@ -24,6 +25,8 @@ export interface ReferenceInfo { preview: { draft: PreviewDocumentValue | undefined published: PreviewDocumentValue | undefined + version: PreviewDocumentValue | undefined + versions: VersionsRecord } } @@ -82,4 +85,5 @@ export interface ReferenceInputProps onEditReference: (event: EditReferenceEvent) => void getReferenceInfo: (id: string, type: ReferenceSchemaType) => Observable + version?: string } diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx index 5e56965952f..c15d7a513d6 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -8,6 +8,7 @@ import { useMemo, useRef, } from 'react' +import {usePerspective, useReleases} from 'sanity' import {type FIXME} from '../../../FIXME' import {useSchema} from '../../../hooks' @@ -28,11 +29,14 @@ interface Options { path: Path schemaType: ReferenceSchemaType value?: Reference + version?: string } export function useReferenceInput(options: Options) { - const {path, schemaType} = options + const {path, schemaType, version} = options const schema = useSchema() + const perspective = usePerspective() + const releases = useReleases() const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -113,8 +117,24 @@ export function useReferenceInput(options: Options) { }, [disableNew, initialValueTemplateItems, schemaType.to]) const getReferenceInfo = useCallback( - (id: string) => adapter.getReferenceInfo(documentPreviewStore, id, schemaType), - [documentPreviewStore, schemaType], + (id: string) => + adapter.getReferenceInfo( + documentPreviewStore, + id, + schemaType, + {version}, + { + bundleIds: releases.releasesIds, + bundleStack: perspective.bundlesPerspective, + }, + ), + [ + documentPreviewStore, + schemaType, + version, + releases.releasesIds, + perspective.bundlesPerspective, + ], ) return { diff --git a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx index 630ecaa030d..8641aefd97d 100644 --- a/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx +++ b/packages/sanity/src/core/form/inputs/files/ImageInput/ImagePreview.styled.tsx @@ -22,7 +22,9 @@ export const Overlay = styled(Flex)<{ $tone: Exclude }>(({$tone}) => { const colorScheme = useColorSchemeValue() + // @ts-expect-error fixme after sanity/ui and sanity/icons release const textColor = studioTheme.color[colorScheme][$tone].card.enabled.fg + // @ts-expect-error fixme after sanity/ui and sanity/icons release const backgroundColor = rgba(studioTheme.color[colorScheme][$tone].card.enabled.bg, 0.8) return css` diff --git a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts index f7356d7db4a..056f80a36f0 100644 --- a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts +++ b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts @@ -1,5 +1,5 @@ import {defineField, defineType} from '@sanity/types' -import {beforeEach, expect, test, vi} from 'vitest' +import {beforeEach, expect, type Mock, test, vi} from 'vitest' import {createSchema} from '../../../../schema/createSchema' import {createImmutableReconcile} from '../immutableReconcile' @@ -7,7 +7,7 @@ import {createImmutableReconcile} from '../immutableReconcile' const immutableReconcile = createImmutableReconcile({decorator: vi.fn}) beforeEach(() => { - ;(immutableReconcile as vi.Mock).mockClear() + ;(immutableReconcile as Mock).mockClear() }) test('it preserves previous value if shallow equal', () => { diff --git a/packages/sanity/src/core/form/studio/FormBuilder.tsx b/packages/sanity/src/core/form/studio/FormBuilder.tsx index 58e1f1d04bf..95c36662732 100644 --- a/packages/sanity/src/core/form/studio/FormBuilder.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilder.tsx @@ -64,6 +64,7 @@ export interface FormBuilderProps schemaType: ObjectSchemaType validation: ValidationMarker[] value: FormDocumentValue | undefined + version?: string } /** @@ -95,6 +96,7 @@ export function FormBuilder(props: FormBuilderProps) { schemaType, validation, value, + version, } = props const handleCollapseField = useCallback( @@ -273,6 +275,7 @@ export function FormBuilder(props: FormBuilderProps) { validation={validation} readOnly={readOnly} schemaType={schemaType} + version={version} > diff --git a/packages/sanity/src/core/form/studio/FormProvider.tsx b/packages/sanity/src/core/form/studio/FormProvider.tsx index 5e1fbc73a29..e43c957105c 100644 --- a/packages/sanity/src/core/form/studio/FormProvider.tsx +++ b/packages/sanity/src/core/form/studio/FormProvider.tsx @@ -55,6 +55,7 @@ export interface FormProviderProps { readOnly?: boolean schemaType: ObjectSchemaType validation: ValidationMarker[] + version?: string } /** @@ -86,6 +87,7 @@ export function FormProvider(props: FormProviderProps) { readOnly, schemaType, validation, + version, } = props const {file, image} = useSource().form @@ -164,6 +166,7 @@ export function FormProvider(props: FormProviderProps) { renderPreview={renderPreview} schemaType={schemaType} validation={validation} + version={version} > {children} diff --git a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts index d8230175e04..aba54c92a58 100644 --- a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts +++ b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts @@ -1,12 +1,24 @@ import {type SanityClient} from '@sanity/client' import {DEFAULT_MAX_FIELD_DEPTH} from '@sanity/schema/_internal' import {type ReferenceFilterSearchOptions, type ReferenceSchemaType} from '@sanity/types' -import {combineLatest, type Observable, of} from 'rxjs' -import {map, mergeMap, switchMap} from 'rxjs/operators' +import {omit} from 'lodash' +import {combineLatest, from, type Observable, of} from 'rxjs' +import {map, mergeMap, scan, startWith, switchMap} from 'rxjs/operators' -import {type DocumentPreviewStore} from '../../../../preview' +import {type DocumentPreviewStore, getPreviewPaths, prepareForPreview} from '../../../../preview' +import { + type VersionsRecord, + type VersionTuple, +} from '../../../../preview/utils/getPreviewStateObservable' import {createSearch} from '../../../../search' -import {collate, type CollatedHit, getDraftId, getIdPair} from '../../../../util' +import { + collate, + type CollatedHit, + getDraftId, + getIdPair, + getVersionId, + isRecord, +} from '../../../../util' import { type PreviewDocumentValue, type ReferenceInfo, @@ -35,22 +47,31 @@ export function getReferenceInfo( documentPreviewStore: DocumentPreviewStore, id: string, referenceType: ReferenceSchemaType, + {version}: {version?: string} = {}, + perspective: {bundleIds: string[]; bundleStack: string[]} = {bundleIds: [], bundleStack: []}, ): Observable { - const {publishedId, draftId} = getIdPair(id) + const {publishedId, draftId, versionId} = getIdPair(id, {version}) - const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id) + const pairAvailability$ = documentPreviewStore.unstable_observeDocumentPairAvailability(id, { + version, + }) return pairAvailability$.pipe( switchMap((pairAvailability) => { - if (!pairAvailability.draft.available && !pairAvailability.published.available) { + if ( + !pairAvailability.draft.available && + !pairAvailability.published.available && + !pairAvailability.published.available + ) { // combine availability of draft + published const availability = + pairAvailability.version?.reason === 'PERMISSION_DENIED' || pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND - // short circuit, neither draft nor published is available so no point in trying to get preview + // short circuit, neither draft nor published nor version is available so no point in trying to get preview return of({ id, type: undefined, @@ -58,6 +79,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -65,9 +88,13 @@ export function getReferenceInfo( const typeName$ = combineLatest([ documentPreviewStore.observeDocumentTypeFromId(draftId), documentPreviewStore.observeDocumentTypeFromId(publishedId), + ...(versionId ? [documentPreviewStore.observeDocumentTypeFromId(versionId)] : []), ]).pipe( - // assume draft + published are always same type - map(([draftTypeName, publishedTypeName]) => draftTypeName || publishedTypeName), + // assume draft + published + version are always same type + map( + ([draftTypeName, publishedTypeName, versionTypeName]) => + versionTypeName || draftTypeName || publishedTypeName, + ), ) return typeName$.pipe( @@ -84,6 +111,8 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } @@ -99,10 +128,12 @@ export function getReferenceInfo( preview: { draft: undefined, published: undefined, + version: undefined, + versions: {}, }, } as const) } - + const previewPaths = getPreviewPaths(refSchemaType?.preview) || [] const draftPreview$ = documentPreviewStore.observeForPreview( {_id: draftId}, refSchemaType, @@ -113,10 +144,67 @@ export function getReferenceInfo( refSchemaType, ) - const value$ = combineLatest([draftPreview$, publishedPreview$]).pipe( - map(([draft, published]) => ({ + const versions$ = from(perspective.bundleIds).pipe( + mergeMap>((bundleId) => + documentPreviewStore + .observePaths({_id: getVersionId(id, bundleId)}, previewPaths) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => + result + ? [ + bundleId, + { + snapshot: { + _id: versionId, + ...prepareForPreview(result, refSchemaType), + }, + }, + ] + : [bundleId, {snapshot: null}], + ), + ), + ), + scan((byBundleId, [bundleId, value]) => { + if (value.snapshot === null) { + return omit({...byBundleId}, [bundleId]) + } + + return { + ...byBundleId, + [bundleId]: value, + } + }, {}), + startWith({}), + ) + + // Iterate the release stack in descending precedence, returning the highest precedence existing + // version document. + const versionPreview$ = versionId + ? versions$.pipe( + map((versions) => { + for (const bundleId of perspective.bundleStack) { + if (bundleId in versions) { + return versions[bundleId] + } + } + return null + }), + startWith(undefined), + ) + : undefined + + const value$ = combineLatest([ + draftPreview$, + publishedPreview$, + ...(versionPreview$ ? [versionPreview$] : []), + versions$, + ]).pipe( + map(([draft, published, versionValue, versions]) => ({ draft, published, + ...(versionValue ? {version: versionValue} : {}), + versions: versions, })), ) @@ -124,9 +212,12 @@ export function getReferenceInfo( map((value): ReferenceInfo => { const availability = // eslint-disable-next-line no-nested-ternary - pairAvailability.draft.available || pairAvailability.published.available + pairAvailability.version?.available || + pairAvailability.draft.available || + pairAvailability.published.available ? READABLE - : pairAvailability.draft.reason === 'PERMISSION_DENIED' || + : pairAvailability.version?.reason === 'PERMISSION_DENIED' || + pairAvailability.draft.reason === 'PERMISSION_DENIED' || pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND @@ -135,10 +226,17 @@ export function getReferenceInfo( id: publishedId, availability, preview: { - draft: (value.draft.snapshot || undefined) as PreviewDocumentValue | undefined, - published: (value.published.snapshot || undefined) as + draft: (isRecord(value.draft.snapshot) ? value.draft : undefined) as | PreviewDocumentValue | undefined, + published: (isRecord(value.published.snapshot) ? value.published : undefined) as + | PreviewDocumentValue + | undefined, + version: (isRecord(value.version?.snapshot) + ? value.version.snapshot + : undefined) as PreviewDocumentValue | undefined, + + versions: isRecord(value.versions) ? value.versions : {}, }, } }), diff --git a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx index 12ec7a50709..87c0a47b329 100644 --- a/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx +++ b/packages/sanity/src/core/form/studio/inputs/reference/StudioReferenceInput.tsx @@ -25,6 +25,7 @@ import { type EditReferenceEvent, } from '../../../inputs/ReferenceInput/types' import {type ObjectInputProps} from '../../../types' +import {useFormBuilder} from '../../../useFormBuilder' import {useReferenceInputOptions} from '../../contexts' import * as adapter from '../client-adapters/reference' import {resolveUserDefinedFilter} from './resolveUserDefinedFilter' @@ -61,6 +62,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { const schema = useSchema() const maxFieldDepth = useSearchMaxFieldDepth() const documentPreviewStore = useDocumentPreviewStore() + const {version} = useFormBuilder() const {path, schemaType} = props const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = useReferenceInputOptions() @@ -187,6 +189,7 @@ export function StudioReferenceInput(props: StudioReferenceInputProps) { editReferenceLinkComponent={EditReferenceLink} createOptions={createOptions} onEditReference={handleEditReference} + version={version} /> ) } diff --git a/packages/sanity/src/core/form/types/fieldProps.ts b/packages/sanity/src/core/form/types/fieldProps.ts index e530df7bc60..c9095a2141b 100644 --- a/packages/sanity/src/core/form/types/fieldProps.ts +++ b/packages/sanity/src/core/form/types/fieldProps.ts @@ -61,6 +61,7 @@ export interface BaseFieldProps { changed: boolean children: ReactNode renderDefault: (props: FieldProps) => ReactElement + version?: string } /** diff --git a/packages/sanity/src/core/hooks/useConnectionState.ts b/packages/sanity/src/core/hooks/useConnectionState.ts index 2405544535b..b23971a9104 100644 --- a/packages/sanity/src/core/hooks/useConnectionState.ts +++ b/packages/sanity/src/core/hooks/useConnectionState.ts @@ -11,12 +11,16 @@ export type ConnectionState = 'connecting' | 'reconnecting' | 'connected' const INITIAL: ConnectionState = 'connecting' /** @internal */ -export function useConnectionState(publishedDocId: string, docTypeName: string): ConnectionState { +export function useConnectionState( + publishedDocId: string, + docTypeName: string, + {version}: {version?: string} = {}, +): ConnectionState { const documentStore = useDocumentStore() const observable = useMemo( () => - documentStore.pair.documentEvents(publishedDocId, docTypeName).pipe( + documentStore.pair.documentEvents(publishedDocId, docTypeName, version).pipe( map((ev: {type: string}) => ev.type), map((eventType) => eventType !== 'reconnect'), switchMap((isConnected) => @@ -25,7 +29,7 @@ export function useConnectionState(publishedDocId: string, docTypeName: string): startWith(INITIAL as any), distinctUntilChanged(), ), - [docTypeName, documentStore.pair, publishedDocId], + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/hooks/useDocumentOperation.ts b/packages/sanity/src/core/hooks/useDocumentOperation.ts index 4e67302fd06..105de51f1b3 100644 --- a/packages/sanity/src/core/hooks/useDocumentOperation.ts +++ b/packages/sanity/src/core/hooks/useDocumentOperation.ts @@ -4,11 +4,15 @@ import {useObservable} from 'react-rx' import {type OperationsAPI, useDocumentStore} from '../store' /** @internal */ -export function useDocumentOperation(publishedDocId: string, docTypeName: string): OperationsAPI { +export function useDocumentOperation( + publishedDocId: string, + docTypeName: string, + version?: string, +): OperationsAPI { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.editOperations(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.editOperations(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) /** * We know that since the observable has a startWith operator, it will always emit a value diff --git a/packages/sanity/src/core/hooks/useEditState.ts b/packages/sanity/src/core/hooks/useEditState.ts index 6d2650b73cf..771f973f07e 100644 --- a/packages/sanity/src/core/hooks/useEditState.ts +++ b/packages/sanity/src/core/hooks/useEditState.ts @@ -9,12 +9,13 @@ export function useEditState( publishedDocId: string, docTypeName: string, priority: 'default' | 'low' = 'default', + version?: string, ): EditStateFor { const documentStore = useDocumentStore() const observable = useMemo(() => { if (priority === 'low') { - const base = documentStore.pair.editState(publishedDocId, docTypeName).pipe(share()) + const base = documentStore.pair.editState(publishedDocId, docTypeName, version).pipe(share()) return merge( base.pipe(take(1)), @@ -25,8 +26,8 @@ export function useEditState( ) } - return documentStore.pair.editState(publishedDocId, docTypeName) - }, [docTypeName, documentStore.pair, priority, publishedDocId]) + return documentStore.pair.editState(publishedDocId, docTypeName, version) + }, [docTypeName, documentStore.pair, priority, publishedDocId, version]) /** * We know that since the observable has a startWith operator, it will always emit a value * and that's why the non-null assertion is used here diff --git a/packages/sanity/src/core/hooks/useSyncState.ts b/packages/sanity/src/core/hooks/useSyncState.ts index 385d3205a25..65888a19558 100644 --- a/packages/sanity/src/core/hooks/useSyncState.ts +++ b/packages/sanity/src/core/hooks/useSyncState.ts @@ -14,15 +14,19 @@ const SYNCING = {isSyncing: true} const NOT_SYNCING = {isSyncing: false} /** @internal */ -export function useSyncState(publishedDocId: string, documentType: string): SyncState { +export function useSyncState( + publishedDocId: string, + documentType: string, + {version}: {version?: string} = {}, +): SyncState { const documentStore = useDocumentStore() const observable = useMemo( () => documentStore.pair - .consistencyStatus(publishedDocId, documentType) + .consistencyStatus(publishedDocId, documentType, version) .pipe(map((isConsistent) => (isConsistent ? NOT_SYNCING : SYNCING))), - [documentStore.pair, documentType, publishedDocId], + [documentStore.pair, documentType, publishedDocId, version], ) return useObservable>(observable, NOT_SYNCING) } diff --git a/packages/sanity/src/core/hooks/useValidationStatus.ts b/packages/sanity/src/core/hooks/useValidationStatus.ts index 165e059d932..2411b0eabe8 100644 --- a/packages/sanity/src/core/hooks/useValidationStatus.ts +++ b/packages/sanity/src/core/hooks/useValidationStatus.ts @@ -7,12 +7,16 @@ import {type ValidationStatus} from '../validation' const INITIAL: ValidationStatus = {validation: [], isValidating: false} /** @internal */ -export function useValidationStatus(publishedDocId: string, docTypeName: string): ValidationStatus { +export function useValidationStatus( + publishedDocId: string, + docTypeName: string, + version?: string, +): ValidationStatus { const documentStore = useDocumentStore() const observable = useMemo( - () => documentStore.pair.validation(publishedDocId, docTypeName), - [docTypeName, documentStore.pair, publishedDocId], + () => documentStore.pair.validation(publishedDocId, docTypeName, version), + [docTypeName, documentStore.pair, publishedDocId, version], ) return useObservable(observable, INITIAL) } diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts index e46949db912..c916abd719a 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 */ @@ -353,12 +357,19 @@ 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*/ 'document-status.not-published': 'Not published', /** Label to show in the document footer indicating the published date of the document */ 'document-status.published': 'Published {{date}}', + /** Label to show in the document footer indicating the revision from date of the document */ + 'document-status.revision-from': 'Revision from {{date}}', /** The value of the _key property must be a unique string. */ 'form.error.duplicate-keys-alert.details.additional-description': @@ -1132,6 +1143,83 @@ export const studioLocaleStrings = defineLocalesResources('studio', { /* Relative time, just now */ 'relative-time.just-now': 'just now', + /** Action message to add document to release */ + 'release.action.add-to-release': 'Add to {{title}}', + /** Action message for when document is already in release */ + 'release.action.already-in-release': 'Already in release {{title}}', + /** Action message for when you click to view all versions you can copy the current document to */ + 'release.action.copy-to': 'Copy version to', + /** Action message for creating new releases */ + 'release.action.create-new': 'New release', + /** Action message for when document is already in release */ + 'release.action.discard-version': 'Discard version', + /** Description for toast when version discarding failed */ + 'release.action.discard-version.failure': 'Failed to discard version', + /** Description for toast when version deletion is successfully discarded */ + 'release.action.discard-version.success': + '{{title}} version was successfully discarded', + /** Action message for when a new release is created off an existing version, draft or published document */ + 'release.action.new-release': 'New Release', + /** Action message for when the view release is pressed */ + 'release.action.view-release': 'View release', + /** Label for banner when release is scheduled */ + 'release.banner.scheduled-for-publishing-on': 'Scheduled for publishing on {{date}}', + /** Label for Draft chip in document header */ + 'release.chip.draft': 'Draft', + /** Label for Published chip in document header */ + 'release.chip.published': 'Published', + /** Label for tooltip in chip with the created date */ + 'release.chip.tooltip.created-date': 'Created {{date}}', + /** Label for tooltip in chip with the lasted edited date */ + 'release.chip.tooltip.edited-date': 'Edited {{date}}', + /** Label for tooltip in chip when document is intended for a future release that hasn't been scheduled */ + 'release.chip.tooltip.intended-for-date': 'Intended for {{date}}', + /** Label for tooltip in chip when there is no recent draft edits */ + 'release.chip.tooltip.no-edits': 'No edits', + /** Label for tooltip in chip when document isn't published */ + 'release.chip.tooltip.not-published': 'Not published', + /** Label for tooltip in chip with the published date */ + 'release.chip.tooltip.published-date': 'Published {{date}}', + /** Label for tooltip in chip when document is in a release that has been scheduled */ + 'release.chip.tooltip.scheduled-for-date': 'Scheduled for {{date}}', + /** Label for tooltip in scheduled chip without a known date */ + 'release.chip.tooltip.unknown-date': 'Unknown date', + /** Label for tooltip on deleted release */ + 'release.deleted-tooltip': 'This release has been deleted', + /** Title for creating releases dialog */ + 'release.dialog.create.title': 'Create release', + /** Label for description in tooltip to explain release types */ + 'release.dialog.tooltip.description': + 'This makes it possible to show whether documents are in conflict when working on multiple versions.', + /** Label for noting that a release time is not final */ + 'release.dialog.tooltip.note': + 'NOTE: You may change the time of release and set an exact time for scheduled publishing later.', + /** Title for tooltip to explain release time */ + 'release.dialog.tooltip.title': 'Approximate time of release', + /** The placeholder text when the release doesn't have a description */ + 'release.form.placeholer-describe-release': 'Describe the release…', + /** Tooltip for button to hide release visibility */ + 'release.layer.hide': 'Hide release', + /** Label for 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', + /** 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 */ diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index a2e38b22cde..2267d06ea61 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -22,10 +22,29 @@ export * from './hooks' export * from './i18n' export * from './presence' export * from './preview' +export { + AddedVersion, + DiscardVersionDialog, + getBundleIdFromReleaseDocumentId, + getPublishDateFromRelease, + getReleaseTone, + isDraftPerspective, + isPublishedPerspective, + isReleaseDocument, + isReleaseScheduledOrScheduling, + LATEST, + type ReleaseDocument, + useDocumentVersions, + usePerspective, + useReleases, + useVersionOperations, + VersionChip, + versionDocumentExists, +} from './releases' export * from './scheduledPublishing' export * from './schema' export type {SearchFactoryOptions, SearchOptions, SearchSort, SearchTerms} from './search' -export {createSearch, getSearchableTypes} from './search' +export {createSearch, getSearchableTypes, isPerspectiveRaw} from './search' export * from './store' export * from './studio' export * from './studioClient' diff --git a/packages/sanity/src/core/preview/availability.ts b/packages/sanity/src/core/preview/availability.ts index 52131be2c7f..cb5bfaf0218 100644 --- a/packages/sanity/src/core/preview/availability.ts +++ b/packages/sanity/src/core/preview/availability.ts @@ -6,7 +6,7 @@ import {combineLatest, defer, from, type Observable, of} from 'rxjs' import {distinctUntilChanged, map, mergeMap, reduce, switchMap} from 'rxjs/operators' import shallowEquals from 'shallow-equals' -import {createSWR, getDraftId, getPublishedId, isRecord} from '../util' +import {createSWR, getDraftId, getPublishedId, getVersionId, isRecord, isVersionId} from '../util' import { AVAILABILITY_NOT_FOUND, AVAILABILITY_PERMISSION_DENIED, @@ -146,18 +146,26 @@ export function createPreviewAvailabilityObserver( */ return function observeDocumentPairAvailability( id: string, + {version}: {version?: string} = {}, ): Observable { const draftId = getDraftId(id) const publishedId = getPublishedId(id) + const versionId = isVersionId(id) && version ? getVersionId(id, version) : undefined return combineLatest([ observeDocumentAvailability(draftId), observeDocumentAvailability(publishedId), + ...(versionId ? [observeDocumentAvailability(versionId)] : []), ]).pipe( distinctUntilChanged(shallowEquals), - map(([draftReadability, publishedReadability]) => { + map(([draftReadability, publishedReadability, versionReadability]) => { return { draft: draftReadability, published: publishedReadability, + ...(versionReadability + ? { + version: versionReadability, + } + : {}), } }), ) diff --git a/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx b/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx index 247e059e083..94b325d9135 100644 --- a/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx +++ b/packages/sanity/src/core/preview/components/SanityDefaultPreview.tsx @@ -132,8 +132,8 @@ export function SanityDefaultPreview(props: SanityDefaultPreviewProps): ReactEle {/* Currently tooltips won't trigger without a wrapping element */}
{children}
diff --git a/packages/sanity/src/core/preview/createGlobalListener.ts b/packages/sanity/src/core/preview/createGlobalListener.ts index ac76417b386..7be43f93e31 100644 --- a/packages/sanity/src/core/preview/createGlobalListener.ts +++ b/packages/sanity/src/core/preview/createGlobalListener.ts @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) { includePreviousRevision: false, includeMutations: false, visibility: 'query', + effectFormat: 'mendoza', tag: 'preview.global', }, ) diff --git a/packages/sanity/src/core/preview/createObserveDocument.ts b/packages/sanity/src/core/preview/createObserveDocument.ts new file mode 100644 index 00000000000..6b384a7fbf8 --- /dev/null +++ b/packages/sanity/src/core/preview/createObserveDocument.ts @@ -0,0 +1,87 @@ +import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {memoize, uniq} from 'lodash' +import {EMPTY, finalize, type Observable, of} from 'rxjs' +import {concatMap, map, scan, shareReplay} from 'rxjs/operators' + +import {type ApiConfig} from './types' +import {applyMendozaPatch} from './utils/applyMendozaPatch' +import {debounceCollect} from './utils/debounceCollect' + +export function createObserveDocument({ + mutationChannel, + client, +}: { + client: SanityClient + mutationChannel: Observable +}) { + const getBatchFetcher = memoize( + function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) { + const _client = client.withConfig(apiConfig) + + function batchFetchDocuments(ids: [string][]) { + return _client.observable + .fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'}) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))), + ) + } + return debounceCollect(batchFetchDocuments, 100) + }, + (apiConfig) => apiConfig.dataset + apiConfig.projectId, + ) + + const MEMO: Record> = {} + + function observeDocument(id: string, apiConfig?: ApiConfig) { + const _apiConfig = apiConfig || { + dataset: client.config().dataset!, + projectId: client.config().projectId!, + } + const fetchDocument = getBatchFetcher(_apiConfig) + return mutationChannel.pipe( + concatMap((event) => { + if (event.type === 'welcome') { + return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document}))) + } + return event.documentId === id ? of(event) : EMPTY + }), + scan((current: SanityDocument | undefined, event) => { + if (event.type === 'sync') { + return event.document + } + if (event.type === 'mutation') { + return applyMutationEvent(current, event) + } + //@ts-expect-error - this should never happen + throw new Error(`Unexpected event type: "${event.type}"`) + }, undefined), + ) + } + return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) { + const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id + if (!(key in MEMO)) { + MEMO[key] = observeDocument(id, apiConfig).pipe( + finalize(() => delete MEMO[key]), + shareReplay({bufferSize: 1, refCount: true}), + ) + } + return MEMO[key] + } +} + +function applyMutationEvent(current: SanityDocument | undefined, event: MutationEvent) { + if (event.previousRev !== current?._rev) { + console.warn('Document out of sync, skipping mutation') + return current + } + if (!event.effects) { + throw new Error( + 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', + ) + } + const next = applyMendozaPatch(current, event.effects.apply) + // next will be undefined in case of deletion + return next ? {...next, _rev: event.resultRev} : undefined +} diff --git a/packages/sanity/src/core/preview/createPreviewObserver.ts b/packages/sanity/src/core/preview/createPreviewObserver.ts index 49b294f961c..20bde34d05f 100644 --- a/packages/sanity/src/core/preview/createPreviewObserver.ts +++ b/packages/sanity/src/core/preview/createPreviewObserver.ts @@ -42,6 +42,7 @@ export function createPreviewObserver(context: { value: Previewable, type: PreviewableType, options: { + perspective?: string viewOptions?: PrepareViewOptions apiConfig?: ApiConfig } = {}, diff --git a/packages/sanity/src/core/preview/documentPair.ts b/packages/sanity/src/core/preview/documentPair.ts index dae8945130e..1d618af1a38 100644 --- a/packages/sanity/src/core/preview/documentPair.ts +++ b/packages/sanity/src/core/preview/documentPair.ts @@ -19,15 +19,21 @@ export function createObservePathsDocumentPair(options: { ) => Observable> { const {observeDocumentPairAvailability, observePaths} = options - const ALWAYS_INCLUDED_SNAPSHOT_PATHS: PreviewPath[] = [['_updatedAt'], ['_createdAt'], ['_type']] + const ALWAYS_INCLUDED_SNAPSHOT_PATHS: PreviewPath[] = [ + ['_updatedAt'], + ['_createdAt'], + ['_type'], + ['_version'], + ] return function observePathsDocumentPair( id: string, paths: PreviewPath[], + {version}: {version?: string} = {}, ): Observable> { - const {draftId, publishedId} = getIdPair(id) + const {draftId, publishedId, versionId} = getIdPair(id, {version}) - return observeDocumentPairAvailability(draftId).pipe( + return observeDocumentPairAvailability(draftId, {version}).pipe( switchMap((availability) => { if (!availability.draft.available && !availability.published.available) { // short circuit, neither draft nor published is available so no point in trying to get a snapshot @@ -42,6 +48,14 @@ export function createObservePathsDocumentPair(options: { availability: availability.published, snapshot: undefined, }, + ...(availability.version + ? { + version: { + availability: availability.version, + snapshot: undefined, + }, + } + : {}), }) } @@ -50,10 +64,12 @@ export function createObservePathsDocumentPair(options: { return combineLatest([ observePaths({_type: 'reference', _ref: draftId}, snapshotPaths), observePaths({_type: 'reference', _ref: publishedId}, snapshotPaths), + ...(version ? [observePaths({_type: 'reference', _ref: versionId}, snapshotPaths)] : []), ]).pipe( - map(([draftSnapshot, publishedSnapshot]) => { + map(([draftSnapshot, publishedSnapshot, versionSnapshot]) => { // note: assume type is always the same const type = + (isRecord(versionSnapshot) && '_type' in versionSnapshot && versionSnapshot._type) || (isRecord(draftSnapshot) && '_type' in draftSnapshot && draftSnapshot._type) || (isRecord(publishedSnapshot) && '_type' in publishedSnapshot && @@ -71,6 +87,14 @@ export function createObservePathsDocumentPair(options: { availability: availability.published, snapshot: publishedSnapshot as T, }, + ...(availability.version + ? { + version: { + availability: availability.version, + snapshot: versionSnapshot as T, + }, + } + : {}), } }), ) diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index 154c7e56c26..8f21f972fef 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,14 +1,21 @@ -import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import { + type MutationEvent, + type QueryParams, + type SanityClient, + type WelcomeEvent, +} from '@sanity/client' import {type PrepareViewOptions, type SanityDocument} from '@sanity/types' -import {type Observable} from 'rxjs' +import {combineLatest, type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' import {isRecord} from '../util' import {createPreviewAvailabilityObserver} from './availability' import {createGlobalListener} from './createGlobalListener' +import {createObserveDocument} from './createObserveDocument' import {createPathObserver} from './createPathObserver' import {createPreviewObserver} from './createPreviewObserver' import {createObservePathsDocumentPair} from './documentPair' +import {createDocumentIdSetObserver, type DocumentIdSetObserverState} from './liveDocumentIdSet' import {createObserveFields} from './observeFields' import { type ApiConfig, @@ -50,12 +57,51 @@ export interface DocumentPreviewStore { */ unstable_observeDocumentPairAvailability: ( id: string, + options?: {version?: string}, ) => Observable unstable_observePathsDocumentPair: ( id: string, paths: PreviewPath[], + options?: {version?: string}, ) => Observable> + + /** + * Observes a set of document IDs that matches the given groq-filter. The document ids are returned in ascending order and will update in real-time + * Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be pushed to subscribers. + * The query is performed once, initially, and thereafter the set of ids are patched based on the `appear` and `disappear` + * transitions on the received listener events. + * This provides a lightweight way of subscribing to a list of ids for simple cases where you just want to subscribe to a set of documents ids + * that matches a particular filter. + * @hidden + * @beta + * @param filter - A groq filter to use for the document set + * @param params - Parameters to use with the groq filter + * @param options - Options for the observer + */ + unstable_observeDocumentIdSet: ( + filter: string, + params?: QueryParams, + options?: { + /** + * Where to insert new items into the set. Defaults to 'sorted' which is based on the lexicographic order of the id + */ + insert?: 'sorted' | 'prepend' | 'append' + }, + ) => Observable + + /** + * Observe a complete document with the given ID + * @hidden + * @beta + */ + unstable_observeDocument: (id: string) => Observable + /** + * Observe a list of complete documents with the given IDs + * @hidden + * @beta + */ + unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]> } /** @internal */ @@ -79,6 +125,8 @@ export function createDocumentPreviewStore({ map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)), ) + const observeDocument = createObserveDocument({client, mutationChannel: globalListener}) + const observeFields = createObserveFields({client: versionedClient, invalidationChannel}) const observePaths = createPathObserver({observeFields}) @@ -86,12 +134,16 @@ export function createDocumentPreviewStore({ id: string, apiConfig?: ApiConfig, ): Observable { - return observePaths({_type: 'reference', _ref: id}, ['_type'], apiConfig).pipe( + return observePaths({_type: 'reference', _ref: id}, ['_type', '_version'], apiConfig).pipe( map((res) => (isRecord(res) && typeof res._type === 'string' ? res._type : undefined)), distinctUntilChanged(), ) } + const observeDocumentIdSet = createDocumentIdSetObserver( + versionedClient.withConfig({apiVersion: 'X'}), + ) + const observeForPreview = createPreviewObserver({observeDocumentTypeFromId, observePaths}) const observeDocumentPairAvailability = createPreviewAvailabilityObserver( versionedClient, @@ -110,6 +162,10 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, + unstable_observeDocumentIdSet: observeDocumentIdSet, + unstable_observeDocument: observeDocument, + unstable_observeDocuments: (ids: string[]) => + combineLatest(ids.map((id) => observeDocument(id))), unstable_observeDocumentPairAvailability: observeDocumentPairAvailability, unstable_observePathsDocumentPair: observePathsDocumentPair, } diff --git a/packages/sanity/src/core/preview/index.ts b/packages/sanity/src/core/preview/index.ts index 5a283024f4d..7e9a3043d57 100644 --- a/packages/sanity/src/core/preview/index.ts +++ b/packages/sanity/src/core/preview/index.ts @@ -3,6 +3,7 @@ export * from './components/PreviewLoader' export * from './components/SanityDefaultPreview' export * from './documentPreviewStore' export * from './types' +export {useObserveDocument as unstable_useObserveDocument} from './useObserveDocument' export * from './useValuePreview' export {getPreviewPaths} from './utils/getPreviewPaths' export {getPreviewStateObservable} from './utils/getPreviewStateObservable' diff --git a/packages/sanity/src/core/preview/liveDocumentIdSet.ts b/packages/sanity/src/core/preview/liveDocumentIdSet.ts new file mode 100644 index 00000000000..49d8401cde1 --- /dev/null +++ b/packages/sanity/src/core/preview/liveDocumentIdSet.ts @@ -0,0 +1,112 @@ +import {type QueryParams, type SanityClient} from '@sanity/client' +import {sortedIndex} from 'lodash' +import {of} from 'rxjs' +import {distinctUntilChanged, filter, map, mergeMap, scan, tap} from 'rxjs/operators' + +export type DocumentIdSetObserverState = { + status: 'reconnecting' | 'connected' + documentIds: string[] +} + +interface LiveDocumentIdSetOptions { + insert?: 'sorted' | 'prepend' | 'append' +} + +export function createDocumentIdSetObserver(client: SanityClient) { + return function observe( + queryFilter: string, + params?: QueryParams, + options: LiveDocumentIdSetOptions = {}, + ) { + const {insert: insertOption = 'sorted'} = options + + const query = `*[${queryFilter}]._id` + function fetchFilter() { + return client.observable + .fetch(query, params, { + tag: 'preview.observe-document-set.fetch', + }) + .pipe( + tap((result) => { + if (!Array.isArray(result)) { + throw new Error( + `Expected query to return array of documents, but got ${typeof result}`, + ) + } + }), + ) + } + return client.observable + .listen(query, params, { + visibility: 'transaction', + events: ['welcome', 'mutation', 'reconnect'], + includeResult: false, + includeMutations: false, + tag: 'preview.observe-document-set.listen', + }) + .pipe( + mergeMap((event) => { + return event.type === 'welcome' + ? fetchFilter().pipe(map((result) => ({type: 'fetch' as const, result}))) + : of(event) + }), + scan( + ( + state: DocumentIdSetObserverState | undefined, + event, + ): DocumentIdSetObserverState | undefined => { + if (event.type === 'reconnect') { + return { + documentIds: state?.documentIds || [], + ...state, + status: 'reconnecting' as const, + } + } + if (event.type === 'fetch') { + return {...state, status: 'connected' as const, documentIds: event.result} + } + if (event.type === 'mutation') { + if (event.transition === 'update') { + // ignore updates, as we're only interested in documents appearing and disappearing from the set + return state + } + if (event.transition === 'appear') { + return { + status: 'connected', + documentIds: insert(state?.documentIds || [], event.documentId, insertOption), + } + } + if (event.transition === 'disappear') { + return { + status: 'connected', + documentIds: state?.documentIds + ? state.documentIds.filter((id) => id !== event.documentId) + : [], + } + } + } + return state + }, + undefined, + ), + distinctUntilChanged(), + filter( + (state: DocumentIdSetObserverState | undefined): state is DocumentIdSetObserverState => + state !== undefined, + ), + ) + } +} + +function insert(array: T[], element: T, strategy: 'sorted' | 'prepend' | 'append') { + let index + if (strategy === 'prepend') { + index = 0 + } else if (strategy === 'append') { + index = array.length + } else { + index = sortedIndex(array, element) + } + + return array.toSpliced(index, 0, element) +} diff --git a/packages/sanity/src/core/preview/types.ts b/packages/sanity/src/core/preview/types.ts index 0adca210afe..37d57011b17 100644 --- a/packages/sanity/src/core/preview/types.ts +++ b/packages/sanity/src/core/preview/types.ts @@ -91,6 +91,12 @@ export interface DraftsModelDocumentAvailability { * document readability for the draft document */ draft: DocumentAvailability + + /** + * document readability for the version document + */ + version?: DocumentAvailability + // TODO: validate versions availability? } /** @@ -107,6 +113,10 @@ export interface DraftsModelDocument + ( + id: string, + options?: {version?: string}, + ): Observable<{ + draft: DocumentAvailability + published: DocumentAvailability + version?: DocumentAvailability + }> } diff --git a/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts new file mode 100644 index 00000000000..2fa3eff626d --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts @@ -0,0 +1,47 @@ +import {type QueryParams} from '@sanity/client' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {scan} from 'rxjs/operators' + +import {useDocumentPreviewStore} from '../store/_legacy/datastores' +import {type DocumentIdSetObserverState} from './liveDocumentIdSet' + +const INITIAL_STATE = {status: 'loading' as const, documentIds: []} + +export type LiveDocumentSetState = + | {status: 'loading'; documentIds: string[]} + | DocumentIdSetObserverState + +/** + * @internal + * @beta + * Returns document ids that matches the provided GROQ-filter, and loading state + * The document ids are returned in ascending order and will update in real-time + * Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be returned. + * This provides a lightweight way of subscribing to a list of ids for simple cases where you just want the documents ids + * that matches a particular filter. + */ +export function useLiveDocumentIdSet( + filter: string, + params?: QueryParams, + options: { + // how to insert new document ids. Defaults to `sorted` + insert?: 'sorted' | 'prepend' | 'append' + } = {}, +) { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo( + () => + documentPreviewStore.unstable_observeDocumentIdSet(filter, params, options).pipe( + scan( + (currentState: LiveDocumentSetState, nextState) => ({ + ...currentState, + ...nextState, + }), + INITIAL_STATE, + ), + ), + [documentPreviewStore, filter, params, options], + ) + return useObservable(observable, INITIAL_STATE) +} diff --git a/packages/sanity/src/core/preview/useLiveDocumentSet.ts b/packages/sanity/src/core/preview/useLiveDocumentSet.ts new file mode 100644 index 00000000000..16c5c27be24 --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentSet.ts @@ -0,0 +1,34 @@ +import {type QueryParams} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {map} from 'rxjs/operators' +import {mergeMapArray} from 'rxjs-mergemap-array' + +import {useDocumentPreviewStore} from '../store' + +const INITIAL_VALUE = {loading: true, documents: []} + +/** + * @internal + * @beta + * + * Observes a set of documents matching the filter and returns an array of complete documents + * A new array will be pushed whenever a document in the set changes + * Document ids are returned in ascending order + * Any sorting beyond that must happen client side + */ +export function useLiveDocumentSet( + groqFilter: string, + params?: QueryParams, +): {loading: boolean; documents: SanityDocument[]} { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo(() => { + return documentPreviewStore.unstable_observeDocumentIdSet(groqFilter, params).pipe( + map((state) => (state.documentIds || []) as string[]), + mergeMapArray((id) => documentPreviewStore.unstable_observeDocument(id)), + map((docs) => ({loading: false, documents: docs as SanityDocument[]})), + ) + }, [documentPreviewStore, groqFilter, params]) + return useObservable(observable, INITIAL_VALUE) +} diff --git a/packages/sanity/src/core/preview/useObserveDocument.ts b/packages/sanity/src/core/preview/useObserveDocument.ts new file mode 100644 index 00000000000..7d386265984 --- /dev/null +++ b/packages/sanity/src/core/preview/useObserveDocument.ts @@ -0,0 +1,32 @@ +import {type SanityDocument} from '@sanity/types' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {map} from 'rxjs/operators' + +import {useDocumentPreviewStore} from '../store/_legacy/datastores' + +const INITIAL_STATE = {loading: true, document: null} + +/** + * @internal + * @beta + * + * Observes a document by its ID and returns the document and loading state + * it will listen to the document changes. + */ +export function useObserveDocument( + documentId: string, +): { + document: T | null + loading: boolean +} { + const documentPreviewStore = useDocumentPreviewStore() + const observable = useMemo( + () => + documentPreviewStore + .unstable_observeDocument(documentId) + .pipe(map((document) => ({loading: false, document: document as T}))), + [documentId, documentPreviewStore], + ) + return useObservable(observable, INITIAL_STATE) +} diff --git a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts new file mode 100644 index 00000000000..0c1be69450c --- /dev/null +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -0,0 +1,18 @@ +import {type SanityDocument} from '@sanity/types' +import {applyPatch, type RawPatch} from 'mendoza' + +function omitRev(document: SanityDocument | undefined) { + if (document === undefined) { + return undefined + } + const {_rev, ...doc} = document + return doc +} + +export function applyMendozaPatch( + document: SanityDocument | undefined, + patch: RawPatch, +): SanityDocument | undefined { + const next = applyPatch(omitRev(document), patch) + return next === null ? undefined : next +} diff --git a/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts b/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts index 2926441e648..5b57acf1fba 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewPaths.test.ts @@ -32,6 +32,7 @@ describe('getPreviewPaths', () => { ['image'], ['_createdAt'], ['_updatedAt'], + ['_version'], ]) }) }) diff --git a/packages/sanity/src/core/preview/utils/getPreviewPaths.ts b/packages/sanity/src/core/preview/utils/getPreviewPaths.ts index 5ef09e29fb2..ccb6c2af8b2 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewPaths.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewPaths.ts @@ -1,6 +1,6 @@ import {type PreviewableType, type PreviewPath} from '../types' -const DEFAULT_PREVIEW_PATHS: PreviewPath[] = [['_createdAt'], ['_updatedAt']] +const DEFAULT_PREVIEW_PATHS: PreviewPath[] = [['_createdAt'], ['_updatedAt'], ['_version']] /** @internal */ export function getPreviewPaths(preview: PreviewableType['preview']): PreviewPath[] | undefined { diff --git a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts index f37e7490868..e78cbe28c6f 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -1,15 +1,32 @@ import {type PreviewValue, type SanityDocument, type SchemaType} from '@sanity/types' +import {omit} from 'lodash' import {type ReactNode} from 'react' -import {combineLatest, type Observable, of} from 'rxjs' -import {map, startWith} from 'rxjs/operators' +import {combineLatest, from, type Observable, of} from 'rxjs' +import {map, mergeMap, scan, startWith} from 'rxjs/operators' +import {type PreparedSnapshot} from 'sanity' -import {getDraftId, getPublishedId} from '../../util/draftUtils' +import { + getDraftId, + getPublishedId, + getVersionFromId, + getVersionId, + isVersionId, +} from '../../util/draftUtils' import {type DocumentPreviewStore} from '../documentPreviewStore' +/** + * @internal + */ +export type VersionsRecord = Record + +export type VersionTuple = [bundleId: string, snapshot: PreparedSnapshot] + export interface PreviewState { isLoading?: boolean draft?: PreviewValue | Partial | null published?: PreviewValue | Partial | null + version?: PreviewValue | Partial | null + versions: VersionsRecord } const isLiveEditEnabled = (schemaType: SchemaType) => schemaType.liveEdit === true @@ -24,22 +41,93 @@ export function getPreviewStateObservable( schemaType: SchemaType, documentId: string, title: ReactNode, + perspective: { + /** + * An array of all existing bundle ids. + */ + bundleIds: string[] + + /** + * An array of release ids ordered chronologically to represent the state of documents at the + * given point in time. + */ + bundleStack: string[] + + /** + * Perspective to use when fetching versions. + * Sometimes we want to fetch versions from a perspective not bound by the bundleStack + * (e.g. raw). + */ + isRaw?: boolean + } = { + bundleIds: [], + bundleStack: [], + isRaw: false, + }, ): Observable { const draft$ = isLiveEditEnabled(schemaType) ? of({snapshot: null}) : documentPreviewStore.observeForPreview({_id: getDraftId(documentId)}, schemaType) + const versions$ = from(perspective.bundleIds).pipe( + mergeMap>((bundleId) => + documentPreviewStore + .observeForPreview({_id: getVersionId(documentId, bundleId)}, schemaType) + .pipe(map((storeValue) => [bundleId, storeValue])), + ), + scan((byBundleId, [bundleId, value]) => { + if (value.snapshot === null) { + return omit({...byBundleId}, [bundleId]) + } + + return { + ...byBundleId, + [bundleId]: value, + } + }, {}), + startWith({}), + ) + + const list = perspective.isRaw ? perspective.bundleIds : perspective.bundleStack + // Iterate the release stack in descending precedence, returning the highest precedence existing + // version document. + const version$ = versions$.pipe( + map((versions) => { + if (perspective.isRaw && versions && isVersionId(documentId)) { + const versionId = getVersionFromId(documentId) ?? '' + if (versionId in versions) { + return versions[versionId] + } + } + for (const bundleId of list) { + if (bundleId in versions) { + return versions[bundleId] + } + } + return {snapshot: null} + }), + startWith({snapshot: null}), + ) + const published$ = documentPreviewStore.observeForPreview( {_id: getPublishedId(documentId)}, schemaType, ) - return combineLatest([draft$, published$]).pipe( - map(([draft, published]) => ({ + return combineLatest([draft$, published$, version$, versions$]).pipe( + map(([draft, published, version, versions]) => ({ draft: draft.snapshot ? {title, ...(draft.snapshot || {})} : null, isLoading: false, published: published.snapshot ? {title, ...(published.snapshot || {})} : null, + version: version.snapshot ? {title, ...(version.snapshot || {})} : null, + versions, })), - startWith({draft: null, isLoading: true, published: null}), + startWith({ + draft: null, + isLoading: true, + published: null, + version: null, + versions: {}, + }), ) } diff --git a/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx b/packages/sanity/src/core/preview/utils/getPreviewValueWithFallback.tsx index 7f41bbbd800..d13e282d610 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, resolveBundlePerspective} from '../../util' + const getMissingDocumentFallback = (item: SanityDocument) => ({ title: {item.title ? String(item.title) : 'Missing document'}, subtitle: {item.title ? `Missing document ID: ${item._id}` : `Document ID: ${item._id}`}, @@ -18,12 +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 typeof resolveBundlePerspective(perspective) !== 'undefined' || isVersionId(value._id): + snapshot = version || draft || published + break + case perspective === 'published': + snapshot = published || draft + break + default: + snapshot = draft || published + } + } if (!snapshot) { return getMissingDocumentFallback(value) diff --git a/packages/sanity/src/core/preview/utils/replayLatest.test.ts b/packages/sanity/src/core/preview/utils/replayLatest.test.ts new file mode 100644 index 00000000000..5b0b52f3f21 --- /dev/null +++ b/packages/sanity/src/core/preview/utils/replayLatest.test.ts @@ -0,0 +1,37 @@ +import {concat, from, lastValueFrom, of, share, timer} from 'rxjs' +import {concatMap, delay, mergeMap, take, toArray} from 'rxjs/operators' +import {expect, test} from 'vitest' + +import {shareReplayLatest} from './shareReplayLatest' + +test('replayLatest() replays matching value to new subscribers', async () => { + const observable = from(['foo', 'bar', 'baz']).pipe( + concatMap((value) => of(value).pipe(delay(100))), + share(), + shareReplayLatest((v) => v === 'foo'), + ) + + const result = observable.pipe( + mergeMap((value) => + value === 'bar' ? concat(of(value), observable.pipe(take(1))) : of(value), + ), + toArray(), + ) + expect(await lastValueFrom(result)).toEqual(['foo', 'bar', 'foo', 'baz']) +}) + +test('replayLatest() doesnt keep the replay value after resets', async () => { + const observable = timer(0, 10).pipe( + shareReplayLatest({ + resetOnRefCountZero: true, + resetOnComplete: true, + predicate: (v) => v < 2, + }), + ) + + const result = observable.pipe(take(5), toArray()) + expect(await lastValueFrom(result)).toEqual([0, 1, 2, 3, 4]) + + const resultAfter = observable.pipe(take(5), toArray()) + expect(await lastValueFrom(resultAfter)).toEqual([0, 1, 2, 3, 4]) +}) diff --git a/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts new file mode 100644 index 00000000000..e87f02dcdf9 --- /dev/null +++ b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts @@ -0,0 +1,97 @@ +import {type ReleaseDocument} from '../store/types' + +export const activeScheduledRelease: ReleaseDocument = { + _id: '_.releases.activeRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'active', + name: 'activeRelease', + metadata: { + title: 'active Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-10T10:00:00Z', + description: 'active Release description', + }, +} + +export const scheduledRelease: ReleaseDocument = { + _id: '_.releases.scheduledRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'scheduled', + name: 'scheduledRelease', + 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 = { + _id: '_.releases.activeASAPRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-01T08:00:00Z', + _updatedAt: '2023-10-01T09:00:00Z', + state: 'active', + name: 'activeRelease', + metadata: { + title: 'active asap Release', + releaseType: 'asap', + description: 'active Release description', + }, +} + +export const archivedScheduledRelease: ReleaseDocument = { + _id: '_.releases.archivedRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'archived', + name: 'archivedRelease', + metadata: { + title: 'archived Release', + releaseType: 'scheduled', + intendedPublishAt: '2023-10-10T10:00:00Z', + description: 'archived Release description', + }, +} + +export const publishedASAPRelease: ReleaseDocument = { + _id: '_.releases.publishedRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'published', + name: 'publishedRelease', + publishAt: '2023-10-10T09:00:00Z', + metadata: { + title: 'published Release', + releaseType: 'asap', + intendedPublishAt: '2023-10-10T09:00:00Z', + description: 'archived Release description', + }, +} + +export const activeUndecidedRelease: ReleaseDocument = { + _id: '_.releases.undecidedRelease', + _type: 'system.release', + createdBy: '', + _createdAt: '2023-10-10T08:00:00Z', + _updatedAt: '2023-10-10T09:00:00Z', + state: 'active', + name: 'undecidedRelease', + 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..5abdf62f2e0 --- /dev/null +++ b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts @@ -0,0 +1,103 @@ +import {defineEvent} from '@sanity/telemetry' + +import {type VersionOriginTypes} from '../index' + +interface VersionInfo { + /** + * document type that was added + */ + + /** + * the origin of the version created (from a draft or from a version) + */ + documentOrigin: VersionOriginTypes +} + +export interface OriginInfo { + /** + * determines where the release was created, either from the structure view or the release plugin + */ + origin: 'structure' | 'release-plugin' +} + +/** + * When a document (version) is successfully added to a release + * @internal + */ +export const AddedVersion = defineEvent({ + name: 'Add version of document to release', + version: 1, + description: 'User added a document to a release', +}) + +/** When a release is successfully created + * @internal + */ +export const CreatedRelease = defineEvent({ + name: 'Create release', + version: 1, + description: 'User created a release', +}) + +/** When a release is successfully updated + * @internal + */ +export const UpdatedRelease = defineEvent({ + name: 'Update release', + version: 1, + description: 'User updated a release', +}) + +/** When a release is successfully deleted + * @internal + */ +export const DeletedRelease = defineEvent({ + name: 'Delete release', + version: 1, + description: 'User deleted a release', +}) + +/** When a release is successfully published + * @internal + */ +export const PublishedRelease = defineEvent({ + name: 'Publish release', + version: 1, + description: 'User published a release', +}) + +/** When a release is successfully scheduled + * @internal + */ +export const ScheduledRelease = defineEvent({ + name: 'Schedule release', + version: 1, + description: 'User scheduled a release', +}) + +/** When a release is successfully scheduled + * @internal + */ +export const UnscheduledRelease = defineEvent({ + name: 'Unschedule release', + version: 1, + description: 'User unscheduled a release', +}) + +/** When a release is successfully archived + * @internal + */ +export const ArchivedRelease = defineEvent({ + name: 'Archive release', + version: 1, + description: 'User archived a release', +}) + +/** When a release is successfully unarchived + * @internal + */ +export const UnarchivedRelease = defineEvent({ + name: 'Unarchive release', + version: 1, + description: 'User unarchived a release', +}) diff --git a/packages/sanity/src/core/releases/components/ReleaseAvatar.tsx b/packages/sanity/src/core/releases/components/ReleaseAvatar.tsx new file mode 100644 index 00000000000..b075d194a40 --- /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 +}): JSX.Element { + return ( + + + + + + ) +} 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..28310f51424 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx @@ -0,0 +1,104 @@ +import {ArrowRightIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Flex, useToast} from '@sanity/ui' +import {type FormEvent, useCallback, useState} from 'react' + +import {Button, Dialog} from '../../../../ui-components' +import {useTranslation} from '../../../i18n' +import {CreatedRelease, type OriginInfo} from '../../__telemetry__/releases.telemetry' +import {type EditableReleaseDocument} from '../../store/types' +import {useReleaseOperations} from '../../store/useReleaseOperations' +import {DEFAULT_RELEASE_TYPE} from '../../util/const' +import {createReleaseId} from '../../util/createReleaseId' +import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' +import {ReleaseForm} from './ReleaseForm' + +interface CreateReleaseDialogProps { + onCancel: () => void + onSubmit: (createdReleaseId: string) => void + origin?: OriginInfo['origin'] +} + +export function CreateReleaseDialog(props: CreateReleaseDialogProps): JSX.Element { + const {onCancel, onSubmit, origin} = props + const toast = useToast() + const {createRelease} = useReleaseOperations() + const {t} = useTranslation() + const telemetry = useTelemetry() + + const [value, setValue] = useState((): EditableReleaseDocument => { + return { + _id: createReleaseId(), + metadata: { + title: '', + description: '', + releaseType: DEFAULT_RELEASE_TYPE, + }, + } as const + }) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleOnSubmit = useCallback( + async (event: FormEvent) => { + try { + event.preventDefault() + setIsSubmitting(true) + + const submitValue = { + ...value, + metadata: {...value.metadata, title: value.metadata?.title?.trim()}, + } + await createRelease(submitValue) + telemetry.log(CreatedRelease, {origin}) + } catch (err) { + console.error(err) + toast.push({ + closable: true, + status: 'error', + title: `Failed to create release`, + }) + } finally { + // TODO: Remove this! temporary fix to give some time for the release to be created and the releases store state updated before closing the dialog. + await new Promise((resolve) => setTimeout(resolve, 1000)) + // TODO: Remove the upper part + + setIsSubmitting(false) + onSubmit(getBundleIdFromReleaseDocumentId(value._id)) + } + }, + [value, createRelease, telemetry, origin, toast, onSubmit], + ) + + const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => { + setValue(changedValue) + }, []) + + const dialogTitle = t('release.dialog.create.title') + + return ( + +
+ + + + +
+ ) +} diff --git a/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx new file mode 100644 index 00000000000..1b6af447508 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx @@ -0,0 +1,81 @@ +import {Box} from '@sanity/ui' +import {useCallback, useState} from 'react' + +import {Dialog} from '../../../../ui-components' +import {LoadingBlock} from '../../../components' +import {useDocumentOperation, useSchema} from '../../../hooks' +import {useTranslation} from '../../../i18n' +import {Preview} from '../../../preview' +import {getPublishedId, getVersionFromId, isVersionId} from '../../../util/draftUtils' +import {usePerspective, useVersionOperations} from '../../hooks' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store' +import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' + +/** + * @internal + */ +export function DiscardVersionDialog(props: { + onClose: () => void + documentId: string + documentType: string +}): JSX.Element { + const {onClose, documentId, documentType} = props + const {t} = useTranslation(releasesLocaleNamespace) + const {discardChanges} = useDocumentOperation(getPublishedId(documentId), documentType) + + const {currentGlobalBundle} = 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) || + getBundleIdFromReleaseDocumentId((currentGlobalBundle as ReleaseDocument)._id), + documentId, + ) + } else { + // on the document header you can also discard the draft + discardChanges.execute() + } + + setIsDiscarding(false) + + onClose() + }, [currentGlobalBundle, 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..6695c6c83d8 --- /dev/null +++ b/packages/sanity/src/core/releases/components/dialog/ReleaseForm.tsx @@ -0,0 +1,165 @@ +import {EarthGlobeIcon, InfoOutlineIcon} from '@sanity/icons' +import {Card, Flex, Stack, TabList, TabPanel, Text} from '@sanity/ui' +import {format, isValid} from 'date-fns' +import {useCallback, useEffect, useMemo, useState} from 'react' + +import {Button, Tab, Tooltip} from '../../../../ui-components' +import {MONTH_PICKER_VARIANT} from '../../../../ui-components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../../ui-components/inputs/DateInputs/calendar/types' +import {DateTimeInput} from '../../../../ui-components/inputs/DateInputs/DateTimeInput' +import {getCalendarLabels} from '../../../form/inputs/DateInputs/utils' +import {useTranslation} from '../../../i18n' +import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone' +import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' +import {type EditableReleaseDocument, type ReleaseType} from '../../store/types' +import {TitleDescriptionForm} from './TitleDescriptionForm' + +const RELEASE_TYPES: ReleaseType[] = ['asap', 'scheduled', 'undecided'] + +/** @internal */ +export function ReleaseForm(props: { + onChange: (params: EditableReleaseDocument) => void + value: EditableReleaseDocument +}): JSX.Element { + const {onChange, value} = props + const {releaseType} = value.metadata || {} + const publishAt = value.metadata.intendedPublishAt + const {t} = useTranslation() + + const {DialogTimeZone, dialogProps, dialogTimeZoneShow} = useDialogTimeZone() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const [currentTimezone, setCurrentTimezone] = useState(timeZone.name) + + const [buttonReleaseType, setButtonReleaseType] = useState(releaseType ?? 'asap') + + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(t), [t]) + const [inputValue, setInputValue] = useState(publishAt ? new Date(publishAt) : new Date()) + + const handleBundlePublishAtCalendarChange = useCallback( + (date: Date | null) => { + if (!date) return + + setInputValue(date) + onChange({...value, metadata: {...value.metadata, intendedPublishAt: date.toISOString()}}) + }, + [onChange, value], + ) + + const handleButtonReleaseTypeChange = useCallback( + (pickedReleaseType: ReleaseType) => { + setButtonReleaseType(pickedReleaseType) + onChange({ + ...value, + metadata: {...value.metadata, releaseType: pickedReleaseType, intendedPublishAt: undefined}, + }) + }, + [onChange, value], + ) + + const handleTitleDescriptionChange = useCallback( + (updatedRelease: EditableReleaseDocument) => { + onChange({ + ...value, + metadata: { + ...value.metadata, + title: updatedRelease.metadata.title, + description: updatedRelease.metadata.description, + }, + }) + }, + [onChange, value], + ) + + useEffect(() => { + /** makes sure to wait for the useTimezone has enough time to update + * and based on that it will update the input value to the current timezone + */ + if (timeZone.name !== currentTimezone) { + setCurrentTimezone(timeZone.name) + if (isValid(inputValue)) { + const currentZoneDate = utcToCurrentZoneDate(inputValue) + setInputValue(currentZoneDate) + } + } + }, [currentTimezone, inputValue, timeZone, utcToCurrentZoneDate]) + + return ( + + + + {t('release.dialog.tooltip.title')} + + + {t('release.dialog.tooltip.description')} + + {t('release.dialog.tooltip.note')} + + + } + delay={0} + placement="right-start" + portal + > + +
+ + + + + + + {RELEASE_TYPES.map((type) => ( + handleButtonReleaseTypeChange(type)} + selected={buttonReleaseType === type} + label={t(`release.type.${type}`)} + /> + ))} + + + + {buttonReleaseType === 'scheduled' && ( + + + + + + ) + } + + return {visibleLabelChildren()} + }, [currentGlobalBundle, t]) + + return ( + + + {releasesToolLink} + {currentGlobalPerspectiveLabel} + + {!isDraftPerspective(currentGlobalBundle) && ( +
+
+ )} +
+
+ ) +} diff --git a/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx b/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx new file mode 100644 index 00000000000..103cd2babdb --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/ReleasesStudioNavbar.tsx @@ -0,0 +1,29 @@ +import {useMemo} from 'react' + +import {type NavbarProps} from '../../config' +import {ReleasesNav} from './ReleasesNav' + +export const ReleasesStudioNavbar = (props: NavbarProps) => { + const actions = useMemo( + (): NavbarProps['__internal_actions'] => [ + { + location: 'topbar', + name: 'releases-topbar', + render: ReleasesNav, + }, + { + location: 'sidebar', + name: 'releases-sidebar', + render: ReleasesNav, + }, + ...(props?.__internal_actions || []), + ], + [props?.__internal_actions], + ) + + return props.renderDefault({ + ...props, + // eslint-disable-next-line camelcase + __internal_actions: actions, + }) +} diff --git a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx new file mode 100644 index 00000000000..cf06736a1e4 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx @@ -0,0 +1,281 @@ +import {fireEvent, render, type RenderResult, screen, waitFor, within} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import { + activeASAPRelease, + activeScheduledRelease, + scheduledRelease, +} from '../../__fixtures__/release.fixture' +import {usePerspectiveMockReturn} from '../../hooks/__tests__/__mocks__/usePerspective.mock' +import {useReleasesMockReturn} from '../../store/__tests__/__mocks/useReleases.mock' +import {LATEST} from '../../util/const' +import {ReleasesNav} from '../ReleasesNav' + +vi.mock('../../hooks/usePerspective', () => ({ + usePerspective: vi.fn(() => usePerspectiveMockReturn), +})) + +vi.mock('../../store/useReleases', () => ({ + useReleases: vi.fn(() => useReleasesMockReturn), +})) + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: vi.fn().mockImplementation((props) => ), + useRouterState: vi.fn().mockReturnValue(undefined), +})) + +let currentRenderedInstance: RenderResult | undefined + +const renderTest = async () => { + const wrapper = await createTestProvider({ + resources: [], + }) + currentRenderedInstance = render(, {wrapper}) + + return currentRenderedInstance +} + +describe('ReleasesNav', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('should have link to releases tool', async () => { + await renderTest() + + const releasesLink = screen.getByRole('link') + + expect(releasesLink).toHaveAttribute('href', '/') + expect(releasesLink).not.toHaveAttribute('data-selected') + }) + + it('should have dropdown menu for global perspectives', async () => { + await renderTest() + + screen.getByTestId('global-perspective-menu-button') + }) + + it('should not have clear button when no perspective is chosen', async () => { + await renderTest() + + expect(screen.queryByTestId('clear-perspective-button')).toBeNull() + }) + + it('should have clear button to unset perspective when a perspective is chosen', async () => { + usePerspectiveMockReturn.currentGlobalBundle = activeScheduledRelease + + await renderTest() + + fireEvent.click(screen.getByTestId('clear-perspective-button')) + + expect(usePerspectiveMockReturn.setPerspective).toHaveBeenCalledWith(LATEST._id) + }) + + it('should list the title of the chosen perspective', async () => { + usePerspectiveMockReturn.currentGlobalBundle = activeScheduledRelease + + await renderTest() + + screen.getByText('active Release') + }) + + it('should show release avatar for chosen perspective', async () => { + usePerspectiveMockReturn.currentGlobalBundle = activeASAPRelease + + await renderTest() + + screen.getByTestId('release-avatar-critical') + }) + + describe('global perspective menu', () => { + const renderAndWaitForStableMenu = async () => { + await renderTest() + + fireEvent.click(screen.getByTestId('global-perspective-menu-button')) + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).toBeNull() + }) + } + + beforeEach(async () => { + useReleasesMockReturn.data = [ + activeScheduledRelease, + { + ...activeScheduledRelease, + _id: '_.releases.active-scheduled-2', + name: 'activeScheduled2', + metadata: {...activeScheduledRelease.metadata, title: 'active Scheduled 2'}, + }, + activeASAPRelease, + + {...scheduledRelease, publishAt: '2023-10-10T09:00:00Z'}, + ] + }) + + describe('when menu is ready', () => { + beforeEach(renderAndWaitForStableMenu) + + it('should show published perspective item', async () => { + within(screen.getByTestId('release-menu')).getByText('Published') + + fireEvent.click(screen.getByText('Published')) + + expect(usePerspectiveMockReturn.setPerspective).toHaveBeenCalledWith('published') + }) + + it('should list all the releases', async () => { + const releaseMenu = within(screen.getByTestId('release-menu')) + + // section titles + releaseMenu.getByText('ASAP') + releaseMenu.getByText('At time') + expect(releaseMenu.queryByText('Undecided')).toBeNull() + + // releases + releaseMenu.getByText('active Release') + releaseMenu.getByText('active Scheduled 2') + releaseMenu.getByText('active asap Release') + releaseMenu.getByText('scheduled Release') + }) + + it('should show the intended release date for intended schedule releases', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('should show the actual release date for a scheduled release', async () => { + const scheduledMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + within(scheduledMenuItem).getByText(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/) + within(scheduledMenuItem).getByTestId('release-lock-icon') + within(scheduledMenuItem).getByTestId('release-avatar-primary') + }) + + it('allows for new release to be created', async () => { + fireEvent.click(screen.getByText('New release')) + + expect(screen.getByRole('dialog')).toHaveAttribute('id', 'create-release-dialog') + }) + }) + + describe('release layering', () => { + beforeEach(() => { + // since usePerspective is mocked, and the layering exclude toggle is + // controlled by currentGlobalBundleId, we need to manually set it + // to the release that will be selected in below tests + usePerspectiveMockReturn.currentGlobalBundleId = '_.releases.active-scheduled-2' + // add an undecided release to expand testing + useReleasesMockReturn.data = [ + ...useReleasesMockReturn.data, + { + ...activeASAPRelease, + _id: '_.releases.undecidedRelease', + metadata: { + ...activeASAPRelease.metadata, + title: 'undecided Release', + releaseType: 'undecided', + }, + }, + ] + }) + + describe('when a release is clicked', () => { + beforeEach(async () => { + await renderAndWaitForStableMenu() + + // select a release that has some other nested layer releases + fireEvent.click(screen.getByText('active Scheduled 2')) + }) + + it('should set a given perspective from the menu', async () => { + expect(usePerspectiveMockReturn.setPerspectiveFromReleaseDocumentId).toHaveBeenCalledWith( + '_.releases.active-scheduled-2', + ) + expect(usePerspectiveMockReturn.setPerspective).not.toHaveBeenCalled() + }) + + it('should allow for hiding of any deeper layered releases', async () => { + const deepLayerRelease = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + // toggle to hide + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'activeRelease', + ) + + // toggle to include + fireEvent.click(within(deepLayerRelease).getByTestId('release-toggle-visibility')) + expect(usePerspectiveMockReturn.toggleExcludedPerspective).toHaveBeenCalledWith( + 'activeRelease', + ) + }) + + 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 not allow hiding of the current perspective', async () => { + const currentRelease = within(screen.getByTestId('release-menu')) + .getByText('active Scheduled 2') + .closest('button')! + + expect( + within(currentRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of un-nested releases', async () => { + const unNestedRelease = within(screen.getByTestId('release-menu')) + .getByText('undecided Release') + .closest('button')! + + expect( + within(unNestedRelease).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + + it('should not allow hiding of locked in scheduled releases', async () => { + const scheduledReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('scheduled Release') + .closest('button')! + + expect( + within(scheduledReleaseMenuItem).queryByTestId('release-toggle-visibility'), + ).not.toBeInTheDocument() + }) + }) + + it('applies existing layering when opened', async () => { + usePerspectiveMockReturn.isPerspectiveExcluded.mockImplementation((id) => { + return id === 'activeRelease' + }) + + await renderAndWaitForStableMenu() + + const activeReleaseMenuItem = within(screen.getByTestId('release-menu')) + .getByText('active Release') + .closest('button')! + + expect( + within(activeReleaseMenuItem).queryByTestId('release-avatar-primary'), + ).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts b/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts new file mode 100644 index 00000000000..9c3bc9816f7 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/useScrollIndicatorVisibility.ts @@ -0,0 +1,47 @@ +import {useCallback, useMemo, useRef, useState} from 'react' + +export type ScrollElement = HTMLDivElement | null + +function isElementVisibleInContainer(container: ScrollElement, element: ScrollElement) { + if (!container || !element) return true + + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + + // 32.5px is padding on published element + padding of perspective menu item + const isVisible = elementRect.top >= containerRect.top + 32.5 + + return isVisible +} + +export const useScrollIndicatorVisibility = () => { + const scrollContainerRef = useRef(null) + const scrollElementRef = useRef(null) + + const [isRangeVisible, setIsRangeVisible] = useState(true) + + const handleScroll = useCallback( + () => + setIsRangeVisible( + isElementVisibleInContainer(scrollContainerRef.current, scrollElementRef.current), + ), + [], + ) + + const setScrollContainer = useCallback((container: HTMLDivElement) => { + scrollContainerRef.current = container + }, []) + + const resetRangeVisibility = useCallback(() => setIsRangeVisible(true), []) + + return useMemo( + () => ({ + resetRangeVisibility, + onScroll: handleScroll, + isRangeVisible, + setScrollContainer, + scrollElementRef, + }), + [handleScroll, isRangeVisible, resetRangeVisibility, setScrollContainer], + ) +} diff --git a/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx new file mode 100644 index 00000000000..5dab17dfd36 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx @@ -0,0 +1,18 @@ +import {type LayoutProps} from '../../config' +import {AddonDatasetProvider} from '../../studio' +import {ReleasesMetadataProvider} from '../contexts/ReleasesMetadataProvider' + +export function ReleasesStudioLayout(props: LayoutProps) { + // TODO: Replace for useReleasesEnabled + const {enabled} = {enabled: true} + + if (!enabled) { + return props.renderDefault(props) + } + + return ( + + {props.renderDefault(props)} + + ) +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx new file mode 100644 index 00000000000..cc7c90aea1f --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/DiscardVersionAction.tsx @@ -0,0 +1,65 @@ +import {TrashIcon} from '@sanity/icons' +import {useCallback, useState} from 'react' +import { + DiscardVersionDialog, + type DocumentActionDescription, + type DocumentActionProps, + InsufficientPermissionsMessage, + useCurrentUser, + useDocumentPairPermissions, +} from 'sanity' + +/** + * @internal + */ +export const DiscardVersionAction = ( + props: DocumentActionProps, +): DocumentActionDescription | null => { + const {id, type, bundleId, version} = props + const currentUser = useCurrentUser() + + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + version: bundleId, + permission: 'publish', + }) + + const [dialogOpen, setDialogOpen] = useState(false) + + // Callbacks + const handleDialogOpen = useCallback(() => { + setDialogOpen(true) + }, []) + + const insufficientPermissions = !isPermissionsLoading && !permissions?.granted + + if (insufficientPermissions) { + return { + disabled: true, + icon: TrashIcon, + label: 'no permissions', + title: , + } + } + + return { + dialog: dialogOpen && + version && { + type: 'custom', + component: ( + setDialogOpen(false)} + /> + ), + }, + /** @todo translate */ + label: 'Discard version', + icon: TrashIcon, + onHandle: handleDialogOpen, + /** @todo translate */ + title: 'Discard version', + } +} diff --git a/packages/sanity/src/core/releases/plugin/documentActions/index.ts b/packages/sanity/src/core/releases/plugin/documentActions/index.ts new file mode 100644 index 00000000000..e8ffcfc7b6e --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/documentActions/index.ts @@ -0,0 +1,19 @@ +import {type DocumentActionComponent, type DocumentActionsContext} from 'sanity' + +import {DiscardVersionAction} from './DiscardVersionAction' + +type Action = DocumentActionComponent + +export default function resolveDocumentActions( + existingActions: Action[], + context: DocumentActionsContext, +): Action[] { + if (context.perspective === 'version') { + const duplicateAction = existingActions.find((action) => { + return action.name === 'DuplicateAction' + }) + return [...(duplicateAction ? [duplicateAction] : []), DiscardVersionAction] + } + + return 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..0be7e88ea42 --- /dev/null +++ b/packages/sanity/src/core/releases/plugin/index.ts @@ -0,0 +1,60 @@ +import {route} from 'sanity/router' + +import {definePlugin} from '../../config' +import {releasesUsEnglishLocaleBundle} from '../i18n' +import {ReleasesStudioNavbar} from '../navbar/ReleasesStudioNavbar' +import {ReleasesTool} from '../tool/ReleasesTool' +import resolveDocumentActions from './documentActions' +import {ReleasesStudioLayout} from './ReleasesStudioLayout' + +/** + * @internal + */ +export const RELEASES_NAME = 'sanity/releases' + +/** + * @internal + */ +export const RELEASES_TOOL_NAME = 'releases' + +/** + * @internal + */ +export const RELEASES_INTENT = 'release' + +/** + * @internal + */ +export const releases = definePlugin({ + name: RELEASES_NAME, + studio: { + components: { + layout: ReleasesStudioLayout, + navbar: ReleasesStudioNavbar, + }, + }, + tools: [ + { + name: RELEASES_TOOL_NAME, + title: 'Releases', + component: ReleasesTool, + router: route.create('/', [route.create('/:releaseId')]), + canHandleIntent: (intent) => { + // If intent is release, open the releases tool. + return Boolean(intent === RELEASES_INTENT) + }, + getIntentState(intent, params) { + if (intent === RELEASES_INTENT) { + return {releaseId: params.id} + } + return null + }, + }, + ], + i18n: { + bundles: [releasesUsEnglishLocaleBundle], + }, + document: { + actions: (actions, context) => resolveDocumentActions(actions, context), + }, +}) 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..3afba456eba --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts @@ -0,0 +1,22 @@ +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(), +} + +export const mockCreateReleaseOperationsStore = createReleaseOperationsStore as Mock< + typeof createReleaseOperationsStore +> diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts new file mode 100644 index 00000000000..69daa86cbf7 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts @@ -0,0 +1,18 @@ +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(), +} + +export const mockUseReleaseOperations = useReleaseOperations as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts new file mode 100644 index 00000000000..19b1c6874f5 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleases.mock.ts @@ -0,0 +1,14 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {useReleases} from '../../useReleases' + +export const useReleasesMockReturn: Mocked> = { + archivedReleases: [], + data: [], + dispatch: vi.fn(), + error: undefined, + loading: false, + releasesIds: [], +} + +export const mockUseReleases = useReleases as Mock diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts new file mode 100644 index 00000000000..dd384c78de1 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleasesMetadata.mock.ts @@ -0,0 +1,11 @@ +import {type Mock, type Mocked} from 'vitest' + +import {useReleasesMetadata} from '../../useReleasesMetadata' + +export const useReleasesMetadataMockReturn: Mocked> = { + data: null, + error: null, + loading: false, +} + +export const mockUseReleasesMetadata = useReleasesMetadata as Mock diff --git a/packages/sanity/src/core/releases/store/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..1a583f34050 --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseMetadataAggregator.ts @@ -0,0 +1,184 @@ +import { + bufferTime, + catchError, + EMPTY, + filter, + iif, + merge, + type Observable, + of, + startWith, + switchMap, +} from 'rxjs' +import {type SanityClient} from 'sanity' + +import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId' +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 = getBundleIdFromReleaseDocumentId(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) => { + const getCountKey = (id: string) => `${id}_existing_count` + const documentCountQuery = Object.entries(releaseDocumentIdResponse).reduce( + (query, releaseMetadata) => { + const [releaseId, metadata] = releaseMetadata + if (!metadata.documentIds || metadata.documentIds.length === 0) return query + + const documentIds = metadata.documentIds + .map((documentId) => `"${documentId}"`) + .toString() + + return `${query}"${getCountKey(releaseId)}": count(*[_id in [${documentIds}]]{_id}),` + }, + ``, + ) + + return client.observable + .fetch< + Record + >(`{${documentCountQuery}}`, {}, {tag: 'release-docs.count'}) + .pipe( + switchMap((releaseDocumentCountResponse) => + of({ + data: Object.entries(releaseDocumentIdResponse).reduce( + (existingReleaseMetadata, releaseMetadata) => { + const [releaseId, metadata] = releaseMetadata + + return { + ...existingReleaseMetadata, + [releaseId]: { + ...metadata, + documentCount: metadata.documentIds?.length || 0, + existingDocumentCount: + releaseDocumentCountResponse[`${getCountKey(releaseId)}`] || 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..143691c82d2 --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts @@ -0,0 +1,211 @@ +import { + type Action, + type EditAction, + type IdentifiedSanityDocumentStub, + type SanityClient, +} from '@sanity/client' + +import {getVersionId} from '../../util' +import {getBundleIdFromReleaseDocumentId, type ReleaseDocument} from '../index' +import {type EditableReleaseDocument} from './types' + +export interface ReleaseOperationsStore { + publishRelease: (releaseId: string) => 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 + createVersion: (releaseId: string, documentId: string) => Promise + discardVersion: (releaseId: string, documentId: string) => Promise +} + +const IS_CREATE_VERSION_ACTION_SUPPORTED = false +// todo: change to `metadata` once the relevant PR has been deployed +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: getBundleIdFromReleaseDocumentId(release._id), + [METADATA_PROPERTY_NAME]: release.metadata, + }) + + const handleUpdateRelease = async (release: EditableReleaseDocument) => { + const bundleId = getBundleIdFromReleaseDocumentId(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) => + requestAction(client, [ + { + actionType: 'sanity.action.release.publish', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleScheduleRelease = (releaseId: string, publishAt: Date) => + requestAction(client, [ + { + actionType: 'sanity.action.release.schedule', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + publishAt: publishAt.toISOString(), + }, + ]) + + const handleUnscheduleRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleArchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.archive', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleUnarchiveRelease = (releaseId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + }, + ]) + + const handleCreateVersion = async (releaseId: string, documentId: string) => { + // 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) { + throw new Error(`Document with id ${documentId} not found`) + } + + const versionDocument = { + ...document, + _id: getVersionId(documentId, releaseId), + } as IdentifiedSanityDocumentStub + + await (IS_CREATE_VERSION_ACTION_SUPPORTED + ? requestAction(client, [ + { + actionType: 'sanity.action.document.createVersion', + releaseId: getBundleIdFromReleaseDocumentId(releaseId), + attributes: versionDocument, + }, + ]) + : client.create(versionDocument)) + } + + const handleDiscardVersion = (releaseId: string, documentId: string) => + requestAction(client, [ + { + actionType: 'sanity.action.document.discard', + draftId: getVersionId(documentId, releaseId), + }, + ]) + + return { + archive: handleArchiveRelease, + unarchive: handleUnarchiveRelease, + schedule: handleScheduleRelease, + unschedule: handleUnscheduleRelease, + createRelease: handleCreateRelease, + updateRelease: handleUpdateRelease, + publishRelease: handlePublishRelease, + createVersion: handleCreateVersion, + discardVersion: handleDiscardVersion, + } +} + +interface ScheduleApiAction { + actionType: 'sanity.action.release.schedule' + releaseId: string + publishAt: string +} + +interface PublishApiAction { + actionType: 'sanity.action.release.publish' + 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 EditReleaseApiAction { + actionType: 'sanity.action.release.edit' + releaseId: string + patch: EditAction['patch'] +} + +type ReleaseAction = + | Action + | ScheduleApiAction + | PublishApiAction + | CreateReleaseApiAction + | EditReleaseApiAction + | UnscheduleApiAction + | ArchiveApiAction + | UnarchiveApiAction + | CreateVersionReleaseApiAction + +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..62910946e23 --- /dev/null +++ b/packages/sanity/src/core/releases/store/createReleaseStore.ts @@ -0,0 +1,189 @@ +import {ClientError, type SanityClient} from '@sanity/client' +import { + BehaviorSubject, + catchError, + concat, + concatWith, + EMPTY, + filter, + from, + merge, + type Observable, + of, + scan, + shareReplay, + Subject, + switchMap, + tap, +} from 'rxjs' +import {map, mergeMap, startWith, toArray} from 'rxjs/operators' + +import {type DocumentPreviewStore} from '../../preview' +import {listenQuery} from '../../store/_legacy' +import {RELEASE_DOCUMENT_TYPE, RELEASE_DOCUMENTS_PATH} from './constants' +import {createReleaseMetadataAggregator} from './createReleaseMetadataAggregator' +import {requestAction} from './createReleaseOperationStore' +import {releasesReducer, type ReleasesReducerAction, type ReleasesReducerState} from './reducer' +import {type ReleaseDocument, type ReleaseStore} from './types' + +type ActionWrapper = {action: ReleasesReducerAction} +type ResponseWrapper = {response: ReleaseDocument[]} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTER = `_type=="${RELEASE_DOCUMENT_TYPE}" && _id in path("${RELEASE_DOCUMENTS_PATH}.*")` + +// TODO: Extend the projection with the fields needed +const QUERY_PROJECTION = `{ + ..., +}` + +// Newest releases first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTER}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +const INITIAL_STATE: ReleasesReducerState = { + releases: new Map(), + state: 'loaded' as const, + releaseStack: [], +} + +const RELEASE_METADATA_TMP_DOC_PATH = 'system-tmp-releases' +// todo: remove this after first tagged release +function migrateWith(client: SanityClient) { + return client.observable.fetch(`*[_id in path('${RELEASE_METADATA_TMP_DOC_PATH}.**')]`).pipe( + tap((tmpDocs: ReleaseDocument[]) => { + // eslint-disable-next-line + console.log('Migrating %d release documents', tmpDocs.length) + }), + mergeMap((tmpDocs: ReleaseDocument[]) => { + if (tmpDocs.length === 0) { + return EMPTY + } + return from(tmpDocs).pipe( + mergeMap(async (tmpDoc) => { + const releaseId = tmpDoc._id.slice(RELEASE_METADATA_TMP_DOC_PATH.length + 1) + await requestAction(client, { + actionType: 'sanity.action.release.edit', + releaseId, + patch: { + set: {metadata: tmpDoc.metadata}, + }, + }).catch((err) => { + if (err instanceof ClientError) { + if (err.details.description == `Release "${releaseId}" was not found`) { + // ignore + return + } + } + throw err + }) + await client.delete(tmpDoc._id) + }, 2), + ) + }), + toArray(), + tap((migrated) => { + // eslint-disable-next-line + console.log('Migrated %d releases', migrated.length) + }), + mergeMap(() => EMPTY), + ) +} +/** + * The releases store is initialised lazily when first subscribed to. Upon subscription, it will + * fetch a list of releases and create a listener to keep the locally held state fresh. + * + * The store is not disposed of when all subscriptions are closed. After it has been initialised, + * it will keep listening for the duration of the app's lifecycle. Subsequent subscriptions will be + * given the latest state upon subscription. + */ +export function createReleaseStore(context: { + previewStore: DocumentPreviewStore + client: SanityClient +}): ReleaseStore { + const {client} = context + + const dispatch$ = new Subject() + const fetchPending$ = new BehaviorSubject(false) + + function dispatch(action: ReleasesReducerAction): void { + dispatch$.next(action) + } + + const listFetch$ = of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: true, + error: undefined, + }, + }, + }).pipe( + // Ignore invocations while the list fetch is pending. + filter(() => !fetchPending$.value), + tap(() => fetchPending$.next(true)), + concatWith( + listenQuery(client, QUERY, {}, {tag: 'releases.listen'}).pipe( + tap(() => fetchPending$.next(false)), + map((releases) => + releases.map( + (releaseDoc: ReleaseDocument): ReleaseDocument => ({ + ...releaseDoc, + metadata: {...(releaseDoc as any).userMetadata, ...releaseDoc.metadata}, + }), + ), + ), + map((releases) => ({response: releases})), + ), + ), + + catchError((error) => + of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error, + }, + }, + }), + ), + switchMap>( + (entry) => { + if ('action' in entry) { + return of(entry.action) + } + + return of( + {type: 'RELEASES_SET', payload: entry.response}, + { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error: undefined, + }, + }, + ) + }, + ), + ) + + const migrateTmpReleases = process.env.NODE_ENV === 'development' ? migrateWith(client) : EMPTY + const state$ = concat(migrateTmpReleases, merge(listFetch$, dispatch$)).pipe( + filter((action): action is ReleasesReducerAction => typeof action !== 'undefined'), + scan((state, action) => releasesReducer(state, action), INITIAL_STATE), + startWith(INITIAL_STATE), + shareReplay(1), + ) + + const getMetadataStateForSlugs$ = createReleaseMetadataAggregator(client) + + return { + state$, + getMetadataStateForSlugs$, + dispatch, + } +} diff --git a/packages/sanity/src/core/releases/store/index.ts b/packages/sanity/src/core/releases/store/index.ts new file mode 100644 index 00000000000..f8dc7aaebca --- /dev/null +++ b/packages/sanity/src/core/releases/store/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './useReleaseOperations' +export * from './useReleases' diff --git a/packages/sanity/src/core/releases/store/reducer.ts b/packages/sanity/src/core/releases/store/reducer.ts new file mode 100644 index 00000000000..6684cae628c --- /dev/null +++ b/packages/sanity/src/core/releases/store/reducer.ts @@ -0,0 +1,108 @@ +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 + + /** + * An array of release ids ordered chronologically to represent the state of documents at the + * given point in time. + */ + releaseStack: string[] +} + +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..a1fdc2b80b8 --- /dev/null +++ b/packages/sanity/src/core/releases/store/types.ts @@ -0,0 +1,84 @@ +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 +} + +/** + * @internal + */ +export interface ReleaseDocument { + /** + * typically + * _.releases. + */ + _id: string + _type: typeof RELEASE_DOCUMENT_TYPE + _createdAt: string + _updatedAt: string + /** + * The same as the last path segment of the _id, added by the backend. + */ + name: string + createdBy: 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' +> & { + metadata: Partial +} + +/** + * @internal + */ +export function isReleaseDocument(doc: unknown): doc is ReleaseDocument { + return ( + typeof doc === 'object' && doc !== null && '_type' in doc && doc._type === RELEASE_DOCUMENT_TYPE + ) +} + +/** + * @internal + */ +export interface ReleaseStore { + state$: Observable + getMetadataStateForSlugs$: (slugs: string[]) => Observable + dispatch: Dispatch +} diff --git a/packages/sanity/src/core/releases/store/useReleaseOperations.ts b/packages/sanity/src/core/releases/store/useReleaseOperations.ts new file mode 100644 index 00000000000..07e92ac66aa --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleaseOperations.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react' + +import {useClient} from '../../hooks' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createReleaseOperationsStore} from './createReleaseOperationStore' + +/** + * @internal + */ +export function useReleaseOperations() { + const studioClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + return useMemo( + () => + createReleaseOperationsStore({ + client: studioClient, + }), + [studioClient], + ) +} diff --git a/packages/sanity/src/core/releases/store/useReleases.ts b/packages/sanity/src/core/releases/store/useReleases.ts new file mode 100644 index 00000000000..6a8c1ff42dd --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleases.ts @@ -0,0 +1,64 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {sortReleases} from '../hooks/utils' +import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId' +import {type ReleasesReducerAction} from './reducer' +import {type ReleaseDocument} from './types' +import {useReleasesStore} from './useReleasesStore' + +interface ReleasesState { + /** + * Sorted array of releases, excluding archived releases + */ + data: ReleaseDocument[] + /** + * Sorted array of release IDs, excluding archived releases + */ + releasesIds: string[] + /** + * Array of archived releases + */ + archivedReleases: ReleaseDocument[] + error?: Error + loading: boolean + dispatch: (event: ReleasesReducerAction) => void +} + +const ARCHIVED_RELEASE_STATES = ['archived', 'published'] + +/** + * @internal + */ +export function useReleases(): ReleasesState { + const {state$, dispatch} = useReleasesStore() + const state = useObservable(state$)! + const releasesAsArray = useMemo( + () => + sortReleases( + Array.from(state.releases.values()).filter( + (release) => !ARCHIVED_RELEASE_STATES.includes(release.state), + ), + ).reverse(), + [state.releases], + ) + const archivedReleases = useMemo( + () => + Array.from(state.releases.values()).filter((release) => + ARCHIVED_RELEASE_STATES.includes(release.state), + ), + [state.releases], + ) + const releasesIds = useMemo( + () => releasesAsArray.map((release) => getBundleIdFromReleaseDocumentId(release._id)), + [releasesAsArray], + ) + return { + data: releasesAsArray, + releasesIds: releasesIds, + archivedReleases, + dispatch, + error: state.error, + loading: ['loading', 'initialising'].includes(state.state), + } +} diff --git a/packages/sanity/src/core/releases/store/useReleasesMetadata.ts b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts new file mode 100644 index 00000000000..7153cc17d67 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesMetadata.ts @@ -0,0 +1,59 @@ +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 number of subset documents with the release version as a prefix + * that are already published + */ + existingDocumentCount: 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..f92c85ed262 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx @@ -0,0 +1,66 @@ +import {type PreviewValue} from '@sanity/types' +import {Card} from '@sanity/ui' +import {type ForwardedRef, forwardRef, useMemo} from 'react' +import {IntentLink} from 'sanity/router' + +import {useTranslation} from '../../../i18n' +import {DocumentPreviewPresence} from '../../../presence' +import {SanityDefaultPreview} from '../../../preview/components/SanityDefaultPreview' +import {getPublishedId} from '../../../util/draftUtils' +import {releasesLocaleNamespace} from '../../i18n' +import {useDocumentPresence} from '../../index' +import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' + +interface ReleaseDocumentPreviewProps { + documentId: string + documentTypeName: string + releaseId: string + previewValues: PreviewValue + isLoading: boolean + hasValidationError?: boolean +} + +export function ReleaseDocumentPreview({ + documentId, + documentTypeName, + releaseId, + previewValues, + isLoading, + hasValidationError, +}: ReleaseDocumentPreviewProps) { + const documentPresence = useDocumentPresence(documentId) + const {t} = useTranslation(releasesLocaleNamespace) + + const LinkComponent = useMemo( + () => + // eslint-disable-next-line @typescript-eslint/no-shadow + forwardRef(function LinkComponent(linkProps, ref: ForwardedRef) { + return ( + + ) + }), + [documentId, documentTypeName, releaseId], + ) + + const previewPresence = useMemo( + () => documentPresence?.length > 0 && , + [documentPresence], + ) + + return ( + + + + ) +} 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..e428fe0e3de --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/ReleaseMenuButton.tsx @@ -0,0 +1,193 @@ +import {ArchiveIcon, EllipsisHorizontalIcon, UnarchiveIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Menu, Spinner, Text, useToast} from '@sanity/ui' +import {useCallback, useMemo, useState} from 'react' + +import {Button, Dialog, MenuButton, MenuItem} from '../../../../../ui-components' +import {Translate, useTranslation} from '../../../../i18n' +import {ArchivedRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseDocument} from '../../../store/types' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {getBundleIdFromReleaseDocumentId} from '../../../util/getBundleIdFromReleaseDocumentId' +import {useBundleDocuments} from '../../detail/useBundleDocuments' + +export type ReleaseMenuButtonProps = { + disabled?: boolean + release: ReleaseDocument +} + +const ARCHIVABLE_STATES = ['active', 'published'] + +export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) => { + const toast = useToast() + const {archive} = useReleaseOperations() + const {loading: isLoadingReleaseDocuments, results: releaseDocuments} = useBundleDocuments( + getBundleIdFromReleaseDocumentId(release._id), + ) + const [isPerformingOperation, setIsPerformingOperation] = useState(false) + const [selectedAction, setSelectedAction] = useState<'edit' | 'confirm-archive'>() + + const releaseMenuDisabled = !release || isLoadingReleaseDocuments || disabled + const {t} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + + const handleArchive = useCallback(async () => { + if (releaseMenuDisabled) return + + try { + setIsPerformingOperation(true) + await archive(release._id) + + // it's in the process of becoming true, so the event we want to track is archive + telemetry.log(ArchivedRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (archivingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(archivingError) + } finally { + setIsPerformingOperation(false) + setSelectedAction(undefined) + } + }, [archive, release._id, release.metadata.title, releaseMenuDisabled, t, telemetry, toast]) + + const handleUnarchive = async () => { + // noop + // TODO: similar to handleArchive - complete once server action exists + } + + const confirmArchiveDialog = useMemo(() => { + if (selectedAction !== 'confirm-archive') return null + + const dialogDescription = + releaseDocuments.length === 1 + ? 'archive-dialog.confirm-archive-description_one' + : 'archive-dialog.confirm-archive-description_other' + + return ( + + } + onClose={() => setSelectedAction(undefined)} + footer={{ + confirmButton: { + text: t('archive-dialog.confirm-archive-button'), + tone: 'positive', + onClick: handleArchive, + loading: isPerformingOperation, + disabled: isPerformingOperation, + }, + }} + > + + + + + ) + }, [ + handleArchive, + isPerformingOperation, + release.metadata.title, + releaseDocuments.length, + selectedAction, + t, + ]) + + const handleOnInitiateArchive = useCallback(() => { + if (releaseDocuments.length > 0) { + setSelectedAction('confirm-archive') + } else { + handleArchive() + } + }, [handleArchive, releaseDocuments.length]) + + return ( + <> + + } + id="release-menu" + menu={ + + {!release?.state || release.state === 'archived' ? ( + + ) : ( + + )} + + } + popover={{ + constrainSize: true, + fallbackPlacements: ['top-end'], + placement: 'bottom', + portal: true, + tone: 'default', + }} + /> + {confirmArchiveDialog} + + ) +} 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..a0a59682d6a --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleaseMenuButton/__tests__/ReleaseMenuButton.test.tsx @@ -0,0 +1,161 @@ +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} from '../../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../../i18n' +import {type ReleaseDocument} 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, disabled = 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}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release')) + }) + + screen.getByTestId('confirm-archive-dialog') + } + + test('does not require confirmation when no documents in release', async () => { + mockUseBundleDocuments.mockReturnValue(useBundleDocumentsMockReturn) + + await renderTest({release: activeScheduledRelease}) + + await waitFor(() => { + screen.getByTestId('release-menu-button') + }) + + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release') + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + }) + + test('can reject archiving', async () => { + await openConfirmArchiveDialog() + + await act(() => { + fireEvent.click(screen.getByTestId('cancel-button')) + }) + + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + + describe('when archiving is successful', () => { + beforeEach(async () => { + await openConfirmArchiveDialog() + }) + + test('will archive an active release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + + describe('when archiving fails', () => { + beforeEach(async () => { + mockUseReleaseOperations.mockReturnValue({ + ...useReleaseOperationsMockReturn, + archive: vi.fn().mockRejectedValue(new Error('some rejection reason')), + }) + + await openConfirmArchiveDialog() + }) + + test('will not archive the release', async () => { + await act(() => { + fireEvent.click(screen.getByTestId('confirm-button')) + }) + + expect(useReleaseOperations().archive).toHaveBeenCalledWith(activeScheduledRelease._id) + expect(screen.queryByTestId('confirm-archive-dialog')).not.toBeInTheDocument() + }) + }) + }) + + 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}) + + fireEvent.click(screen.getByTestId('release-menu-button')) + + await act(() => { + fireEvent.click(screen.getByTestId('archive-release')) + }) + + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith({ + ...archivedRelease, + archivedAt: undefined, + }) + }) + + test('will be disabled', async () => { + await renderTest({release: activeScheduledRelease, disabled: true}) + + const actionsButton = screen.getByTestId('release-menu-button') + + expect(actionsButton).toBeDisabled() + + fireEvent.click(actionsButton) + + expect(screen.queryByTestId('archive-release')).not.toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx new file mode 100644 index 00000000000..b402cf83ff1 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx @@ -0,0 +1,165 @@ +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 {useRouter} from 'sanity/router' + +import {Button, Dialog} from '../../../../../ui-components' +import {ToneIcon} from '../../../../../ui-components/toneIcon/ToneIcon' +import {Translate, useTranslation} from '../../../../i18n' +import {PublishedRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {type DocumentInRelease} from '../../../tool/detail/useBundleDocuments' + +interface ReleasePublishAllButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +export const ReleasePublishAllButton = ({ + release, + documents, + disabled, +}: ReleasePublishAllButtonProps) => { + const toast = useToast() + const router = useRouter() + const {publishRelease} = useReleaseOperations() + const {t} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + const [publishBundleStatus, setPublishBundleStatus] = useState<'idle' | 'confirm' | '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 { + setPublishBundleStatus('publishing') + await publishRelease(release._id) + telemetry.log(PublishedRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + // TODO: handle a published release on the document list + router.navigate({}) + } catch (publishingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(publishingError) + } finally { + setPublishBundleStatus('idle') + } + }, [release, publishRelease, telemetry, toast, t, router]) + + 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 ( + <> + + + ) +} 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..0cd97852f92 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx @@ -0,0 +1,377 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react' +import {route, RouterProvider} from 'sanity/router' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {mockUseRouterReturn} from '../../../../../../test/mocks/useRouter.mock' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {activeASAPRelease, publishedASAPRelease} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {useReleaseOperationsMockReturn} from '../../../store/__tests__/__mocks/useReleaseOperations.mock' +import { + mockUseReleases, + useReleasesMockReturn, +} from '../../../store/__tests__/__mocks/useReleases.mock' +import {getBundleIdFromReleaseDocumentId} from '../../../util/getBundleIdFromReleaseDocumentId' +import {ReleaseDetail} from '../ReleaseDetail' +import { + documentsInRelease, + mockUseBundleDocuments, + useBundleDocumentsMockReturn, +} from './__mocks__/useBundleDocuments.mock' + +vi.mock('sanity/router', async (importOriginal) => { + return { + ...(await importOriginal()), + useRouter: vi.fn(() => mockUseRouterReturn), + route: { + create: vi.fn(), + }, + IntentLink: vi.fn(), + } +}) + +vi.mock('../../../store/useReleases', () => ({ + useReleases: vi.fn(() => useReleasesMockReturn), +})) + +vi.mock('../../../index', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), + isReleaseScheduledOrScheduling: vi.fn(), +})) + +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn), +})) + +vi.mock('../useBundleDocuments', () => ({ + useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturn), +})) + +vi.mock('../../components/ReleasePublishAllButton/useObserveDocumentRevisions', () => ({ + useObserveDocumentRevisions: vi.fn().mockReturnValue({ + '123': 'mock revision id', + }), +})) + +vi.mock('../ReleaseSummary', () => ({ + ReleaseSummary: () =>
, +})) + +vi.mock('../documentTable/useReleaseHistory', () => ({ + useReleaseHistory: vi.fn().mockReturnValue({ + documentsHistory: new Map(), + }), +})) + +const mockRouterNavigate = vi.fn() + +const renderTest = async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return render( + + + , + {wrapper}, + ) +} + +const publishAgnosticTests = (title: string) => { + it('should allow for navigating back to releases overview', () => { + screen.getByTestId('back-to-releases-button').click() + }) + + it('should show the release title', () => { + screen.getAllByText(title) + }) +} + +describe('ReleaseDetail', () => { + describe('when loading releases', () => { + beforeEach(async () => { + vi.clearAllMocks() + mockUseReleases.mockClear() + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + loading: true, + }) + + await renderTest() + }) + + it('should show a loading spinner', () => { + screen.getByTestId('loading-block') + }) + + it('does not show the rest of the screen ui', () => { + expect(screen.queryByText('Publish all')).toBeNull() + expect(screen.queryByText('Summary')).toBeNull() + expect(screen.queryByText('Review changes')).toBeNull() + expect(screen.queryByLabelText('Release menu')).toBeNull() + }) + }) + + describe('when loaded releases but still loading release documents', () => { + beforeEach(async () => { + vi.clearAllMocks() + + mockUseReleases.mockClear() + mockUseBundleDocuments.mockClear() + + mockUseBundleDocuments.mockReturnValue({...useBundleDocumentsMockReturn, loading: true}) + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [activeASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getBundleIdFromReleaseDocumentId(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', () => { + const currentDate = new Date().toISOString() + 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') + }) + + // eslint-disable-next-line no-warning-comments + // TODO: unsure if this will work this way in the future + /*it('should navigate to release review changes screen', () => { + expect(screen.getByTestId('review-button').closest('button')).not.toBeDisabled() + fireEvent.click(screen.getByTestId('review-button')) + expect(mockRouterNavigate).toHaveBeenCalledWith({ + path: '/test-release-id?screen=review', + }) + })*/ + } + + 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, + ) + }) + }) + + describe('with failing document validation', () => { + beforeEach(async () => { + mockUseBundleDocuments.mockReturnValue({ + loading: false, + results: [ + { + ...documentsInRelease, + validation: { + hasError: true, + isValidating: false, + validation: [ + { + message: 'title validation message', + level: 'error', + path: ['title'], + }, + ], + }, + }, + ], + }) + await renderTest() + }) + + publishAgnosticTests(activeASAPRelease.metadata.title) + loadedReleaseAndDocumentsTests() + + it('should disable publish all button', () => { + expect(screen.getByTestId('publish-all-button')).toBeDisabled() + fireEvent.mouseOver(screen.getByTestId('publish-all-button')) + }) + }) + }) + + describe('with published release', () => { + beforeEach(async () => { + mockUseReleases.mockReset() + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [publishedASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getBundleIdFromReleaseDocumentId(publishedASAPRelease._id), + } + + await renderTest() + }) + + publishAgnosticTests(publishedASAPRelease.metadata.title) + + it('should not show the publish button', () => { + expect(screen.queryByText('Publish all')).toBeNull() + }) + + it('should allow for the release to be unarchived', () => { + fireEvent.click(screen.getByTestId('release-menu-button')) + screen.getByTestId('archive-release') + }) + + it('should not show the review changes button', () => { + expect(screen.queryByText('Review changes')).toBeNull() + }) + + it('should disable Release menu', () => { + act(() => { + fireEvent.click(screen.getByTestId('release-menu-button')) + }) + + expect(screen.getByTestId('archive-release')).toBeEnabled() + }) + }) + + describe('with a published release', () => { + beforeEach(async () => { + mockUseReleases.mockReset() + mockUseBundleDocuments.mockReset() + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + archivedReleases: [publishedASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getBundleIdFromReleaseDocumentId(publishedASAPRelease._id), + } + + mockUseBundleDocuments.mockReturnValue({ + ...useBundleDocumentsMockReturn, + results: [ + { + ...documentsInRelease, + document: {...documentsInRelease.document, publishedDocumentExists: true}, + }, + ], + }) + + await renderTest() + }) + + publishAgnosticTests(publishedASAPRelease.metadata.title) + + it('should not show publish button', () => { + expect(screen.queryByText('Publish all')).toBeNull() + }) + }) + + describe('with missing release', () => { + beforeEach(async () => { + mockUseReleases.mockReset() + + mockUseReleases.mockReturnValue({ + ...useReleasesMockReturn, + data: [activeASAPRelease], + }) + + mockUseRouterReturn.state = { + releaseId: getBundleIdFromReleaseDocumentId(activeASAPRelease._id), + } + + await renderTest() + }) + + it('should show missing release message', () => { + screen.getByText(activeASAPRelease.metadata.title) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx new file mode 100644 index 00000000000..46982123cbd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetailsEditor.test.tsx @@ -0,0 +1,73 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {type ReleaseDocument} from '../../../index' +import {useReleaseOperations} from '../../../store/useReleaseOperations' +import {ReleaseDetailsEditor} from '../ReleaseDetailsEditor' +// Mock the dependencies +vi.mock('../../../store/useReleaseOperations', () => ({ + useReleaseOperations: vi.fn().mockReturnValue({ + updateRelease: vi.fn(), + }), +})) + +describe('ReleaseDetailsEditor', () => { + beforeEach(async () => { + const initialRelease = { + _id: 'release1', + metadata: { + title: 'Initial Title', + description: '', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + const wrapper = await createTestProvider() + render(, {wrapper}) + }) + + it('should call updateRelease after title change', () => { + const release = { + _id: 'release1', + metadata: { + title: 'New Title', + description: '', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + + const input = screen.getByTestId('release-form-title') + fireEvent.change(input, {target: {value: release.metadata.title}}) + + waitFor( + () => { + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith(release) + }, + {timeout: 250}, + ) + }) + + it('should call updateRelease after description change', () => { + const release = { + _id: 'release1', + metadata: { + title: 'Initial Title', + description: 'woo hoo', + releaseType: 'asap', + intendedPublishAt: undefined, + }, + } as ReleaseDocument + + const input = screen.getByTestId('release-form-description') + fireEvent.change(input, {target: {value: release.metadata.description}}) + + waitFor( + () => { + expect(useReleaseOperations().updateRelease).toHaveBeenCalledWith(release) + }, + {timeout: 250}, + ) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx new file mode 100644 index 00000000000..8e3d9cc701e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseReview.test.tsx @@ -0,0 +1,333 @@ +import {act, fireEvent, render, screen, within} from '@testing-library/react' +import {type ReactNode} from 'react' +import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {queryByDataUi} from '../../../../../../test/setup/customQueries' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {useObserveDocument} from '../../../../preview/useObserveDocument' +import {ColorSchemeProvider} from '../../../../studio' +import {UserColorManagerProvider} from '../../../../user-color' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {ReleaseReview} from '../ReleaseReview' +import {type DocumentInRelease} from '../useBundleDocuments' + +const BASE_DOCUMENTS_MOCKS = { + doc1: { + name: 'William Faulkner', + role: 'developer', + _id: 'doc1', + _rev: 'FvEfB9CaLlljeKWNkRBaf5', + _type: 'author', + _createdAt: '', + _updatedAt: '', + }, + doc2: { + name: 'Virginia Woolf', + role: 'developer', + _id: 'doc2', + _rev: 'FvEfB9CaLlljeKWNkRBaf5', + _type: 'author', + _createdAt: '', + _updatedAt: '', + }, +} as const + +const MOCKED_DOCUMENTS: DocumentInRelease[] = [ + { + memoKey: 'key123', + document: { + _rev: 'FvEfB9CaLlljeKWNkQgpz9', + _type: 'author', + role: 'designer', + _createdAt: '2024-07-10T12:10:38Z', + name: 'William Faulkner added', + _id: 'versions.differences.doc1', + _updatedAt: '2024-07-15T10:46:02Z', + }, + previewValues: { + isLoading: false, + values: { + _createdAt: '2024-07-10T12:10:38Z', + _updatedAt: '2024-07-15T10:46:02Z', + _version: {}, + title: 'William Faulkner added', + subtitle: 'Designer', + }, + }, + validation: { + isValidating: false, + validation: [], + revision: 'FvEfB9CaLlljeKWNk8Mh0N', + hasError: false, + }, + }, + { + memoKey: 'key123', + document: { + _rev: 'FvEfB9CaLlljeKWNkQg1232', + _type: 'author', + role: 'developer', + _createdAt: '2024-07-10T12:10:38Z', + name: 'Virginia Woolf test', + _id: 'versions.differences.doc2', + _updatedAt: '2024-07-15T10:46:02Z', + }, + previewValues: { + isLoading: false, + values: { + _createdAt: '2024-07-10T12:10:38Z', + _updatedAt: '2024-07-15T10:46:02Z', + _version: {}, + title: 'Virginia Woolf test', + subtitle: 'Developer', + }, + }, + validation: { + isValidating: false, + validation: [], + revision: 'FvEfB9CaLlljeKWNk8Mh0N', + hasError: false, + }, + }, +] +const MOCKED_PROPS = { + scrollContainerRef: {current: null}, + documents: MOCKED_DOCUMENTS, + release: { + _updatedAt: '2024-07-12T10:39:32Z', + authorId: 'p8xDvUMxC', + _type: 'release', + description: 'To test differences in documents', + hue: 'gray', + title: 'Differences', + _createdAt: '2024-07-10T12:09:56Z', + icon: 'cube', + slug: 'differences', + _id: 'd3137faf-ece6-44b5-a2b1-1090967f868e', + _rev: 'j9BPWHem9m3oUugvhMXEGV', + } as const, + documentsHistory: { + 'differences.doc1': { + history: [], + createdBy: 'p8xDvUMxC', + lastEditedBy: 'p8xDvUMxC', + editors: ['p8xDvUMxC'], + }, + + 'differences.doc2': { + history: [], + createdBy: 'p8xDvUMxC', + lastEditedBy: 'p8xDvUMxC', + editors: ['p8xDvUMxC'], + }, + }, +} + +vi.mock('sanity/router', async (importOriginal) => ({ + ...(await importOriginal()), + IntentLink: vi.fn().mockImplementation((props: any) => {props.children}), + useRouter: vi.fn().mockReturnValue({ + state: {releaseId: 'differences'}, + navigate: vi.fn(), + }), +})) + +vi.mock('../../../../preview/useObserveDocument', () => { + return { + useObserveDocument: vi.fn(), + } +}) + +const mockedUseObserveDocument = useObserveDocument as Mock + +async function createReleaseReviewWrapper() { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + return ({children}: {children: ReactNode}) => + wrapper({ + children: ( + + {children} + + ), + }) +} + +describe.skip('ReleaseReview', () => { + describe('when loading baseDocument', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: null, + loading: true, + }) + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it("should show the loader when the base document hasn't loaded", () => { + queryByDataUi(document.body, 'Spinner') + }) + }) + describe('when there is no base document', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: null, + loading: false, + }) + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it('should render the new document ui, showing the complete values as added', async () => { + const firstDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[0].document._id}`, + ) + const secondDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[1].document._id}`, + ) + + expect( + within(firstDocumentDiff).getByText( + (content, el) => + el?.tagName.toLowerCase() === 'ins' && content === 'William Faulkner added', + ), + ).toBeInTheDocument() + expect(within(firstDocumentDiff).getByText('Designer')).toBeInTheDocument() + + expect( + within(secondDocumentDiff).getByText( + (content, el) => el?.tagName.toLowerCase() === 'ins' && content === 'Virginia Woolf test', + ), + ).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('Developer')).toBeInTheDocument() + }) + }) + + describe('when the base document is loaded and there are no changes', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockReturnValue({ + document: MOCKED_DOCUMENTS[0].document, + loading: false, + }) + + const wrapper = await createReleaseReviewWrapper() + render(, { + wrapper, + }) + }) + it('should show that there are no changes', async () => { + expect(screen.getByText('No changes')).toBeInTheDocument() + }) + }) + + describe('when the base document is loaded and has changes', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockImplementation((docId: string) => { + return { + // @ts-expect-error - key is valid, ts won't infer it + document: BASE_DOCUMENTS_MOCKS[docId], + loading: false, + } + }) + + const wrapper = await createReleaseReviewWrapper() + render(, {wrapper}) + }) + it('should should show the changes', async () => { + // Find an ins tag with the text "added" + const firstDocumentChange = screen.getByText((content, el) => { + return el?.tagName.toLowerCase() === 'ins' && content === 'added' + }) + + expect(firstDocumentChange).toBeInTheDocument() + + const secondDocumentChange = screen.getByText((content, el) => { + return el?.tagName.toLowerCase() === 'ins' && content === 'test' + }) + + expect(secondDocumentChange).toBeInTheDocument() + }) + it('should collapse documents', () => { + const firstDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[0].document._id}`, + ) + const secondDocumentDiff = screen.getByTestId( + `doc-differences-${MOCKED_DOCUMENTS[1].document._id}`, + ) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + // get the toggle button with id 'document-review-header-toggle' inside the first document diff + const firstDocToggle = within(firstDocumentDiff).getByTestId('document-review-header-toggle') + act(() => { + fireEvent.click(firstDocToggle) + }) + expect(within(firstDocumentDiff).queryByText('added')).not.toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + act(() => { + fireEvent.click(firstDocToggle) + }) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).getByText('test')).toBeInTheDocument() + + const secondDocToggle = within(secondDocumentDiff).getByTestId( + 'document-review-header-toggle', + ) + act(() => { + fireEvent.click(secondDocToggle) + }) + expect(within(firstDocumentDiff).getByText('added')).toBeInTheDocument() + expect(within(secondDocumentDiff).queryByText('test')).not.toBeInTheDocument() + }) + }) + describe('filtering documents', () => { + beforeEach(async () => { + mockedUseObserveDocument.mockImplementation((docId: string) => { + return { + // @ts-expect-error - key is valid, ts won't infer it + document: BASE_DOCUMENTS_MOCKS[docId], + loading: false, + } + }) + + const wrapper = await createReleaseReviewWrapper() + + render(, {wrapper}) + }) + + it('should show all the documents when no filter is applied', () => { + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).toBeInTheDocument() + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + }) + it('should show support filtering by title', async () => { + const searchInput = screen.getByPlaceholderText('Search documents') + act(() => { + fireEvent.change(searchInput, {target: {value: 'Virginia'}}) + }) + + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).not.toBeInTheDocument() + + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + + act(() => { + fireEvent.change(searchInput, {target: {value: ''}}) + }) + expect( + screen.queryByText(MOCKED_DOCUMENTS[0].previewValues.values.title as string), + ).toBeInTheDocument() + expect( + screen.queryByText(MOCKED_DOCUMENTS[1].previewValues.values.title as string), + ).toBeInTheDocument() + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx new file mode 100644 index 00000000000..8c2140398f7 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseSummary.test.tsx @@ -0,0 +1,245 @@ +import {defineType} from '@sanity/types' +import {fireEvent, render, screen, within} from '@testing-library/react' +import {route, RouterProvider} from 'sanity/router' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getAllByDataUi, getByDataUi} from '../../../../../../test/setup/customQueries' +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import {type ReleaseDocument} from '../../../index' +import {type DocumentHistory} from '../documentTable/useReleaseHistory' +import {ReleaseSummary, type ReleaseSummaryProps} from '../ReleaseSummary' +import {type DocumentInRelease} from '../useBundleDocuments' + +vi.mock('../../../../studio/addonDataset/useAddonDataset', () => ({ + useAddonDataset: vi.fn().mockReturnValue({client: {}}), +})) + +vi.mock('../../../../store', async (importOriginal) => ({ + ...(await importOriginal()), + useUser: vi.fn().mockReturnValue([{}]), +})) + +vi.mock('../../../../user-color', () => ({ + useUserColor: vi.fn().mockReturnValue('red'), +})) + +const timeNow = new Date() + +const releaseDocuments: DocumentInRelease[] = [ + { + memoKey: 'key123', + document: { + _id: '123', + _type: 'document', + // 3 days ago + _createdAt: new Date(timeNow.getTime() - 24 * 60 * 60 * 1000 * 3).toISOString(), + // 2 days ago + _updatedAt: new Date(timeNow.getTime() - 24 * 60 * 60 * 1000 * 2).toISOString(), + _version: {}, + _rev: 'abc', + title: 'First document', + }, + previewValues: { + values: { + title: 'First document', + }, + isLoading: false, + }, + validation: { + hasError: false, + isValidating: true, + validation: [], + }, + }, + { + memoKey: 'key456', + document: { + _id: '456', + _type: 'document', + // 24 hrs ago + _createdAt: new Date(timeNow.getTime() - 24 * 60 * 60 * 1000).toISOString(), + // 12 hrs ago + _updatedAt: new Date(timeNow.getTime() - 12 * 60 * 60 * 1000).toISOString(), + _version: {}, + _rev: 'abc', + title: 'Second document', + }, + previewValues: { + values: { + title: 'Second document', + }, + isLoading: false, + }, + validation: { + hasError: false, + isValidating: true, + validation: [], + }, + }, +] + +const renderTest = async (props: Partial) => { + const wrapper = await createTestProvider({ + config: { + projectId: 'test', + dataset: 'test', + name: 'default', + schema: { + types: [ + defineType({ + type: 'document' as const, + name: 'test', + title: 'Test', + fields: [ + { + type: 'string', + name: 'title', + title: 'Title', + }, + ], + }), + ], + }, + }, + resources: [releasesUsEnglishLocaleBundle], + }) + + return render( + + + , + { + wrapper, + }, + ) +} + +describe.skip('ReleaseSummary', () => { + beforeEach(async () => { + vi.clearAllMocks() + + await renderTest({}) + }) + + it('lists the release title and description', () => { + screen.getByText('Release title') + screen.getByText('Release description') + }) + + it('shows the number of documents in release', () => { + screen.getByText('2 documents') + }) + + it('shows the creator and date of release', () => { + within(screen.getByTestId('summary')).getByText('just now') + }) + + it('shows whether release has been published', () => { + screen.getByText('Not published') + }) + + it('shows a list of collaborators on release', () => { + const collaborators = getByDataUi(screen.getByTestId('summary'), 'AvatarStack') + expect(collaborators.childNodes).toHaveLength(2) + }) + + describe('documents table', () => { + it('shows list of all documents in release', () => { + const documents = screen.getAllByTestId('table-row') + + expect(documents).toHaveLength(2) + + const [firstDocument, secondDocument] = documents + + // first document + const [previewCellFirst, createdCellFirst, editedCellFirst] = + within(firstDocument).getAllByRole('cell') + within(previewCellFirst).getByText('First document') + within(createdCellFirst).getByText('3 days ago') + getByDataUi(createdCellFirst, 'Avatar') + within(editedCellFirst).getByText('2 days ago') + getByDataUi(editedCellFirst, 'Avatar') + + // second document + const [ + previewCellSecond, + createdCellSecond, + editedCellSecond, + publishedCellSecond, + collaboratorsCellSecond, + ] = within(secondDocument).getAllByRole('cell') + within(previewCellSecond).getByText('Second document') + within(createdCellSecond).getByText('yesterday') + within(editedCellSecond).getByText('12 hr. ago') + const collaborators = getByDataUi(collaboratorsCellSecond, 'AvatarStack') + expect(getAllByDataUi(collaborators, 'Avatar')).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('Created')) + + 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')) + 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', () => { + fireEvent.change(screen.getByPlaceholderText('Search documents'), {target: {value: 'Second'}}) + + const [searchedFirstDocument] = screen.getAllByTestId('table-row') + + within(searchedFirstDocument).getByText('Second document') + }) + }) +}) 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/documentTable/DocumentActions.tsx b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentActions.tsx new file mode 100644 index 00000000000..118230d1a0c --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentActions.tsx @@ -0,0 +1,51 @@ +import {CloseIcon} from '@sanity/icons' +import {Card, Menu} 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 {type BundleDocumentRow} from '../ReleaseSummary' + +export const DocumentActions = memo( + function DocumentActions({ + document, + releaseTitle, + }: { + document: BundleDocumentRow + releaseTitle: string + }) { + const [showDiscardDialog, setShowDiscardDialog] = useState(false) + const {t: coreT} = useTranslation() + + return ( + <> + + } + menu={ + + setShowDiscardDialog(true)} + /> + + } + /> + + {showDiscardDialog && ( + setShowDiscardDialog(false)} + documentId={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..103a576d6e1 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/documentTable/DocumentTableColumnDefs.tsx @@ -0,0 +1,169 @@ +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 {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, + }: { + item: DocumentInRelease + releaseId: 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, +) + +export const getDocumentTableColumnDefs: ( + releaseId: string, + t: TFunction<'releases', undefined>, +) => Column[] = (releaseId, t) => [ + { + id: 'action', + width: 100, + header: (props) => ( + + + + ), + cell: ({cellProps, datum}) => ( + + + {datum.document.publishedDocumentExists ? ( + + {t('table-body.action.change')} + + ) : ( + + {t('table-body.action.add')} + + )} + + + ), + }, + { + id: 'document._type', + width: 100, + sorting: true, + header: (props) => ( + + + + ), + cell: ({cellProps, datum}) => ( + + + + + + ), + }, + { + id: 'search', + width: null, + style: {minWidth: '50%', maxWidth: '50%'}, + 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-singular' + : 'document-validation.error', + {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/review/DocumentDiff.styled.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.styled.tsx new file mode 100644 index 00000000000..5b449781477 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.styled.tsx @@ -0,0 +1,94 @@ +import {Container} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {css, styled} from 'styled-components' + +export const ChangesWrapper = styled(Container)((props) => { + const theme = getTheme_v2(props.theme) + return css` + [data-ui='group-change-content'] { + // Hide the first grouping border border + &::before { + display: none; + } + [data-ui='group-change-list'] { + grid-gap: ${theme.space[6]}px; + } + + [data-ui='group-change-content'] { + // For inner groupings, show the border and reduce the gap + &::before { + display: block; + } + [data-ui='group-change-list'] { + grid-gap: ${theme.space[4]}px; + } + } + } + + [data-ui='field-diff-inspect-wrapper'] { + // Hide the border of the field diff wrapper + padding: 0; + padding-top: ${theme.space[2]}px; + &::before { + display: none; + } + } + ` +}) + +export const FieldWrapper = styled.div` + [data-changed] { + cursor: default; + } + + [data-diff-action='removed'] { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + [data-diff-action='added'] { + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + } + + [data-ui='diff-card'] { + cursor: default; + + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + &:has(del) { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + &[data-hover] { + &::after { + // Remove the hover effect for the cards + display: none; + } + } + } + + del[data-ui='diff-card'] { + background-color: var(--card-badge-critical-bg-color); + color: var(--card-badge-critical-fg-color); + } + + ins[data-ui='diff-card'] { + background-color: var(--card-badge-positive-bg-color); + color: var(--card-badge-positive-fg-color); + } + + del { + text-decoration: none; + &:hover { + // Hides the border bottom added to the text differences when hovering + background-image: none; + } + } + ins { + &:hover { + // Hides the border bottom added to the text differences when hovering + background-image: none; + } + } +` diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx new file mode 100644 index 00000000000..3dc58e6e392 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiff.tsx @@ -0,0 +1,77 @@ +import {diffInput, wrap} from '@sanity/diff' +import {type ObjectSchemaType, type SanityDocument} from '@sanity/types' +import {Text} from '@sanity/ui' +import {useMemo} from 'react' +import {DocumentChangeContext} from 'sanity/_singletons' + +import {buildChangeList} from '../../../../field/diff/changes/buildChangeList' +import {ChangeResolver} from '../../../../field/diff/components/ChangeResolver' +import {type ObjectDiff} from '../../../../field/types' +import {useTranslation} from '../../../../i18n' +import {releasesLocaleNamespace} from '../../../i18n' +import {ChangesWrapper, FieldWrapper} from './DocumentDiff.styled' + +const buildDocumentForDiffInput = (document: Partial) => { + // Remove internal fields and undefined values + const {_id, _rev, _createdAt, _updatedAt, _type, _version, ...rest} = JSON.parse( + JSON.stringify(document), + ) + + return rest +} + +/** + * Compares two documents with the same schema type. + * Showing the changes introduced by the document compared to the base document. + */ +export function DocumentDiff({ + baseDocument, + document, + schemaType, +}: { + baseDocument: SanityDocument | null + document: SanityDocument + schemaType: ObjectSchemaType +}) { + const {changesList, rootDiff} = useMemo(() => { + const diff = diffInput( + wrap(buildDocumentForDiffInput(baseDocument ?? {}), null), + wrap(buildDocumentForDiffInput(document), null), + ) as ObjectDiff + + if (!diff.isChanged) return {changesList: [], rootDiff: null} + const changeList = buildChangeList(schemaType, diff, [], [], {}) + return {changesList: changeList, rootDiff: diff} + }, [baseDocument, document, schemaType]) + const {t} = useTranslation(releasesLocaleNamespace) + + const isChanged = !!rootDiff?.isChanged + + if (!isChanged) { + return {t('diff.no-changes')} + } + + return ( + { + return {props.children} + }, + value: document, + showFromValue: !!baseDocument, + }} + > + + {changesList.length ? ( + changesList.map((change) => ) + ) : ( + {t('diff.list-empty')} + )} + + + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx new file mode 100644 index 00000000000..118cbebd5fa --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentDiffContainer.tsx @@ -0,0 +1,74 @@ +import {type ObjectSchemaType} from '@sanity/types' +import {Card, Flex} from '@sanity/ui' +import {memo} from 'react' + +import {LoadingBlock} from '../../../../components/loadingBlock/LoadingBlock' +import {useSchema} from '../../../../hooks/useSchema' +import {useObserveDocument} from '../../../../preview/useObserveDocument' +import {getPublishedId} from '../../../../util/draftUtils' +import {type DocumentHistory} from '../documentTable/useReleaseHistory' +import {DocumentReviewHeader} from '../review/DocumentReviewHeader' +import {type DocumentInRelease} from '../useBundleDocuments' +import {DocumentDiff} from './DocumentDiff' + +const DocumentDiffExpanded = memo( + function DocumentDiffExpanded({document}: {document: DocumentInRelease['document']}) { + const publishedId = getPublishedId(document._id) + + const schema = useSchema() + const schemaType = schema.get(document._type) as ObjectSchemaType + if (!schemaType) { + throw new Error(`Schema type "${document._type}" not found`) + } + + const {document: baseDocument, loading: baseDocumentLoading} = useObserveDocument(publishedId) + + if (baseDocumentLoading) return + + return + }, + (prev, next) => prev.document._rev === next.document._rev, +) + +export const DocumentDiffContainer = memo( + function DocumentDiffContainer({ + item, + history, + releaseSlug, + isExpanded, + toggleIsExpanded, + }: { + history?: DocumentHistory + releaseSlug: string + item: DocumentInRelease + isExpanded: boolean + toggleIsExpanded: () => void + }) { + return ( + + + {isExpanded && ( + + + + )} + + ) + }, + (prev, next) => { + return ( + prev.item.memoKey === next.item.memoKey && + prev.isExpanded === next.isExpanded && + prev.history?.lastEditedBy === next.history?.lastEditedBy + ) + }, +) diff --git a/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx b/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx new file mode 100644 index 00000000000..ea52a516b25 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/review/DocumentReviewHeader.tsx @@ -0,0 +1,107 @@ +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 ( + + +