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 (
+
+ )
+})