diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/operations/createVersion.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/operations/createVersion.ts index f97b8dbbda5..4cbf9ae723a 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/operations/createVersion.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/operations/createVersion.ts @@ -9,7 +9,7 @@ export const createVersion: OperationImpl<[baseDocumentId: string], 'NO_NEW_VERS return snapshots.published || snapshots.draft ? false : 'NO_NEW_VERSION' }, execute: ({schema, client, snapshots, typeName}, dupeId) => { - const source = snapshots.draft || snapshots.published + const source = snapshots.version || snapshots.draft || snapshots.published if (!source) { throw new Error('cannot execute on empty document') diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx index f839fdc5202..bf1c1d5d67a 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx @@ -1,13 +1,19 @@ import {DotIcon} from '@sanity/icons' -// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button import {Text} from '@sanity/ui' -// eslint-disable-next-line camelcase import {memo, useCallback} from 'react' -import {getVersionFromId, useDateTimeFormat, usePerspective, useTranslation} from 'sanity' +import { + getVersionFromId, + useBundles, + useDateTimeFormat, + usePerspective, + useTranslation, +} from 'sanity' +import {versionDocumentExists} from '../../../../../../core/releases' import {usePaneRouter} from '../../../../../components' import {useDocumentPane} from '../../../useDocumentPane' import {VersionChip} from './VersionChip' +import {VersionPopoverMenu} from './VersionPopoverMenu' export const DocumentPerspectiveMenu = memo(function DocumentPerspectiveMenu() { const paneRouter = usePaneRouter() @@ -17,8 +23,18 @@ export const DocumentPerspectiveMenu = memo(function DocumentPerspectiveMenu() { dateStyle: 'medium', timeStyle: 'short', }) + const {data: bundles, loading} = useBundles() - const {documentVersions, editState, displayed} = useDocumentPane() + const {documentVersions, editState, displayed, documentType} = useDocumentPane() + + // remove the versions that the document already has + // remove the archived releases + const filteredReleases = + (documentVersions && + bundles?.filter( + (bundle) => !versionDocumentExists(documentVersions, bundle._id) && !bundle.archivedAt, + )) || + [] const handleBundleChange = useCallback( (bundleId: string) => () => { @@ -89,6 +105,15 @@ export const DocumentPerspectiveMenu = memo(function DocumentPerspectiveMenu() { text={release.title} tone={'primary'} icon={DotIcon} + menuContent={ + + } /> ))} diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionChip.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionChip.tsx index 6f469ccdc0a..b809dbbbfa0 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionChip.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionChip.tsx @@ -1,8 +1,9 @@ -import {memo, type ReactNode} from 'react' +import {useClickOutsideEvent, useGlobalKeyDown} from '@sanity/ui' +import {memo, type MouseEvent, type ReactNode, useCallback, useMemo, useRef, useState} from 'react' import {styled} from 'styled-components' import {type BundleDocument, type releaseType} from '../../../../../../core' -import {Button, Tooltip} from '../../../../../../ui-components' +import {Button, Popover, Tooltip} from '../../../../../../ui-components' const Chip = styled(Button)` border-radius: 9999px !important; @@ -26,9 +27,67 @@ export const VersionChip = memo(function VersionChip(props: { text: string tone: 'default' | 'primary' | 'positive' | 'caution' | 'critical' icon: React.ComponentType + menuContent?: ReactNode }) { - const {disabled, releaseOptions, selected, tooltipContent, version, onClick, text, tone, icon} = - props + const { + disabled, + releaseOptions, + selected, + tooltipContent, + version, + onClick, + text, + tone, + icon, + menuContent, + } = props + + const [contextMenuPoint, setContextMenuPoint] = useState<{x: number; y: number} | undefined>( + undefined, + ) + const popoverRef = useRef(null) + + const close = useCallback(() => setContextMenuPoint(undefined), []) + + const handleContextMenu = useCallback((event: MouseEvent) => { + event.preventDefault() + + setContextMenuPoint({x: event.clientX, y: event.clientY}) + }, []) + + useClickOutsideEvent(close, () => [popoverRef.current]) + + useGlobalKeyDown( + useCallback( + (event) => { + if (event.key === 'Escape') { + close() + } + }, + [close], + ), + ) + + const referenceElement = useMemo(() => { + if (!contextMenuPoint) { + return null + } + + return { + getBoundingClientRect() { + return { + x: contextMenuPoint.x, + y: contextMenuPoint.y, + left: contextMenuPoint.x, + top: contextMenuPoint.y, + right: contextMenuPoint.x, + bottom: contextMenuPoint.y, + width: 0, + height: 0, + } + }, + } as HTMLElement + }, [contextMenuPoint]) return ( <> @@ -45,8 +104,20 @@ export const VersionChip = memo(function VersionChip(props: { text={text} tone={tone} icon={icon} + onContextMenu={handleContextMenu} /> + + ) }) diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionPopoverMenu.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionPopoverMenu.tsx new file mode 100644 index 00000000000..0a86a1e6636 --- /dev/null +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/VersionPopoverMenu.tsx @@ -0,0 +1,153 @@ +import {CalendarIcon, CopyIcon, TrashIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Menu, MenuDivider, Spinner, useToast} from '@sanity/ui' +import {memo, useCallback, useState} from 'react' +import {filter, firstValueFrom} from 'rxjs' +import { + type BundleDocument, + DEFAULT_STUDIO_CLIENT_OPTIONS, + getPublishedId, + getVersionFromId, + getVersionId, + isVersionId, + Translate, + useClient, + useDocumentOperation, + useDocumentStore, + usePerspective, + useTranslation, +} from 'sanity' +import {IntentLink} from 'sanity/router' + +import {AddedVersion} from '../../../../../../core/releases/__telemetry__/releases.telemetry' +import {MenuGroup, MenuItem} from '../../../../../../ui-components' + +export const VersionPopoverMenu = memo(function VersionPopoverMenu(props: { + documentId: string + releases: BundleDocument[] + releasesLoading: boolean + documentType: string + menuReleaseId: string +}) { + const {documentId, releases, releasesLoading, documentType, menuReleaseId} = props + const [isDiscarding, setIsDiscarding] = useState(false) + const {t} = useTranslation() + const {setPerspective} = usePerspective() + const isVersion = isVersionId(documentId) + + const optionsReleaseList = releases.map((release) => ({ + value: release, + })) + + const toast = useToast() + + const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const publishedId = getPublishedId(documentId) + + const releaseId = isVersion ? getVersionFromId(documentId) : documentId + + const {createVersion} = useDocumentOperation(publishedId, documentType, menuReleaseId) + + const telemetry = useTelemetry() + const documentStore = useDocumentStore() + + const handleAddVersion = useCallback( + async (targetRelease: string) => { + // set up the listener before executing + const createVersionSuccess = firstValueFrom( + documentStore.pair + .operationEvents(getPublishedId(documentId), documentType) + .pipe(filter((e) => e.op === 'createVersion' && e.type === 'success')), + ) + + const docId = getVersionId(publishedId, targetRelease) + + createVersion.execute(docId) + + // only change if the version was created successfully + await createVersionSuccess + setPerspective(targetRelease) + + telemetry.log(AddedVersion, { + schemaType: documentType, + documentOrigin: isVersion ? 'version' : 'draft', + }) + }, + [ + createVersion, + documentId, + documentStore.pair, + documentType, + isVersion, + publishedId, + setPerspective, + telemetry, + ], + ) + + const handleDiscardVersion = useCallback(async () => { + setIsDiscarding(true) + try { + // if it's a version it'll always use the versionId based on the menu that it's pressing + // otherwise it'll use the documentId + const docId = isVersion ? getVersionId(documentId, menuReleaseId) : documentId + + await client.delete(docId) + + toast.push({ + closable: true, + status: 'success', + description: ( + + ), + }) + } catch (e) { + toast.push({ + closable: true, + status: 'error', + title: t('release.action.discard-version.failure'), + }) + } + + setIsDiscarding(false) + }, [client, documentId, isVersion, menuReleaseId, t, toast]) + + /* @todo update literal */ + return ( + + {isVersion && ( + + )} + {releasesLoading && } + + {optionsReleaseList.map((option) => ( + handleAddVersion(option.value._id)} + text={option.value.title} + /> + ))} + + + + + ) +})