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 (
+
+ )
+}
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' && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {DialogTimeZone && }
+
+ )
+}
diff --git a/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx b/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx
new file mode 100644
index 00000000000..470cbe2328a
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/dialog/TitleDescriptionForm.tsx
@@ -0,0 +1,173 @@
+import {Stack} from '@sanity/ui'
+// eslint-disable-next-line camelcase
+import {getTheme_v2} from '@sanity/ui/theme'
+import {type ChangeEvent, useCallback, useEffect, useRef, useState} from 'react'
+import {css, styled} from 'styled-components'
+
+import {useTranslation} from '../../../i18n/hooks/useTranslation'
+import {type EditableReleaseDocument} from '../../index'
+import {DEFAULT_RELEASE_TYPE} from '../../util/const'
+
+const MAX_DESCRIPTION_HEIGHT = 200
+
+const TitleInput = styled.input((props) => {
+ const {color, font} = getTheme_v2(props.theme)
+ return css`
+ resize: none;
+ overflow: hidden;
+ appearance: none;
+ background: none;
+ border: 0;
+ padding: 0;
+ border-radius: 0;
+ outline: none;
+ width: 100%;
+ box-sizing: border-box;
+ font-family: ${font.text.family};
+ font-weight: ${font.text.weights.bold};
+ font-size: ${font.text.sizes[4].fontSize}px;
+ line-height: ${font.text.sizes[4].lineHeight}px;
+ margin: 0;
+ position: relative;
+ z-index: 1;
+ display: block;
+ transition: height 500ms;
+ /* NOTE: This is a hack to disable Chrome’s autofill styles */
+ &:-webkit-autofill,
+ &:-webkit-autofill:hover,
+ &:-webkit-autofill:focus,
+ &:-webkit-autofill:active {
+ -webkit-text-fill-color: var(--input-fg-color) !important;
+ transition: background-color 5000s;
+ transition-delay: 86400s /* 24h */;
+ }
+
+ color: ${color.input.default.enabled.fg};
+
+ &::placeholder {
+ color: ${color.input.default.enabled.placeholder};
+ }
+ `
+})
+
+const DescriptionTextArea = styled.textarea((props) => {
+ const {color, font} = getTheme_v2(props.theme)
+
+ return css`
+ resize: none;
+ overflow: hidden;
+ appearance: none;
+ background: none;
+ border: 0;
+ padding: 0;
+ border-radius: 0;
+ outline: none;
+ width: 100%;
+ box-sizing: border-box;
+ font-family: ${font.text.family};
+ font-weight: ${font.text.weights.regular};
+ font-size: ${font.text.sizes[2].fontSize}px;
+ height: auto;
+ line-height: ${font.text.sizes[2].lineHeight}px;
+ margin: 0;
+ max-width: 624px;
+ position: relative;
+ z-index: 1;
+ display: block;
+ color: ${color.input.default.enabled.fg};
+
+ &::placeholder {
+ color: ${color.input.default.enabled.placeholder};
+ }
+ `
+})
+
+export function TitleDescriptionForm({
+ release,
+ onChange,
+}: {
+ release: EditableReleaseDocument
+ onChange: (changedValue: EditableReleaseDocument) => void
+}): JSX.Element {
+ const descriptionRef = useRef(null)
+
+ const [scrollHeight, setScrollHeight] = useState(46)
+ const [value, setValue] = useState((): EditableReleaseDocument => {
+ return {
+ _id: release?._id,
+ metadata: {
+ title: release?.metadata.title,
+ description: release?.metadata.description,
+ intendedPublishAt: release?.metadata?.intendedPublishAt,
+ releaseType: release?.metadata.releaseType || DEFAULT_RELEASE_TYPE,
+ },
+ } as const
+ })
+ const {t} = useTranslation()
+
+ useEffect(() => {
+ // make sure that the text area for the description has the right height initially
+ if (descriptionRef.current) {
+ setScrollHeight(descriptionRef.current.scrollHeight)
+ }
+ }, [])
+
+ const handleTitleChange = useCallback(
+ (event: ChangeEvent) => {
+ event.preventDefault()
+ const title = event.target.value
+ onChange({...value, metadata: {...value.metadata, title}})
+ // save the values to make input snappier while requests happen in the background
+ setValue({...value, metadata: {...value.metadata, title}})
+ },
+ [onChange, value],
+ )
+
+ const handleDescriptionChange = useCallback(
+ (event: ChangeEvent) => {
+ event.preventDefault()
+ const description = event.target.value
+ onChange({...value, metadata: {...value.metadata, description}})
+ // save the values to make input snappier while requests happen in the background
+ setValue({...value, metadata: {...value.metadata, description}})
+
+ /** we must reset the height in order to make sure that if the text area shrinks,
+ * that the actual input will change height as well */
+ if (descriptionRef.current) {
+ descriptionRef.current.style.overflow = 'hidden'
+ descriptionRef.current.style.height = 'auto'
+ descriptionRef.current.style.height = `${descriptionRef.current.scrollHeight}px`
+
+ if (parseInt(descriptionRef.current.style.height, 10) > MAX_DESCRIPTION_HEIGHT) {
+ descriptionRef.current.style.overflow = 'auto'
+ }
+ }
+
+ setScrollHeight(event.currentTarget.scrollHeight)
+ },
+ [onChange, value],
+ )
+
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx b/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx
new file mode 100644
index 00000000000..93c5d3c9d9a
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/dialog/__tests__/CreateReleaseDialog.test.tsx
@@ -0,0 +1,60 @@
+import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+
+import {createTestProvider} from '../../../../../../test/testUtils/TestProvider'
+import {activeASAPRelease} from '../../../__fixtures__/release.fixture'
+import {type ReleaseDocument} from '../../../index'
+import {useReleaseOperationsMockReturn} from '../../../store/__tests__/__mocks/useReleaseOperations.mock'
+import {CreateReleaseDialog} from '../CreateReleaseDialog'
+
+vi.mock('../../../store/useReleaseOperations', () => ({
+ useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn),
+}))
+
+describe('CreateReleaseDialog', () => {
+ describe('when creating a new release', () => {
+ const onCancelMock = vi.fn()
+ const onSubmitMock = vi.fn()
+
+ beforeEach(async () => {
+ onCancelMock.mockClear()
+ onSubmitMock.mockClear()
+
+ const wrapper = await createTestProvider()
+ render(, {wrapper})
+ })
+
+ it('should render the dialog', () => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should call onCancel when dialog is closed', () => {
+ fireEvent.click(screen.getByRole('button', {name: /close/i}))
+
+ expect(onCancelMock).toHaveBeenCalled()
+ })
+
+ it('should call createRelease and onCreate when form is submitted', async () => {
+ const value: Partial = activeASAPRelease
+
+ act(async () => {
+ const titleInput = screen.getByTestId('release-form-title')
+ fireEvent.change(titleInput, {target: {value: value.metadata?.title}})
+
+ const submitButton = screen.getByTestId('submit-release-button')
+ fireEvent.click(submitButton)
+
+ waitFor(async () => {
+ await Promise.resolve()
+
+ expect(onSubmitMock).toHaveBeenCalledOnce()
+ expect(useReleaseOperationsMockReturn.createRelease).toHaveBeenCalledWith(
+ expect.objectContaining({
+ _id: expect.stringContaining('releases'),
+ }),
+ )
+ })
+ })
+ })
+ })
+})
diff --git a/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx
new file mode 100644
index 00000000000..46ea8866f5e
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseForm.test.tsx
@@ -0,0 +1,198 @@
+import {fireEvent, render, screen} from '@testing-library/react'
+import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
+
+import {createTestProvider} from '../../../../../../test/testUtils/TestProvider'
+import {useDateTimeFormat} from '../../../../hooks'
+import {type EditableReleaseDocument, type ReleaseDocument, useReleases} from '../../../store'
+import {RELEASE_DOCUMENT_TYPE} from '../../../store/constants'
+import {ReleaseForm} from '../ReleaseForm'
+
+vi.mock('../../../../../core/hooks/useDateTimeFormat', () => ({
+ useDateTimeFormat: vi.fn(),
+}))
+vi.mock('../../../store/useReleases', () => ({
+ useReleases: vi.fn(),
+}))
+
+vi.mock('../../../i18n/hooks/useTranslation', () => ({
+ useTranslate: vi.fn().mockReturnValue({
+ t: vi.fn(),
+ }),
+}))
+
+const mockUseReleases = useReleases as Mock
+const mockUseDateTimeFormat = useDateTimeFormat as Mock
+
+describe('ReleaseForm', () => {
+ const onChangeMock = vi.fn()
+ const onErrorMock = vi.fn()
+ const valueMock: EditableReleaseDocument = {
+ _id: 'very-random',
+ metadata: {
+ title: '',
+ description: '',
+ },
+ }
+
+ describe('when creating a new release', () => {
+ beforeEach(async () => {
+ onChangeMock.mockClear()
+ onErrorMock.mockClear()
+
+ // Mock the data returned by useBundles hook
+ const mockData: ReleaseDocument[] = [
+ {
+ _id: 'db76c50e-358b-445c-a57c-8344c588a5d5',
+ _type: RELEASE_DOCUMENT_TYPE,
+ _createdAt: '2024-07-02T11:37:51Z',
+ _updatedAt: '2024-07-12T10:39:32Z',
+ name: 'spring-drop',
+ createdBy: 'unknown',
+ state: 'active',
+ metadata: {
+ releaseType: 'asap',
+ title: 'Spring Drop',
+ description: 'What a spring drop, allergies galore 🌸',
+ },
+ },
+ // Add more mock data if needed
+ ]
+ mockUseReleases.mockReturnValue({
+ data: mockData,
+ loading: false,
+ dispatch: vi.fn(),
+ error: undefined,
+ archivedReleases: [],
+ releasesIds: [],
+ })
+
+ mockUseDateTimeFormat.mockReturnValue({format: vi.fn().mockReturnValue('Mocked date')})
+
+ const wrapper = await createTestProvider()
+ render(, {
+ wrapper,
+ })
+ })
+
+ it('should render the form fields', () => {
+ expect(screen.getByTestId('release-form-title')).toBeInTheDocument()
+ expect(screen.getByTestId('release-form-description')).toBeInTheDocument()
+ //expect(screen.getByTestId('release-form-publish-at')).toBeInTheDocument()
+ })
+
+ it('should call onChange when title input value changes', () => {
+ const titleInput = screen.getByTestId('release-form-title')
+ fireEvent.change(titleInput, {target: {value: 'Bundle 1'}})
+
+ expect(onChangeMock).toHaveBeenCalledWith({
+ ...valueMock,
+ metadata: {...valueMock.metadata, title: 'Bundle 1'},
+ })
+ })
+
+ it('should call onChange when description textarea value changes', () => {
+ const descriptionTextarea = screen.getByTestId('release-form-description')
+ fireEvent.change(descriptionTextarea, {target: {value: 'New Description'}})
+
+ expect(onChangeMock).toHaveBeenCalledWith({
+ ...valueMock,
+ metadata: {...valueMock.metadata, description: 'New Description'},
+ })
+ })
+
+ /*it('should call onChange when publishAt input value changes', () => {
+ const publishAtInput = screen.getByTestId('release-form-publish-at')
+ fireEvent.change(publishAtInput, {target: {value: '2022-01-01'}})
+
+ expect(onChangeMock).toHaveBeenCalledWith({...valueMock, publishAt: '2022-01-01'})
+ })
+
+ it('should call onChange with undefined when publishAt input value is empty', () => {
+ const publishAtInput = screen.getByTestId('release-form-publish-at')
+ fireEvent.change(publishAtInput, {target: {value: ' '}})
+
+ expect(onChangeMock).toHaveBeenCalledWith({...valueMock, publishAt: ''})
+ })*/
+
+ /*it('should show an error when the publishAt input value is invalid', () => {
+ const publishAtInput = screen.getByTestId('release-form-publish-at')
+ fireEvent.change(publishAtInput, {target: {value: 'invalid-date'}})
+
+ expect(screen.getByTestId('input-validation-icon-error')).toBeInTheDocument()
+ })*/
+ })
+
+ describe('when updating an existing release', () => {
+ const existingBundleValue: ReleaseDocument = {
+ _id: 'db76c50e-358b-445c-a57c-8344c588a5d5',
+ _type: RELEASE_DOCUMENT_TYPE,
+ _createdAt: '2024-07-02T11:37:51Z',
+ _updatedAt: '2024-07-12T10:39:32Z',
+ name: 'spring-drop',
+ createdBy: 'unknown',
+ state: 'active',
+ metadata: {
+ title: 'Summer Drop',
+ description: 'Summer time',
+ releaseType: 'asap',
+ },
+ }
+ beforeEach(async () => {
+ onChangeMock.mockClear()
+ onErrorMock.mockClear()
+
+ // Mock the data returned by useBundles hook
+ const mockData: ReleaseDocument[] = [
+ {
+ _id: 'db76c50e-358b-445c-a57c-8344c588a5d5',
+ _type: RELEASE_DOCUMENT_TYPE,
+ _createdAt: '2024-07-02T11:37:51Z',
+ _updatedAt: '2024-07-12T10:39:32Z',
+ name: 'spring-drop',
+ createdBy: 'unknown',
+ state: 'active',
+ metadata: {
+ releaseType: 'asap',
+ title: 'Spring Drop',
+ description: 'What a spring drop, allergies galore 🌸',
+ },
+ },
+ // Add more mock data if needed
+ ]
+ mockUseReleases.mockReturnValue({
+ data: mockData,
+ loading: false,
+ dispatch: vi.fn(),
+ error: undefined,
+ archivedReleases: [],
+ releasesIds: [],
+ })
+
+ mockUseDateTimeFormat.mockReturnValue({format: vi.fn().mockReturnValue('Mocked date')})
+
+ const wrapper = await createTestProvider()
+ render(, {
+ wrapper,
+ })
+ })
+
+ it('should allow for any title to be used', async () => {
+ const titleInput = screen.getByTestId('release-form-title')
+ expect(titleInput).toHaveValue(existingBundleValue.metadata.title)
+ // the slug of this title already exists,
+ // but the slug for the existing edited release will not be changed
+ fireEvent.change(titleInput, {target: {value: 'Spring Drop'}})
+
+ expect(screen.queryByTestId('input-validation-icon-error')).not.toBeInTheDocument()
+ })
+
+ it('should populate the form with the existing release values', () => {
+ expect(screen.getByTestId('release-form-title')).toHaveValue(
+ existingBundleValue.metadata.title,
+ )
+ expect(screen.getByTestId('release-form-description')).toHaveValue(
+ existingBundleValue.metadata.description,
+ )
+ })
+ })
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx
new file mode 100644
index 00000000000..4d3199333a1
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx
@@ -0,0 +1,237 @@
+import {LockIcon} from '@sanity/icons'
+import {type BadgeTone, useClickOutsideEvent, useGlobalKeyDown} from '@sanity/ui'
+import {
+ memo,
+ type MouseEvent,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {styled} from 'styled-components'
+
+import {Button, Popover, Tooltip} from '../../../../ui-components'
+import {getVersionId} from '../../../util/draftUtils'
+import {useVersionOperations} from '../../hooks/useVersionOperations'
+import {type ReleaseDocument} from '../../store/types'
+import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId'
+import {DiscardVersionDialog} from '../dialog/DiscardVersionDialog'
+import {ReleaseAvatar} from '../ReleaseAvatar'
+import {VersionContextMenu} from './contextMenu/VersionContextMenu'
+import {CopyToNewReleaseDialog} from './dialog/CopyToNewReleaseDialog'
+
+const Chip = styled(Button)`
+ border-radius: 9999px !important;
+ transition: none;
+ text-decoration: none !important;
+ cursor: pointer;
+
+ // target enabled state
+ &:not([data-disabled='true']) {
+ --card-border-color: var(--card-badge-default-bg-color);
+ }
+
+ &[data-disabled='true'] {
+ color: var(--card-muted-fg-color);
+ }
+`
+
+/**
+ * @internal
+ */
+export const VersionChip = memo(function VersionChip(props: {
+ disabled?: boolean
+ selected: boolean
+ tooltipContent: ReactNode
+ onClick: () => void
+ text: string
+ tone: BadgeTone
+ locked?: boolean
+ contextValues: {
+ documentId: string
+ releases: ReleaseDocument[]
+ releasesLoading: boolean
+ documentType: string
+ menuReleaseId: string
+ fromRelease: string
+ isVersion: boolean
+ disabled?: boolean
+ }
+}) {
+ const {
+ disabled,
+ selected,
+ tooltipContent,
+ onClick,
+ text,
+ tone,
+ locked = false,
+ contextValues: {
+ documentId,
+ releases,
+ releasesLoading,
+ documentType,
+ menuReleaseId,
+ fromRelease,
+ isVersion,
+ disabled: contextMenuDisabled = false,
+ },
+ } = props
+
+ const [contextMenuPoint, setContextMenuPoint] = useState<{x: number; y: number} | undefined>(
+ undefined,
+ )
+ const popoverRef = useRef(null)
+ const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false)
+ const [isCreateReleaseDialogOpen, setIsCreateReleaseDialogOpen] = useState(false)
+
+ const chipRef = useRef(null)
+
+ useEffect(() => {
+ if (selected) chipRef.current?.scrollIntoView({inline: 'center'})
+ }, [selected])
+
+ const docId = isVersion ? getVersionId(documentId, fromRelease) : documentId // operations recognises publish and draft as empty
+
+ const {createVersion} = useVersionOperations()
+
+ const close = useCallback(() => setContextMenuPoint(undefined), [])
+
+ const handleContextMenu = useCallback((event: MouseEvent) => {
+ event.preventDefault()
+
+ setContextMenuPoint({x: event.clientX, y: event.clientY})
+ }, [])
+
+ useClickOutsideEvent(
+ () => {
+ if (contextMenuPoint?.x && contextMenuPoint?.y) {
+ close()
+ }
+ },
+ () => [popoverRef.current],
+ )
+
+ useGlobalKeyDown(
+ useCallback(
+ (event) => {
+ if (event.key === 'Escape') {
+ close()
+ }
+ },
+ [close],
+ ),
+ )
+
+ const openDiscardDialog = useCallback(() => {
+ setIsDiscardDialogOpen(true)
+ }, [setIsDiscardDialogOpen])
+
+ const openCreateReleaseDialog = useCallback(() => {
+ setIsCreateReleaseDialogOpen(true)
+ }, [setIsCreateReleaseDialogOpen])
+
+ const handleAddVersion = useCallback(
+ async (targetRelease: string) => {
+ await createVersion(getBundleIdFromReleaseDocumentId(targetRelease), docId)
+ close()
+ },
+ [createVersion, docId, close],
+ )
+
+ const referenceElement = useMemo(() => {
+ if (!contextMenuPoint) {
+ return null
+ }
+
+ return {
+ getBoundingClientRect() {
+ return {
+ x: contextMenuPoint.x,
+ y: contextMenuPoint.y,
+ left: contextMenuPoint.x,
+ top: contextMenuPoint.y,
+ right: contextMenuPoint.x,
+ bottom: contextMenuPoint.y,
+ width: 0,
+ height: 0,
+ }
+ },
+ } as HTMLElement
+ }, [contextMenuPoint])
+
+ return (
+ <>
+
+ (chipRef.current = ref)}
+ disabled={disabled}
+ mode="bleed"
+ onClick={onClick}
+ padding={2}
+ paddingRight={3}
+ selected={selected}
+ style={{flex: 'none'}}
+ text={text}
+ tone={tone}
+ icon={}
+ iconRight={locked && LockIcon}
+ onContextMenu={handleContextMenu}
+ />
+
+
+
+ }
+ fallbackPlacements={[]}
+ open={Boolean(referenceElement)}
+ portal
+ placement="bottom-start"
+ ref={popoverRef}
+ referenceElement={referenceElement}
+ zOffset={10}
+ />
+
+ {isDiscardDialogOpen && (
+ setIsDiscardDialogOpen(false)}
+ documentId={
+ isVersion
+ ? getVersionId(documentId, getBundleIdFromReleaseDocumentId(menuReleaseId))
+ : documentId
+ }
+ documentType={documentType}
+ />
+ )}
+
+ {isCreateReleaseDialogOpen && (
+ setIsCreateReleaseDialogOpen(false)}
+ onCreateVersion={handleAddVersion}
+ documentId={
+ isVersion
+ ? getVersionId(documentId, getBundleIdFromReleaseDocumentId(menuReleaseId))
+ : documentId
+ }
+ documentType={documentType}
+ tone={tone}
+ title={text}
+ />
+ )}
+ >
+ )
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx
new file mode 100644
index 00000000000..673ebfcd04c
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx
@@ -0,0 +1,110 @@
+import {AddIcon, CalendarIcon, CopyIcon, TrashIcon} from '@sanity/icons'
+import {Menu, MenuDivider, Spinner, Stack} from '@sanity/ui'
+import {memo} from 'react'
+import {IntentLink} from 'sanity/router'
+import {styled} from 'styled-components'
+
+import {MenuGroup} from '../../../../../ui-components/menuGroup/MenuGroup'
+import {MenuItem} from '../../../../../ui-components/menuItem/MenuItem'
+import {useTranslation} from '../../../../i18n/hooks/useTranslation'
+import {isPublishedId} from '../../../../util/draftUtils'
+import {type ReleaseDocument} from '../../../store/types'
+import {isReleaseScheduledOrScheduling} from '../../../util/util'
+import {VersionContextMenuItem} from './VersionContextMenuItem'
+
+const ReleasesList = styled(Stack)`
+ max-width: 300px;
+ max-height: 200px;
+ overflow-y: auto;
+`
+
+export const VersionContextMenu = memo(function VersionContextMenu(props: {
+ documentId: string
+ releases: ReleaseDocument[]
+ releasesLoading: boolean
+ fromRelease: string
+ isVersion: boolean
+ onDiscard: () => void
+ onCreateRelease: () => void
+ onCreateVersion: (targetId: string) => void
+ disabled?: boolean
+ locked?: boolean
+}) {
+ const {
+ documentId,
+ releases,
+ releasesLoading,
+ fromRelease,
+ isVersion,
+ onDiscard,
+ onCreateRelease,
+ onCreateVersion,
+ disabled,
+ locked,
+ } = props
+ const {t} = useTranslation()
+ const isPublished = isPublishedId(documentId) && !isVersion
+ const optionsReleaseList = releases.map((release) => ({
+ value: release,
+ }))
+
+ const releaseId = isVersion ? fromRelease : documentId
+
+ return (
+ <>
+
+ >
+ )
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx
new file mode 100644
index 00000000000..f56710641f8
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenuItem.tsx
@@ -0,0 +1,41 @@
+import {LockIcon} from '@sanity/icons'
+import {Flex, Stack, Text} from '@sanity/ui'
+import {memo} from 'react'
+
+import {useTranslation} from '../../../../i18n'
+import {formatRelativeLocale} from '../../../../util/formatRelativeLocale'
+import {type ReleaseDocument} from '../../../store/types'
+import {getReleaseTone} from '../../../util/getReleaseTone'
+import {getPublishDateFromRelease, isReleaseScheduledOrScheduling} from '../../../util/util'
+import {ReleaseAvatar} from '../../ReleaseAvatar'
+
+export const VersionContextMenuItem = memo(function VersionContextMenuItem(props: {
+ release: ReleaseDocument
+}) {
+ const {release} = props
+ const {t} = useTranslation()
+ const isScheduled = isReleaseScheduledOrScheduling(release)
+
+ return (
+
+
+
+
+ {release.metadata?.title || t('release.placeholder-untitled-release')}
+
+
+ {release.metadata.releaseType === 'asap' && <>{t('release.type.asap')}>}
+ {release.metadata.releaseType === 'scheduled' &&
+ (release.metadata.intendedPublishAt ? (
+ <>{formatRelativeLocale(getPublishDateFromRelease(release), new Date())}>
+ ) : (
+ /** should not be allowed to do, but a fall back in case if somehow no date is added */
+ <>{t('release.chip.tooltip.unknown-date')}>
+ ))}
+ {release.metadata.releaseType === 'undecided' && <>{t('release.type.undecided')}>}
+
+
+ {isScheduled && }
+
+ )
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx
new file mode 100644
index 00000000000..bc5924c7e59
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenu.test.tsx
@@ -0,0 +1,133 @@
+import {fireEvent, render, screen, waitFor} from '@testing-library/react'
+import * as sanity from 'sanity'
+import {describe, expect, it, vi} from 'vitest'
+
+import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider'
+import {VersionContextMenu} from '../VersionContextMenu'
+
+vi.mock('sanity/router', () => ({
+ IntentLink: ({children}: {children: React.ReactNode}) => {children}
,
+ route: {
+ create: vi.fn(),
+ },
+}))
+
+describe('VersionContextMenu', () => {
+ const mockReleases: sanity.ReleaseDocument[] = [
+ {
+ _id: '_.releases.release1',
+ name: 'release1',
+ _type: 'system.release',
+ _updatedAt: '',
+ _createdAt: '',
+ state: 'active',
+ createdBy: 'safsd',
+ metadata: {
+ title: 'Release 1',
+ releaseType: 'asap',
+ },
+ },
+ {
+ _id: '_.releases.release2',
+ name: 'release2',
+ _type: 'system.release',
+ _createdAt: '',
+ _updatedAt: '',
+ createdBy: 'safsd',
+ state: 'active',
+ metadata: {
+ title: 'Release 2',
+ releaseType: 'asap',
+ },
+ },
+ ]
+
+ const defaultProps = {
+ documentId: 'versions.bundle.doc1',
+ releases: mockReleases,
+ releasesLoading: false,
+ fromRelease: 'release1',
+ isVersion: true,
+ onDiscard: vi.fn(),
+ onCreateRelease: vi.fn(),
+ onCreateVersion: vi.fn(),
+ disabled: false,
+ }
+
+ it('renders the menu items correctly', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+
+ expect(screen.getByText('Copy version to')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('Copy version to'))
+ await waitFor(() => {
+ expect(screen.getByText('New Release')).toBeInTheDocument()
+ expect(screen.getByText('Release 1')).toBeInTheDocument()
+ expect(screen.getByText('Release 2')).toBeInTheDocument()
+ })
+ })
+
+ it('calls onCreateRelease when "New release" is clicked', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+
+ fireEvent.click(screen.getByText('Copy version to'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('New Release'))
+ })
+ expect(defaultProps.onCreateRelease).toHaveBeenCalled()
+ })
+
+ it('hides discard version on published chip', async () => {
+ const wrapper = await createTestProvider()
+ const publishedProps = {
+ ...defaultProps,
+ documentId: 'testid',
+ isVersion: false,
+ }
+
+ /** @todo we can probably rewrite this to be better */
+ vi.spyOn(sanity, 'isPublishedId').mockReturnValue(true)
+
+ render(, {wrapper})
+
+ expect(screen.queryByTestId('discard')).not.toBeInTheDocument()
+ })
+
+ it('calls onDiscard when "Discard version" is clicked', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Discard version'))
+ })
+ expect(defaultProps.onDiscard).toHaveBeenCalled()
+ })
+
+ it('calls onCreateRelease when a "new release" is clicked', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+
+ fireEvent.click(screen.getByText('Copy version to'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('New Release'))
+ })
+ expect(defaultProps.onCreateRelease).toHaveBeenCalled()
+ })
+
+ it('calls onCreateVersion when a release is clicked and sets the perspective to the release', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+
+ fireEvent.click(screen.getByText('Copy version to'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Release 2'))
+ })
+ expect(defaultProps.onCreateRelease).toHaveBeenCalled()
+ })
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx
new file mode 100644
index 00000000000..8db2e334c28
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/__tests__/VersionContextMenuItem.test.tsx
@@ -0,0 +1,85 @@
+import {render, screen} from '@testing-library/react'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+
+import {createTestProvider} from '../../../../../../../test/testUtils/TestProvider'
+import {type ReleaseDocument, type ReleaseType} from '../../../../store/types'
+import {VersionContextMenuItem} from '../VersionContextMenuItem'
+
+const mockRelease: ReleaseDocument = {
+ _id: '_.releases.1',
+ _type: 'system.release',
+ createdBy: '',
+ _createdAt: '',
+ _updatedAt: '',
+ state: 'active',
+ name: '1',
+ metadata: {
+ title: 'Test Release',
+ releaseType: 'scheduled',
+ intendedPublishAt: '2023-10-01T10:00:00Z',
+ },
+}
+
+vi.mock('../../../../../util/formatRelativeLocale', () => ({
+ formatRelativeLocale: () => 'formatted date',
+}))
+
+describe('VersionContextMenuItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders release title', async () => {
+ const wrapper = await createTestProvider()
+ render(, {wrapper})
+ expect(screen.getByText('Test Release')).toBeInTheDocument()
+ })
+
+ it('renders release type as scheduled with date', async () => {
+ const wrapper = await createTestProvider()
+ const scheduledRelease = {...mockRelease, releaseType: 'scheduled' as ReleaseType}
+
+ render(, {wrapper})
+ expect(screen.getByText('formatted date')).toBeInTheDocument()
+ })
+
+ it('renders release type as ASAP', async () => {
+ const asapRelease: ReleaseDocument = {
+ ...mockRelease,
+ metadata: {...mockRelease.metadata, releaseType: 'asap'},
+ }
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+ expect(screen.getByText('ASAP')).toBeInTheDocument()
+ })
+
+ it('renders release type as undecided', async () => {
+ const asapRelease: ReleaseDocument = {
+ ...mockRelease,
+ metadata: {...mockRelease.metadata, releaseType: 'undecided'},
+ }
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+ expect(screen.getByText('Undecided')).toBeInTheDocument()
+ })
+
+ it('renders "Unknown date" for scheduled release without date', async () => {
+ const noDateRelease: ReleaseDocument = {
+ ...mockRelease,
+ metadata: {...mockRelease.metadata, releaseType: 'scheduled', intendedPublishAt: undefined},
+ }
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+ expect(screen.getByText('Unknown date')).toBeInTheDocument()
+ })
+
+ it('renders ReleaseAvatar component', async () => {
+ const wrapper = await createTestProvider()
+
+ render(, {wrapper})
+ expect(screen.getByTestId('release-avatar-primary')).toBeInTheDocument()
+ })
+})
diff --git a/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx
new file mode 100644
index 00000000000..2798299014d
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx
@@ -0,0 +1,140 @@
+import {useTelemetry} from '@sanity/telemetry/react'
+import {type BadgeTone, Box, Flex, Text, useToast} from '@sanity/ui'
+import {useCallback, useState} from 'react'
+
+import {Dialog} from '../../../../../ui-components/dialog/Dialog'
+import {LoadingBlock} from '../../../../components/loadingBlock/LoadingBlock'
+import {useSchema} from '../../../../hooks/useSchema'
+import {useTranslation} from '../../../../i18n/hooks/useTranslation'
+import {Preview} from '../../../../preview/components/Preview'
+import {CreatedRelease} from '../../../__telemetry__/releases.telemetry'
+import {type EditableReleaseDocument} from '../../../store/types'
+import {useReleaseOperations} from '../../../store/useReleaseOperations'
+import {DEFAULT_RELEASE_TYPE} from '../../../util/const'
+import {createReleaseId} from '../../../util/createReleaseId'
+import {ReleaseForm} from '../../dialog/ReleaseForm'
+import {ReleaseAvatar} from '../../ReleaseAvatar'
+
+export function CopyToNewReleaseDialog(props: {
+ onClose: () => void
+ documentId: string
+ documentType: string
+ tone: BadgeTone
+ title: string
+ onCreateVersion: (releaseId: string) => void
+}): JSX.Element {
+ const {onClose, documentId, documentType, tone, title, onCreateVersion} = props
+ const {t} = useTranslation()
+ const toast = useToast()
+
+ const schema = useSchema()
+ const schemaType = schema.get(documentType)
+
+ const [newReleaseId] = useState(createReleaseId())
+
+ const [value, setValue] = useState((): EditableReleaseDocument => {
+ return {
+ _id: newReleaseId,
+ metadata: {
+ title: '',
+ description: '',
+ releaseType: DEFAULT_RELEASE_TYPE,
+ },
+ } as const
+ })
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const telemetry = useTelemetry()
+ const {createRelease} = useReleaseOperations()
+
+ const displayTitle = title || t('release.placeholder-untitled-release')
+
+ const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => {
+ setValue(changedValue)
+ }, [])
+
+ const handleAddVersion = useCallback(async () => {
+ onCreateVersion(newReleaseId)
+ }, [onCreateVersion, newReleaseId])
+
+ const handleCreateRelease = useCallback(async () => {
+ try {
+ setIsSubmitting(true)
+
+ await createRelease(value)
+
+ await handleAddVersion()
+ telemetry.log(CreatedRelease, {origin: 'document-panel'})
+ } catch (err) {
+ console.error(err)
+ toast.push({
+ closable: true,
+ status: 'error',
+ title: `Failed to create release`,
+ description: err.message,
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }, [createRelease, handleAddVersion, telemetry, toast, value])
+
+ return (
+
+ )
+}
diff --git a/packages/sanity/src/core/releases/components/index.ts b/packages/sanity/src/core/releases/components/index.ts
new file mode 100644
index 00000000000..fca3f050243
--- /dev/null
+++ b/packages/sanity/src/core/releases/components/index.ts
@@ -0,0 +1,4 @@
+export * from './dialog/DiscardVersionDialog'
+export * from './dialog/ReleaseForm'
+export * from './documentHeader/VersionChip'
+export * from './ReleaseAvatar'
diff --git a/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx b/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx
new file mode 100644
index 00000000000..a7544ea9c9b
--- /dev/null
+++ b/packages/sanity/src/core/releases/contexts/ReleasesMetadataProvider.tsx
@@ -0,0 +1,113 @@
+import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
+import {useObservable} from 'react-rx'
+import {ReleasesMetadataContext} from 'sanity/_singletons'
+
+import {type MetadataWrapper} from '../store/createReleaseMetadataAggregator'
+import {type ReleasesMetadata} from '../store/useReleasesMetadata'
+import {useReleasesStore} from '../store/useReleasesStore'
+
+/**
+ * @internal
+ */
+export interface ReleasesMetadataContextValue {
+ state: MetadataWrapper
+ addReleaseIdsToListener: (slugs: string[]) => void
+ removeReleaseIdsFromListener: (slugs: string[]) => void
+}
+
+const DEFAULT_METADATA_STATE: MetadataWrapper = {
+ data: null,
+ error: null,
+ loading: false,
+}
+
+const ReleasesMetadataProviderInner = ({children}: {children: React.ReactNode}) => {
+ const [listenerReleaseIds, setListenerReleaseIds] = useState([])
+ const {getMetadataStateForSlugs$} = useReleasesStore()
+ const [releasesMetadata, setReleasesMetadata] = useState | null>(
+ null,
+ )
+
+ const memoObservable = useMemo(
+ () => getMetadataStateForSlugs$(listenerReleaseIds.map((slug) => slug)),
+ [getMetadataStateForSlugs$, listenerReleaseIds],
+ )
+
+ const observedResult = useObservable(memoObservable) || DEFAULT_METADATA_STATE
+
+ // patch metadata in local state
+ useEffect(
+ () =>
+ setReleasesMetadata((prevReleaseMetadata) => {
+ if (!observedResult.data) return prevReleaseMetadata
+
+ return {...(prevReleaseMetadata || {}), ...observedResult.data}
+ }),
+ [observedResult.data],
+ )
+
+ const addReleaseIdsToListener = useCallback((addReleaseIds: (string | undefined)[]) => {
+ setListenerReleaseIds((prevSlugs) => [
+ ...prevSlugs,
+ ...addReleaseIds.filter((releaseId): releaseId is string => typeof releaseId === 'string'),
+ ])
+ }, [])
+
+ const removeReleaseIdsFromListener = useCallback((releaseIds: string[]) => {
+ setListenerReleaseIds((prevSlugs) => {
+ const {nextSlugs} = prevSlugs.reduce<{removedSlugs: string[]; nextSlugs: string[]}>(
+ (acc, slug) => {
+ const {removedSlugs, nextSlugs: accNextSlugs} = acc
+ /**
+ * In cases where multiple consumers are listening to the same release id
+ * the release id will appear multiple times in listenerReleaseIds array
+ * removing should only remove 1 instance of the slug and retain all others
+ */
+ if (releaseIds.includes(slug) && !removedSlugs.includes(slug)) {
+ return {removedSlugs: [...removedSlugs, slug], nextSlugs: accNextSlugs}
+ }
+ return {removedSlugs, nextSlugs: [...accNextSlugs, slug]}
+ },
+ {removedSlugs: [], nextSlugs: []},
+ )
+ return nextSlugs
+ })
+ }, [])
+
+ const context = useMemo<{
+ addReleaseIdsToListener: (slugs: string[]) => void
+ removeReleaseIdsFromListener: (slugs: string[]) => void
+ state: MetadataWrapper
+ }>(
+ () => ({
+ addReleaseIdsToListener: addReleaseIdsToListener,
+ removeReleaseIdsFromListener: removeReleaseIdsFromListener,
+ state: {...observedResult, data: releasesMetadata},
+ }),
+ [addReleaseIdsToListener, releasesMetadata, observedResult, removeReleaseIdsFromListener],
+ )
+
+ return (
+ {children}
+ )
+}
+
+export const ReleasesMetadataProvider = ({children}: {children: React.ReactNode}) => {
+ const context = useContext(ReleasesMetadataContext)
+
+ // Avoid mounting the provider if it's already provided by a parent
+ if (context) return children
+ return {children}
+}
+
+export const useReleasesMetadataProvider = (): ReleasesMetadataContextValue => {
+ const contextValue = useContext(ReleasesMetadataContext)
+
+ return (
+ contextValue || {
+ state: DEFAULT_METADATA_STATE,
+ addReleaseIdsToListener: () => null,
+ removeReleaseIdsFromListener: () => null,
+ }
+ )
+}
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/usePerspective.mock.ts b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/usePerspective.mock.ts
new file mode 100644
index 00000000000..23687e0f216
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/usePerspective.mock.ts
@@ -0,0 +1,19 @@
+import {type Mock, type Mocked, vi} from 'vitest'
+
+import {LATEST} from '../../../util/const'
+import {type PerspectiveValue, usePerspective} from '../../usePerspective'
+
+export const usePerspectiveMockReturn: Mocked = {
+ perspective: undefined,
+ excludedPerspectives: [],
+ setPerspective: vi.fn(),
+ currentGlobalBundle: LATEST,
+ setPerspectiveFromReleaseId: vi.fn(),
+ setPerspectiveFromReleaseDocumentId: vi.fn(),
+ toggleExcludedPerspective: vi.fn(),
+ isPerspectiveExcluded: vi.fn(),
+ currentGlobalBundleId: 'drafts',
+ bundlesPerspective: [],
+}
+
+export const mockUsePerspective = usePerspective as Mock
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts
new file mode 100644
index 00000000000..1464c2a5fec
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/__tests__/__mocks__/useVersionOperations.mock.ts
@@ -0,0 +1,10 @@
+import {type Mock, type Mocked, vi} from 'vitest'
+
+import {useVersionOperations, type VersionOperationsValue} from '../../useVersionOperations'
+
+export const useVersionOperationsReturn: Mocked = {
+ createVersion: vi.fn(),
+ discardVersion: vi.fn(),
+}
+
+export const mockUseVersionOperations = useVersionOperations as Mock
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx
new file mode 100644
index 00000000000..89886491c4a
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx
@@ -0,0 +1,120 @@
+import {renderHook, waitFor} from '@testing-library/react'
+import {delay, of} from 'rxjs'
+import {describe, expect, it, type Mock, vi} from 'vitest'
+
+import {type DocumentPreviewStore} from '../../../preview'
+import {type DocumentIdSetObserverState} from '../../../preview/liveDocumentIdSet'
+import {useDocumentPreviewStore} from '../../../store'
+import {getPublishedId, type PublishedId} from '../../../util/draftUtils'
+import {type ReleaseDocument, useReleases} from '../../store'
+import {RELEASE_DOCUMENTS_PATH} from '../../store/constants'
+import {useDocumentVersions} from '../useDocumentVersions'
+
+vi.mock('../../store', () => ({
+ useReleasesMetadata: vi.fn(),
+ useReleases: vi.fn(),
+}))
+
+vi.mock('../../../store', () => ({
+ useDocumentPreviewStore: vi.fn(),
+}))
+
+vi.mock('../../../util/draftUtils', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getPublishedId: vi.fn(),
+}))
+
+const mockReleases = [
+ {
+ _id: `${RELEASE_DOCUMENTS_PATH}.spring-drop`,
+ name: 'spring-drop',
+ _type: 'system-tmp.release',
+ _updatedAt: '2024-07-12T10:39:32Z',
+ _createdAt: '2024-07-02T11:37:51Z',
+ createdBy: 'pzAhBTkNX',
+ state: 'active',
+ metadata: {
+ description: 'What a spring drop, allergies galore 🌸',
+ title: 'Spring Drop',
+ icon: 'heart-filled',
+ hue: 'magenta',
+ releaseType: 'asap',
+ },
+ },
+ {
+ _id: 'winter-drop',
+ _type: 'system-tmp.release',
+ name: 'winter-drop',
+ _createdAt: '2024-07-02T11:37:51Z',
+ _updatedAt: '2024-07-12T10:39:32Z',
+ createdBy: 'pzAhBTkNX',
+ state: 'active',
+ metadata: {
+ description: 'What a winter drop',
+ title: 'Winter Drop',
+ icon: 'heart-filled',
+ hue: 'purple',
+ releaseType: 'asap',
+ },
+ },
+] satisfies ReleaseDocument[]
+
+async function setupMocks({
+ releases,
+ versionIds,
+}: {
+ releases: ReleaseDocument[]
+ versionIds: string[]
+}) {
+ const mockUseReleases = useReleases as Mock
+ const mockDocumentPreviewStore = useDocumentPreviewStore as Mock
+ const mockedGetPublishedId = getPublishedId as Mock
+
+ mockUseReleases.mockReturnValue({
+ data: releases,
+ loading: false,
+ dispatch: vi.fn(),
+ stack: [],
+ })
+
+ mockedGetPublishedId.mockReturnValue('document-1' as PublishedId)
+
+ mockDocumentPreviewStore.mockReturnValue({
+ unstable_observeDocumentIdSet: vi
+ .fn()
+ .mockImplementation(() =>
+ of({status: 'connected', documentIds: versionIds} as DocumentIdSetObserverState).pipe(
+ // simulate async initial emission
+ delay(0),
+ ),
+ ),
+ } as unknown as DocumentPreviewStore)
+}
+
+describe('useDocumentVersions', () => {
+ it('should return initial state', async () => {
+ await setupMocks({releases: mockReleases, versionIds: []})
+
+ const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'}))
+ expect(result.current.loading).toBe(true)
+ expect(result.current.error).toBe(null)
+ expect(result.current.data).toEqual([])
+ })
+
+ it('should return an empty array if no versions are found', async () => {
+ await setupMocks({releases: mockReleases, versionIds: []})
+ const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'}))
+ expect(result.current.data).toEqual([])
+ })
+
+ it('should return the releases if versions are found', async () => {
+ await setupMocks({
+ releases: [mockReleases[0]],
+ versionIds: ['versions.spring-drop.document-1'],
+ })
+ const {result} = renderHook(() => useDocumentVersions({documentId: 'document-1'}))
+ await waitFor(() => {
+ expect(result.current.data).toEqual([mockReleases[0]])
+ })
+ })
+})
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx b/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx
new file mode 100644
index 00000000000..cbefcbca5fc
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/__tests__/useVersionOperations.test.tsx
@@ -0,0 +1,50 @@
+import {act, renderHook} from '@testing-library/react'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+
+import {createTestProvider} from '../../../../../test/testUtils/TestProvider'
+import {useReleaseOperationsMockReturn} from '../../store/__tests__/__mocks/useReleaseOperations.mock'
+import {useVersionOperations} from '../useVersionOperations'
+import {usePerspectiveMockReturn} from './__mocks__/usePerspective.mock'
+
+vi.mock('../../store/useReleaseOperations', () => ({
+ useReleaseOperations: vi.fn(() => useReleaseOperationsMockReturn),
+}))
+
+vi.mock('../usePerspective', () => ({
+ usePerspective: vi.fn(() => usePerspectiveMockReturn),
+}))
+
+describe('useVersionOperations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should create a version successfully', async () => {
+ const wrapper = await createTestProvider()
+ const {result} = renderHook(() => useVersionOperations(), {wrapper})
+
+ await act(async () => {
+ await result.current.createVersion('releaseId', 'documentId')
+ })
+
+ expect(useReleaseOperationsMockReturn.createVersion).toHaveBeenCalledWith(
+ 'releaseId',
+ 'documentId',
+ )
+ expect(usePerspectiveMockReturn.setPerspectiveFromReleaseId).toHaveBeenCalledWith('releaseId')
+ })
+
+ it('should discard a version successfully', async () => {
+ const wrapper = await createTestProvider()
+ const {result} = renderHook(() => useVersionOperations(), {wrapper})
+
+ await act(async () => {
+ await result.current.discardVersion('releaseId', 'documentId')
+ })
+
+ expect(useReleaseOperationsMockReturn.discardVersion).toHaveBeenCalledWith(
+ 'releaseId',
+ 'documentId',
+ )
+ })
+})
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts
new file mode 100644
index 00000000000..79acfd79365
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts
@@ -0,0 +1,232 @@
+import {describe, expect, it} from 'vitest'
+
+import {RELEASE_DOCUMENT_TYPE} from '../../store/constants'
+import {type ReleaseDocument} from '../../store/types'
+import {createReleaseId} from '../../util/createReleaseId'
+import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId'
+import {getReleasesPerspective, sortReleases} from '../utils'
+
+function createReleaseMock(
+ value: Partial<
+ Omit & {
+ metadata: Partial
+ }
+ >,
+): ReleaseDocument {
+ const id = value._id || createReleaseId()
+ const name = getBundleIdFromReleaseDocumentId(id)
+ return {
+ _id: id,
+ _type: RELEASE_DOCUMENT_TYPE,
+ _createdAt: new Date().toISOString(),
+ _updatedAt: new Date().toISOString(),
+ name: getBundleIdFromReleaseDocumentId(id),
+ createdBy: 'snty1',
+ state: 'active',
+ ...value,
+ metadata: {
+ title: `Release ${name}`,
+ releaseType: 'asap',
+ ...value.metadata,
+ },
+ }
+}
+describe('sortReleases()', () => {
+ it('should return the asap releases ordered by createdAt', () => {
+ const releases: ReleaseDocument[] = [
+ createReleaseMock({
+ _id: '_.releases.asap1',
+ _createdAt: '2024-10-24T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.asap2',
+ _createdAt: '2024-10-25T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ ]
+ const sorted = sortReleases(releases)
+ const expectedOrder = ['asap2', 'asap1']
+ expectedOrder.forEach((expectedName, idx) => {
+ expect(sorted[idx].name).toBe(expectedName)
+ })
+ })
+ it('should return the scheduled releases ordered by intendedPublishAt or publishAt', () => {
+ const releases: ReleaseDocument[] = [
+ createReleaseMock({
+ _id: '_.releases.future2',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-11-25T00:00:00Z',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future1',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-11-23T00:00:00Z',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future4',
+ state: 'scheduled',
+ publishAt: '2024-11-31T00:00:00Z',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-10-20T00:00:00Z',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future3',
+ state: 'scheduled',
+ publishAt: '2024-11-26T00:00:00Z',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-11-22T00:00:00Z',
+ },
+ }),
+ ]
+ const sorted = sortReleases(releases)
+ const expectedOrder = ['future4', 'future3', 'future2', 'future1']
+ expectedOrder.forEach((expectedName, idx) => {
+ expect(sorted[idx].name).toBe(expectedName)
+ })
+ })
+ it('should return the undecided releases ordered by createdAt', () => {
+ const releases: ReleaseDocument[] = [
+ createReleaseMock({
+ _id: '_.releases.undecided1',
+ _createdAt: '2024-10-25T00:00:00Z',
+ metadata: {
+ releaseType: 'undecided',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.undecided2',
+ _createdAt: '2024-10-26T00:00:00Z',
+ metadata: {
+ releaseType: 'undecided',
+ },
+ }),
+ ]
+ const sorted = sortReleases(releases)
+ const expectedOrder = ['undecided2', 'undecided1']
+ expectedOrder.forEach((expectedName, idx) => {
+ expect(sorted[idx].name).toBe(expectedName)
+ })
+ })
+ it("should gracefully combine all release types, and sort them by 'undecided', 'scheduled', 'asap'", () => {
+ const releases = [
+ createReleaseMock({
+ _id: '_.releases.asap2',
+ _createdAt: '2024-10-25T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.asap1',
+ _createdAt: '2024-10-24T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.undecided2',
+ _createdAt: '2024-10-26T00:00:00Z',
+ metadata: {
+ releaseType: 'undecided',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future4',
+ state: 'scheduled',
+ publishAt: '2024-11-31T00:00:00Z',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-10-20T00:00:00Z',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future1',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-11-23T00:00:00Z',
+ },
+ }),
+ ]
+ const sorted = sortReleases(releases)
+ const expectedOrder = ['undecided2', 'future4', 'future1', 'asap2', 'asap1']
+ expectedOrder.forEach((expectedName, idx) => {
+ expect(sorted[idx].name).toBe(expectedName)
+ })
+ })
+})
+
+describe('getReleasesPerspective()', () => {
+ const releases = [
+ createReleaseMock({
+ _id: '_.releases.asap2',
+ _createdAt: '2024-10-25T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.asap1',
+ _createdAt: '2024-10-24T00:00:00Z',
+ metadata: {
+ releaseType: 'asap',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.undecided2',
+ _createdAt: '2024-10-26T00:00:00Z',
+ metadata: {
+ releaseType: 'undecided',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future4',
+ state: 'scheduled',
+ publishAt: '2024-11-31T00:00:00Z',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-10-20T00:00:00Z',
+ },
+ }),
+ createReleaseMock({
+ _id: '_.releases.future1',
+ metadata: {
+ releaseType: 'scheduled',
+ intendedPublishAt: '2024-11-23T00:00:00Z',
+ },
+ }),
+ ]
+ // Define your test cases with the expected outcomes
+ const testCases = [
+ {perspective: 'bundle.asap1', excluded: [], expected: ['asap1', 'drafts']},
+ {perspective: 'bundle.asap2', excluded: [], expected: ['asap2', 'asap1', 'drafts']},
+ {
+ perspective: 'bundle.undecided2',
+ excluded: [],
+ expected: ['undecided2', 'future4', 'future1', 'asap2', 'asap1', 'drafts'],
+ },
+ {
+ perspective: 'bundle.undecided2',
+ excluded: ['future1', 'drafts'],
+ expected: ['undecided2', 'future4', 'asap2', 'asap1'],
+ },
+ ]
+ it.each(testCases)(
+ 'should return the correct release stack for %s',
+ ({perspective, excluded, expected}) => {
+ const result = getReleasesPerspective({releases, perspective, excluded})
+ expect(result).toEqual(expected)
+ },
+ )
+})
diff --git a/packages/sanity/src/core/releases/hooks/index.ts b/packages/sanity/src/core/releases/hooks/index.ts
new file mode 100644
index 00000000000..0ed88aee8c3
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useDocumentVersions'
+export * from './usePerspective'
+export * from './useVersionOperations'
diff --git a/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx
new file mode 100644
index 00000000000..3b8f4897bec
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx
@@ -0,0 +1,75 @@
+import {useMemo} from 'react'
+import {useObservable} from 'react-rx'
+import {map, of} from 'rxjs'
+import {catchError} from 'rxjs/operators'
+
+import {useDocumentPreviewStore} from '../../store'
+import {getPublishedId, getVersionFromId} from '../../util/draftUtils'
+import {createSWR} from '../../util/rxSwr'
+import {type ReleaseDocument, useReleases} from '../store'
+import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId'
+
+export interface DocumentPerspectiveProps {
+ documentId: string
+}
+
+export interface DocumentPerspectiveState {
+ data: ReleaseDocument[]
+ error?: unknown
+ loading: boolean
+}
+
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
+/**
+ * Fetches the document versions for a given document
+ * @param props - document Id of the document (might include release id)
+ * @returns - data: document versions, loading, errors
+ * @hidden
+ * @beta
+ */
+export function useDocumentVersions(props: DocumentPerspectiveProps): DocumentPerspectiveState {
+ const {documentId} = props
+
+ const {data: releases} = useReleases()
+ const publishedId = getPublishedId(documentId)
+
+ const documentPreviewStore = useDocumentPreviewStore()
+
+ const observable = useMemo(() => {
+ return documentPreviewStore
+ .unstable_observeDocumentIdSet(`sanity::versionOf("${publishedId}")`)
+ .pipe(
+ swr(`${publishedId}`),
+ map(({value}) => ({
+ documentIds: value.documentIds,
+ loading: false,
+ error: null,
+ })),
+ catchError((error) => {
+ return of({error, documentIds: [] as string[], loading: false})
+ }),
+ )
+ }, [documentPreviewStore, publishedId])
+
+ const result = useObservable(observable, {
+ documentIds: [] as string[],
+ error: null,
+ loading: true,
+ })
+ const filterData = useMemo(
+ () =>
+ result.documentIds.flatMap((docId) => {
+ const matchingBundle = releases?.find(
+ (release) => getVersionFromId(docId) === getBundleIdFromReleaseDocumentId(release._id),
+ )
+ return matchingBundle || []
+ }),
+ [releases, result.documentIds],
+ )
+
+ return useMemo(
+ () => ({data: filterData, loading: result.loading, error: result.error}),
+ [filterData, result],
+ )
+}
diff --git a/packages/sanity/src/core/releases/hooks/usePerspective.tsx b/packages/sanity/src/core/releases/hooks/usePerspective.tsx
new file mode 100644
index 00000000000..146a58f2232
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/usePerspective.tsx
@@ -0,0 +1,173 @@
+import {useCallback, useMemo} from 'react'
+import {useRouter} from 'sanity/router'
+
+import {resolveBundlePerspective} from '../../util/resolvePerspective'
+import {type ReleaseDocument} from '../store/types'
+import {useReleases} from '../store/useReleases'
+import {LATEST} from '../util/const'
+import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId'
+import {isPublishedPerspective} from '../util/util'
+import {getReleasesPerspective} from './utils'
+
+/**
+ * @internal
+ */
+export type CurrentPerspective = ReleaseDocument | 'published' | typeof LATEST
+
+/**
+ * @internal
+ */
+export interface PerspectiveValue {
+ /* The current perspective */
+ perspective: 'published' | `bundle.${string}` | undefined
+
+ /* The excluded perspectives */
+ excludedPerspectives: string[]
+ /* Return the current global release */
+ currentGlobalBundle: CurrentPerspective
+ /* Change the perspective in the studio based on the perspective name */
+ setPerspective: (perspectiveId: string) => void
+ /* change the perspective in the studio based on a release ID */
+ setPerspectiveFromReleaseDocumentId: (releaseDocumentId: string) => void
+ setPerspectiveFromReleaseId: (releaseId: string) => void
+ /* Add/remove excluded perspectives */
+ toggleExcludedPerspective: (perspectiveId: string) => void
+ /* Check if a perspective is excluded */
+ isPerspectiveExcluded: (perspectiveId: string) => boolean
+ /**
+ * The stacked array of releases ids ordered chronologically to represent the state of documents at the given point in time.
+ */
+ bundlesPerspective: string[]
+ /* */
+ currentGlobalBundleId: string
+}
+
+const EMPTY_ARRAY: string[] = []
+/**
+ * TODO: Improve distinction between global and pane perspectives.
+ *
+ * @internal
+ */
+export function usePerspective(): PerspectiveValue {
+ const router = useRouter()
+ const {data: releases} = useReleases()
+ // TODO: Actually validate the perspective value, if it's not a valid perspective, we should fallback to undefined
+ const perspective = router.stickyParams.perspective as
+ | 'published'
+ | `bundle.${string}`
+ | undefined
+
+ const excludedPerspectives = useMemo(
+ () => router.stickyParams.excludedPerspectives?.split(',') || EMPTY_ARRAY,
+ [router.stickyParams.excludedPerspectives],
+ )
+
+ // TODO: Should it be possible to set the perspective within a pane, rather than globally?
+ const setPerspective = useCallback(
+ (releaseId: string | undefined) => {
+ let perspectiveParam = ''
+
+ if (releaseId === 'published') {
+ perspectiveParam = 'published'
+ } else if (releaseId !== 'drafts') {
+ perspectiveParam = `bundle.${releaseId}`
+ }
+
+ router.navigateStickyParams({
+ excludedPerspectives: '',
+ perspective: perspectiveParam,
+ })
+ },
+ [router],
+ )
+
+ const selectedBundle =
+ perspective && releases
+ ? releases.find(
+ (release: ReleaseDocument) =>
+ `bundle.${getBundleIdFromReleaseDocumentId(release._id)}` === perspective,
+ )
+ : LATEST
+
+ // TODO: Improve naming; this may not be global.
+ const currentGlobalBundle: CurrentPerspective = useMemo(
+ () => (perspective === 'published' ? perspective : selectedBundle || LATEST),
+ [perspective, selectedBundle],
+ )
+
+ const setPerspectiveFromReleaseId = useCallback(
+ (releaseId: string) => setPerspective(releaseId),
+ [setPerspective],
+ )
+
+ const setPerspectiveFromReleaseDocumentId = useCallback(
+ (releaseId: string) => setPerspectiveFromReleaseId(getBundleIdFromReleaseDocumentId(releaseId)),
+ [setPerspectiveFromReleaseId],
+ )
+
+ const bundlesPerspective = useMemo(
+ () =>
+ getReleasesPerspective({
+ releases,
+ perspective,
+ excluded: (excludedPerspectives.map(resolveBundlePerspective) as string[]) || [],
+ }),
+ [releases, perspective, excludedPerspectives],
+ )
+
+ const toggleExcludedPerspective = useCallback(
+ (excluded: string) => {
+ if (excluded === LATEST._id) return
+ const existingPerspectives = excludedPerspectives || []
+
+ const excludedPerspectiveId = isPublishedPerspective(excluded)
+ ? 'published'
+ : `bundle.${excluded}`
+
+ const nextExcludedPerspectives = existingPerspectives.includes(excludedPerspectiveId)
+ ? existingPerspectives.filter((id) => id !== excludedPerspectiveId)
+ : [...existingPerspectives, excludedPerspectiveId]
+
+ router.navigateStickyParams({excludedPerspectives: nextExcludedPerspectives.toString()})
+ },
+ [excludedPerspectives, router],
+ )
+
+ const isPerspectiveExcluded = useCallback(
+ (perspectiveId: string) =>
+ Boolean(
+ excludedPerspectives?.includes(
+ isPublishedPerspective(perspectiveId) ? 'published' : `bundle.${perspectiveId}`,
+ ),
+ ),
+ [excludedPerspectives],
+ )
+
+ return useMemo(
+ () => ({
+ perspective,
+ excludedPerspectives,
+ setPerspective,
+ setPerspectiveFromReleaseDocumentId: setPerspectiveFromReleaseDocumentId,
+ setPerspectiveFromReleaseId: setPerspectiveFromReleaseId,
+ toggleExcludedPerspective,
+ currentGlobalBundle,
+ currentGlobalBundleId: isPublishedPerspective(currentGlobalBundle)
+ ? 'published'
+ : currentGlobalBundle._id,
+ bundlesPerspective,
+ isPerspectiveExcluded,
+ }),
+ [
+ perspective,
+ excludedPerspectives,
+ setPerspective,
+ setPerspectiveFromReleaseDocumentId,
+ setPerspectiveFromReleaseId,
+ toggleExcludedPerspective,
+ currentGlobalBundle,
+ bundlesPerspective,
+ isPerspectiveExcluded,
+ ],
+ )
+}
diff --git a/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx
new file mode 100644
index 00000000000..31043d529c4
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx
@@ -0,0 +1,70 @@
+import {useTelemetry} from '@sanity/telemetry/react'
+import {useToast} from '@sanity/ui'
+
+import {Translate, useTranslation} from '../../i18n'
+import {AddedVersion} from '../__telemetry__/releases.telemetry'
+import {useReleaseOperations} from '../store/useReleaseOperations'
+import {getCreateVersionOrigin} from '../util/util'
+import {usePerspective} from './usePerspective'
+
+export interface VersionOperationsValue {
+ createVersion: (releaseId: string, documentId: string) => Promise
+ discardVersion: (releaseId: string, documentId: string) => Promise
+}
+
+/** @internal */
+export function useVersionOperations(): VersionOperationsValue {
+ const telemetry = useTelemetry()
+ const {createVersion, discardVersion} = useReleaseOperations()
+
+ const {setPerspectiveFromReleaseId} = usePerspective()
+ const toast = useToast()
+ const {t} = useTranslation()
+
+ const handleCreateVersion = async (releaseId: string, documentId: string) => {
+ const origin = getCreateVersionOrigin(documentId)
+ try {
+ await createVersion(releaseId, documentId)
+ setPerspectiveFromReleaseId(releaseId)
+ telemetry.log(AddedVersion, {
+ documentOrigin: origin,
+ })
+ } catch (err) {
+ toast.push({
+ closable: true,
+ status: 'error',
+ title: t('release.action.create-version.failure'),
+ description: err.message,
+ })
+ }
+ }
+
+ const handleDiscardVersion = async (releaseId: string, documentId: string) => {
+ try {
+ await discardVersion(releaseId, documentId)
+
+ toast.push({
+ closable: true,
+ status: 'success',
+ description: (
+
+ ),
+ })
+ } catch (err) {
+ toast.push({
+ closable: true,
+ status: 'error',
+ title: t('release.action.discard-version.failure'),
+ description: err.message,
+ })
+ }
+ }
+ return {
+ createVersion: handleCreateVersion,
+ discardVersion: handleDiscardVersion,
+ }
+}
diff --git a/packages/sanity/src/core/releases/hooks/utils.ts b/packages/sanity/src/core/releases/hooks/utils.ts
new file mode 100644
index 00000000000..adf392c2f94
--- /dev/null
+++ b/packages/sanity/src/core/releases/hooks/utils.ts
@@ -0,0 +1,79 @@
+import {DRAFTS_FOLDER} from '../../util/draftUtils'
+import {resolveBundlePerspective} from '../../util/resolvePerspective'
+import {type ReleaseDocument} from '../store/types'
+import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId'
+
+export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[] {
+ // The order should always be:
+ // [undecided (sortByCreatedAt), scheduled(sortBy publishAt || metadata.intendedPublishAt), asap(sortByCreatedAt)]
+ return [...releases].sort((a, b) => {
+ // undecided are always first, then by createdAt descending
+ if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType !== 'undecided') {
+ return -1
+ }
+ if (a.metadata.releaseType !== 'undecided' && b.metadata.releaseType === 'undecided') {
+ return 1
+ }
+ if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType === 'undecided') {
+ // Sort by createdAt
+ return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime()
+ }
+
+ // Scheduled are always at the middle, then by publishAt descending
+ if (a.metadata.releaseType === 'scheduled' && b.metadata.releaseType === 'scheduled') {
+ const aPublishAt = a.publishAt || a.metadata.intendedPublishAt
+ if (!aPublishAt) {
+ return 1
+ }
+ const bPublishAt = b.publishAt || b.metadata.intendedPublishAt
+ if (!bPublishAt) {
+ return -1
+ }
+ return new Date(bPublishAt).getTime() - new Date(aPublishAt).getTime()
+ }
+
+ // ASAP are always last, then by createdAt descending
+ if (a.metadata.releaseType === 'asap' && b.metadata.releaseType !== 'asap') {
+ return 1
+ }
+ if (a.metadata.releaseType !== 'asap' && b.metadata.releaseType === 'asap') {
+ return -1
+ }
+ if (a.metadata.releaseType === 'asap' && b.metadata.releaseType === 'asap') {
+ // Sort by createdAt
+ return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime()
+ }
+
+ return 0
+ })
+}
+
+export function getReleasesPerspective({
+ releases,
+ perspective,
+ excluded,
+}: {
+ releases: ReleaseDocument[]
+ perspective: string | undefined // Includes the bundle. or 'published'
+ excluded: string[]
+}): string[] {
+ if (!perspective?.startsWith('bundle.')) {
+ return []
+ }
+ const perspectiveId = resolveBundlePerspective(perspective)
+ if (!perspectiveId) {
+ return []
+ }
+
+ const sorted = sortReleases(releases).map((release) =>
+ getBundleIdFromReleaseDocumentId(release._id),
+ )
+ const selectedIndex = sorted.indexOf(perspectiveId)
+ if (selectedIndex === -1) {
+ return []
+ }
+ return sorted
+ .slice(selectedIndex)
+ .concat(DRAFTS_FOLDER)
+ .filter((name) => !excluded.includes(name))
+}
diff --git a/packages/sanity/src/core/releases/i18n/index.ts b/packages/sanity/src/core/releases/i18n/index.ts
new file mode 100644
index 00000000000..0580e6b9a42
--- /dev/null
+++ b/packages/sanity/src/core/releases/i18n/index.ts
@@ -0,0 +1,29 @@
+import {type LocaleResourceBundle} from '../../i18n'
+
+/**
+ * The locale namespace for the releases tool
+ *
+ * @public
+ */
+// api extractor take issues with 'as const' for literals
+// eslint-disable-next-line @typescript-eslint/prefer-as-const
+export const releasesLocaleNamespace: 'releases' = 'releases'
+
+/**
+ * The default locale release for the releases tool, which is US English.
+ *
+ * @internal
+ */
+export const releasesUsEnglishLocaleBundle: LocaleResourceBundle = {
+ locale: 'en-US',
+ namespace: releasesLocaleNamespace,
+ resources: () => import('./resources'),
+}
+
+/**
+ * The locale resource keys for the releases tool.
+ *
+ * @alpha
+ * @hidden
+ */
+export type {ReleasesLocaleResourceKeys} from './resources'
diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts
new file mode 100644
index 00000000000..67a3c04a276
--- /dev/null
+++ b/packages/sanity/src/core/releases/i18n/resources.ts
@@ -0,0 +1,241 @@
+/**
+ * Defined locale strings for the releases tool, in US English.
+ *
+ * @internal
+ */
+const releasesLocaleStrings = {
+ /** Action text for adding a document to release */
+ 'action.add-document': 'Add document',
+ /** Action text for archiving a release */
+ 'action.archive': 'Archive release',
+ /** Tooltip for when the archive release action is disabled due to release being scheduled */
+ 'action.archive.tooltip': 'Unschedule this release to archive it',
+ /** Action text for showing the archived releases */
+ 'action.archived': 'Archived',
+ /** Action text for deleting a release */
+ 'action.delete': 'Delete',
+ /** Description for toast when release deletion failed */
+ 'action.delete.failure': 'Failed to delete release',
+ /** Description for toast when release is successfully deleted */
+ 'action.delete.success': '{{title}} release was successfully deleted',
+ /** Action text for editing a release */
+ 'action.edit': 'Edit release',
+ /** Action text for opening a release */
+ 'action.open': 'Open',
+ /** Action text for scheduling a release */
+ 'action.schedule': 'Schedule for publishing...',
+ /** Action text for scheduling a release */
+ 'action.unschedule': 'Unschedule',
+ /** Action text for publishing all documents in a release (and the release itself) */
+ 'action.publish-all-documents': 'Publish all documents',
+ /** Text for the review changes button in release tool */
+ 'action.review': 'Review changes',
+ /** Text for the summary button in release tool */
+ 'actions.summary': 'Summary',
+ /** Label for unarchiving a release */
+ 'action.unarchive': 'Unarchive release',
+ /** Title for the dialog confirming the archive of a release */
+ 'archive-dialog.confirm-archive-title':
+ "Are you sure you want to archive the '{{title}}' release?",
+ /** Description for the dialog confirming the archive of a release with one document */
+ 'archive-dialog.confirm-archive-description_one': 'This will archive 1 document version.',
+ /** Description for the dialog confirming the publish of a release with more than one document */
+ 'archive-dialog.confirm-archive-description_other':
+ 'This will archive {{count}} document versions.',
+ /** Label for the button to proceed with archiving a release */
+ 'archive-dialog.confirm-archive-button': 'Yes, archive now',
+
+ /** Title for changes to published documents */
+ 'changes-published-docs.title': 'Changes to published documents',
+ /** Text for when a release / document was created */
+ 'created': 'Created ',
+
+ /** Text for the releases detail screen when a release was published */
+ 'dashboard.details.published-on': 'Published on {{date}}',
+
+ /** Text for the releases detail screen in the pin release button. */
+ 'dashboard.details.pin-release': 'Pin release',
+
+ /** Activity inspector button text */
+ 'dashboard.details.activity': 'Activity',
+ /** Warning for deleting a release that it will delete one document version */
+ 'delete.warning_one': 'This will also delete one document version.',
+ /** Warning for deleting a release that it will delete multiple document version */
+ 'delete.warning_other': 'This will also delete {{count}} document versions.',
+ /** Header for deleting a release dialog */
+ 'delete-dialog.header': "Are you sure you want to delete the release '{{title}}'?",
+ /** Text for when there's no changes in a release diff */
+ 'diff.no-changes': 'No changes',
+ /** Text for when there's no changes in a release diff */
+ 'diff.list-empty': 'Changes list is empty, see document',
+ /** Description for discarding a version of a document dialog */
+ 'discard-version-dialog.description':
+ "The '{{title}}' version of this document will be permanently deleted.",
+ /** Header for discarding a version of a document dialog */
+ 'discard-version-dialog.header': 'Are you sure you want to discard the document version?',
+ /** Title for dialog for discarding a version of a document */
+ 'discard-version-dialog.title': 'Discard version',
+ /** Label for the count of added documents in to a release */
+ 'document-count.added': '{{count}} added documents',
+ /** Label for the count of added documents in to a release when only 1 document added*/
+ 'document-count.added-singular': '{{count}} added document',
+ /** Label for the count of changed documents in a release */
+ 'document-count.changed': '{{count}} changed documents',
+ /** Label for the count of changed documents in a release when only 1 document changed */
+ 'document-count.changed-singular': '{{count}} changed document',
+ /** Text for when documents of a release are loading */
+ 'document-loading': 'Loading documents',
+ /** Label for when a document in a release has multiple validation warnings */
+ 'document-validation.error': '{{count}} validation errors',
+ /** Label for when a document in a release has a single validation warning */
+ 'document-validation.error-singular': '{{count}} validation error',
+
+ /** Label when a release has been deleted by a different user */
+ 'deleted-release': "The '{{title}}' release has been deleted",
+
+ /** Title text when error during release update */
+ 'failed-edit-title': 'Failed to save changes',
+ /**The text that will be shown in the footer to indicate the time the release was archived */
+ 'footer.status.archived': 'Archived',
+ /**The text that will be shown in the footer to indicate the time the release was created */
+ 'footer.status.created': 'Created',
+ /**The text that will be shown in the footer to indicate the time the release was created */
+ 'footer.status.edited': 'Edited',
+ /**The text that will be shown in the footer to indicate the time the release was published */
+ 'footer.status.published': 'Published',
+
+ /** Label text for the loading state whilst release is being loaded */
+ 'loading-release': 'Loading release',
+
+ /** Label for the release menu */
+ 'menu.label': 'Release menu',
+ /** Tooltip for the release menu */
+ 'menu.tooltip': 'Actions',
+
+ /** Text for when no archived releases are found */
+ 'no-archived-release': 'No archived releases',
+ /** Text for when no releases are found */
+ 'no-releases': 'No Releases',
+ /** Text for when a release is not found */
+ 'not-found': 'Release not found: {{releaseId}}',
+
+ /** Description for the release tool */
+ 'overview.description':
+ 'Releases are collections of document versions which can be managed and published together.',
+ /** Text for the placeholder in the search release input */
+ 'overview.search-releases-placeholder': 'Search releases',
+ /** Title for the release tool */
+ 'overview.title': 'Releases',
+
+ /** Title for the dialog confirming the publish of a release */
+ 'publish-dialog.confirm-publish.title':
+ 'Are you sure you want to publish the release and all document versions?',
+ /** Description for the dialog confirming the publish of a release with one document */
+ 'publish-dialog.confirm-publish-description_one':
+ "The '{{title}}' release and its document will be published.",
+ /** Description for the dialog confirming the publish of a release with multiple documents */
+ 'publish-dialog.confirm-publish-description_other':
+ "The '{{title}}' release and its {{releaseDocumentsLength}} documents will be published.",
+ /** Label for when documents are being validated */
+ 'publish-dialog.validation.loading': 'Validating documents...',
+ /** Label for when documents in release have validation errors */
+ 'publish-dialog.validation.error': 'Some documents have validation errors',
+
+ /** Title o unschedule release dialog */
+ 'schedule-button.tooltip': 'Are you sure you want to unschedule the release?',
+
+ /** Schedule release button tooltip when validation is loading */
+ 'schedule-button-tooltip.validation.loading': 'Validating documents...',
+ /** Schedule release button tooltip when there are validation errors */
+ 'schedule-button-tooltip.validation.error': 'Some documents have validation errors',
+
+ /** Schedule release button tooltip when the release is already scheduled */
+ 'schedule-button-tooltip.already-scheduled': 'This release is already scheduled',
+
+ /** Title for unschedule release dialog */
+ 'schedule-dialog.confirm-title':
+ 'Are you sure you want to schedule the release and all document versions for publishing?',
+ /** Description shown in unschedule relaease dialog */
+ 'schedule-dialog.confirm-description_one':
+ "The '{{title}}' release and its document will be published on the selected date.",
+ /** Description for the dialog confirming the publish of a release with multiple documents */
+ 'schedule-dialog.confirm-description_other':
+ 'The {{title}} release and its {{count}} document versions will be scheduled for publishing.',
+
+ /** Description for the confirm button for scheduling a release */
+ 'schedule-dialog.confirm-button': 'Yes, schedule for publishing',
+
+ /** Label for date picker when scheduling a release */
+ 'schedule-dialog.select-publish-date-label': 'Schedule for publishing on',
+
+ /** Title for unschedule release dialog */
+ 'unschedule-dialog.confirm-title': 'Are you sure you want to unschedule the release?',
+ /** Description shown in unschedule relaease dialog */
+ 'unschedule-dialog.confirm-description':
+ 'The release will no longer be published on the scheduled date',
+
+ /** Description for the review changes button in release tool */
+ 'review.description': 'Add documents to this release to review changes',
+ /** Text for when a document is edited */
+ 'review.edited': 'Edited ',
+
+ /** Placeholder for search of documents in a release */
+ 'search-documents-placeholder': 'Search documents',
+ /** Text for when the release was created */
+ 'summary.created': 'Created ',
+ /** Text for when the release was published */
+ 'summary.published': 'Published ',
+ /** Text for when the release has not published */
+ 'summary.not-published': 'Not published',
+ /** Text for when the release has no documents */
+ 'summary.no-documents': 'No documents',
+ /** Text for when the release is composed of one document */
+ 'summary.document-count_one': '{{count}} document',
+ /** Text for when the release is composed of multiple documents */
+ 'summary.document-count_other': '{{count}} documents',
+
+ /** add action type that will be shown in the table*/
+ 'table-body.action.add': 'Add',
+ /** Change action type that will be shown in the table*/
+ 'table-body.action.change': 'Change',
+
+ /** Header for the document table in the release tool - contributors */
+ 'table-header.contributors': 'Contributors',
+ /** Header for the document table in the release tool - type */
+ 'table-header.type': 'Type',
+ /** Header for the document table in the release tool - release title */
+ 'table-header.title': 'Release',
+ /** Header for the document table in the release tool - action */
+ 'table-header.action': 'Action',
+ /** Header for the document table in the release tool - title */
+ 'table-header.documents': 'Documents',
+ /** Header for the document table in the release tool - edited */
+ 'table-header.edited': 'Edited',
+ /** Header for the document table in the release tool - time */
+ 'table-header.time': 'Time',
+ /** Text for toast when release has been archived */
+ 'toast.archive.success': "The '{{title}}' release was archived.",
+ /** Text for toast when release failed to archive */
+ 'toast.archive.error': "Failed to archive '{{title}}': {{error}}",
+ /** Text for toast when release failed to publish */
+ 'toast.publish.error': "Failed to publish '{{title}}': {{error}}",
+ /** Text for toast when release has been published */
+ 'toast.publish.success': "The '{{title}}' release was published.",
+ /** Text for toast when release failed to schedule */
+ 'toast.schedule.error': "Failed to schedule '{{title}}': {{error}}",
+ /** Text for toast when release has been scheduled */
+ 'toast.schedule.success': "The '{{title}}' release was scheduled.",
+ /** Text for toast when release failed to unschedule */
+ 'toast.unschedule.error': "Failed to unscheduled '{{title}}': {{error}}",
+ /** Text for toast when release has been unschedule */
+ 'toast.unschedule.success': "The '{{title}}' release was unscheduled.",
+
+ 'type-picker.tooltip.scheduled': 'The release is scheduled, unschedule it to change type',
+}
+
+/**
+ * @alpha
+ */
+export type ReleasesLocaleResourceKeys = keyof typeof releasesLocaleStrings
+
+export default releasesLocaleStrings
diff --git a/packages/sanity/src/core/releases/index.ts b/packages/sanity/src/core/releases/index.ts
new file mode 100644
index 00000000000..22de4d47251
--- /dev/null
+++ b/packages/sanity/src/core/releases/index.ts
@@ -0,0 +1,11 @@
+export * from '../store/_legacy'
+export * from '../store/user'
+export * from './__telemetry__/releases.telemetry'
+export * from './components'
+export * from './hooks'
+export * from './store'
+export * from './util/const'
+export * from './util/createReleaseId'
+export * from './util/getBundleIdFromReleaseDocumentId'
+export * from './util/getReleaseTone'
+export * from './util/util'
diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx
new file mode 100644
index 00000000000..90ce706b43f
--- /dev/null
+++ b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx
@@ -0,0 +1,196 @@
+import {AddIcon, ChevronDownIcon} from '@sanity/icons'
+// eslint-disable-next-line no-restricted-imports -- MenuItem requires props, only supported by @sanity/ui
+import {Box, Button, Flex, Menu, MenuDivider, MenuItem, Spinner} from '@sanity/ui'
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {css, styled} from 'styled-components'
+
+import {MenuButton} from '../../../ui-components'
+import {useTranslation} from '../../i18n'
+import {CreateReleaseDialog} from '../components/dialog/CreateReleaseDialog'
+import {usePerspective} from '../hooks/usePerspective'
+import {type ReleaseDocument, type ReleaseType} from '../store/types'
+import {useReleases} from '../store/useReleases'
+import {
+ getRangePosition,
+ GlobalPerspectiveMenuItem,
+ type LayerRange,
+} from './GlobalPerspectiveMenuItem'
+import {ReleaseTypeMenuSection} from './ReleaseTypeMenuSection'
+import {useScrollIndicatorVisibility} from './useScrollIndicatorVisibility'
+
+const StyledMenu = styled(Menu)`
+ min-width: 200px;
+ max-width: 320px;
+`
+
+const StyledBox = styled(Box)`
+ overflow: auto;
+ max-height: 75vh;
+`
+
+const StyledPublishedBox = styled(Box)<{$removePadding: boolean}>(
+ ({$removePadding}) => css`
+ position: sticky;
+ top: 0;
+ background-color: var(--card-bg-color);
+ z-index: 10;
+ padding-bottom: ${$removePadding ? '4px' : '16px'};
+ `,
+)
+
+const orderedReleaseTypes: ReleaseType[] = ['asap', 'scheduled', 'undecided']
+
+const ASAP_RANGE_OFFSET = 2
+
+export function GlobalPerspectiveMenu(): JSX.Element {
+ const {loading, data: releases} = useReleases()
+ const {currentGlobalBundleId} = usePerspective()
+ const [createBundleDialogOpen, setCreateBundleDialogOpen] = useState(false)
+ const styledMenuRef = useRef(null)
+
+ const {isRangeVisible, onScroll, resetRangeVisibility, setScrollContainer, scrollElementRef} =
+ useScrollIndicatorVisibility()
+
+ const {t} = useTranslation()
+
+ /* create new release */
+ const handleCreateBundleClick = useCallback(() => {
+ setCreateBundleDialogOpen(true)
+ }, [])
+
+ const handleClose = useCallback(() => {
+ setCreateBundleDialogOpen(false)
+ }, [])
+
+ const sortedReleaseTypeReleases = useMemo(
+ () =>
+ orderedReleaseTypes.reduce>(
+ (ReleaseTypeReleases, releaseType) => ({
+ ...ReleaseTypeReleases,
+ [releaseType]: releases.filter(({metadata}) => metadata.releaseType === releaseType),
+ }),
+ {} as Record,
+ ),
+ [releases],
+ )
+
+ const range: LayerRange = useMemo(() => {
+ let lastIndex = 0
+
+ const {asap, scheduled} = sortedReleaseTypeReleases
+ const countAsapReleases = asap.length
+ const countScheduledReleases = scheduled.length
+
+ const offsets = {
+ asap: ASAP_RANGE_OFFSET,
+ scheduled: ASAP_RANGE_OFFSET + countAsapReleases,
+ undecided: ASAP_RANGE_OFFSET + countAsapReleases + countScheduledReleases,
+ }
+
+ const adjustIndexForReleaseType = (type: ReleaseType) => {
+ const groupSubsetReleases = sortedReleaseTypeReleases[type]
+ const offset = offsets[type]
+
+ groupSubsetReleases.forEach(({_id}, groupReleaseIndex) => {
+ const index = offset + groupReleaseIndex
+
+ if (_id === currentGlobalBundleId) {
+ lastIndex = index
+ }
+ })
+ }
+
+ orderedReleaseTypes.forEach(adjustIndexForReleaseType)
+
+ return {
+ lastIndex,
+ offsets,
+ }
+ }, [currentGlobalBundleId, sortedReleaseTypeReleases])
+
+ const releasesList = useMemo(() => {
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ <>
+ {orderedReleaseTypes.map((releaseType) => (
+
+ ))}
+ >
+
+
+
+
+ )
+ }, [
+ handleCreateBundleClick,
+ isRangeVisible,
+ loading,
+ onScroll,
+ range,
+ releases.length,
+ scrollElementRef,
+ setScrollContainer,
+ sortedReleaseTypeReleases,
+ t,
+ ])
+
+ return (
+ <>
+
+ }
+ id="releases-menu"
+ onClose={resetRangeVisibility}
+ menu={
+
+ {releasesList}
+
+ }
+ popover={{
+ constrainSize: true,
+ fallbackPlacements: ['bottom-end'],
+ placement: 'bottom-end',
+ portal: true,
+ tone: 'default',
+ zOffset: 3000,
+ }}
+ />
+ {createBundleDialogOpen && (
+
+ )}
+ >
+ )
+}
diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx
new file mode 100644
index 00000000000..58f3b8d92be
--- /dev/null
+++ b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx
@@ -0,0 +1,211 @@
+import {DotIcon, EyeClosedIcon, EyeOpenIcon, LockIcon} from '@sanity/icons'
+// eslint-disable-next-line no-restricted-imports -- custom use for MenuItem & Button not supported by ui-components
+import {Box, Button, Flex, MenuItem, Stack, Text} from '@sanity/ui'
+import {type CSSProperties, forwardRef, type MouseEvent, useCallback, useMemo} from 'react'
+import {css, styled} from 'styled-components'
+
+import {Tooltip} from '../../../ui-components/tooltip'
+import {useTranslation} from '../../i18n/hooks/useTranslation'
+import {formatRelativeLocale} from '../../util/formatRelativeLocale'
+import {ReleaseAvatar} from '../components/ReleaseAvatar'
+import {usePerspective} from '../hooks/usePerspective'
+import {type ReleaseDocument} from '../store/types'
+import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId'
+import {getReleaseTone} from '../util/getReleaseTone'
+import {
+ getPublishDateFromRelease,
+ isPublishedPerspective,
+ isReleaseScheduledOrScheduling,
+} from '../util/util'
+import {GlobalPerspectiveMenuItemIndicator} from './PerspectiveLayerIndicator'
+
+export interface LayerRange {
+ lastIndex: number
+ offsets: {
+ asap: number
+ scheduled: number
+ undecided: number
+ }
+}
+
+const ToggleLayerButton = styled(Button)<{$visible: boolean}>(
+ ({$visible}) => css`
+ --card-fg-color: inherit;
+ --card-icon-color: inherit;
+
+ background-color: inherit;
+ opacity: ${$visible ? 0 : 1};
+
+ @media (hover: hover) {
+ &:not([data-disabled='true']):hover {
+ --card-fg-color: inherit;
+ --card-icon-color: inherit;
+ }
+ }
+
+ [data-ui='MenuItem']:hover & {
+ opacity: 1;
+ }
+ `,
+)
+
+const ExcludedLayerDot = () => (
+
+
+
+
+
+)
+
+type rangePosition = 'first' | 'within' | 'last' | undefined
+
+export function getRangePosition(range: LayerRange, index: number): rangePosition {
+ const {lastIndex} = range
+
+ if (lastIndex === 0) return undefined
+ if (index === 0) return 'first'
+ if (index === lastIndex) return 'last'
+ if (index > 0 && index < lastIndex) return 'within'
+
+ return undefined
+}
+
+export const GlobalPerspectiveMenuItem = forwardRef<
+ HTMLDivElement,
+ {
+ release: ReleaseDocument | 'published'
+ rangePosition: rangePosition
+ }
+>((props, ref) => {
+ const {release, rangePosition} = props
+ const {
+ currentGlobalBundleId,
+ setPerspectiveFromReleaseDocumentId,
+ setPerspective,
+ toggleExcludedPerspective,
+ isPerspectiveExcluded,
+ } = usePerspective()
+ const isReleasePublishedPerspective = isPublishedPerspective(release)
+ const isUnnamedRelease = !isReleasePublishedPerspective && !release.metadata.title
+ const releaseId = isReleasePublishedPerspective ? 'published' : release._id
+ const active = releaseId === currentGlobalBundleId
+ const first = rangePosition === 'first'
+ const within = rangePosition === 'within'
+ const last = rangePosition === 'last'
+ const inRange = first || within || last
+
+ const releasePerspectiveId = isReleasePublishedPerspective
+ ? releaseId
+ : getBundleIdFromReleaseDocumentId(releaseId)
+ const isReleasePerspectiveExcluded = isPerspectiveExcluded(releasePerspectiveId)
+
+ const {t} = useTranslation()
+
+ const displayTitle = useMemo(() => {
+ if (isUnnamedRelease) {
+ return t('release.placeholder-untitled-release')
+ }
+
+ return isReleasePublishedPerspective ? t('release.navbar.published') : release.metadata?.title
+ }, [isReleasePublishedPerspective, isUnnamedRelease, release, t])
+
+ const handleToggleReleaseVisibility = useCallback(
+ (event: MouseEvent) => {
+ event.stopPropagation()
+ toggleExcludedPerspective(releasePerspectiveId)
+ },
+ [toggleExcludedPerspective, releasePerspectiveId],
+ )
+
+ const handleOnReleaseClick = useCallback(
+ () =>
+ isReleasePublishedPerspective
+ ? setPerspective(releaseId)
+ : setPerspectiveFromReleaseDocumentId(releaseId),
+ [releaseId, isReleasePublishedPerspective, setPerspective, setPerspectiveFromReleaseDocumentId],
+ )
+
+ const canReleaseBeExcluded =
+ !isPublishedPerspective(release) && !isReleaseScheduledOrScheduling(release) && inRange && !last
+
+ return (
+
+
+
+ )
+})
+
+GlobalPerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem'
diff --git a/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx
new file mode 100644
index 00000000000..f195d7b6ea3
--- /dev/null
+++ b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx
@@ -0,0 +1,116 @@
+import {Box} from '@sanity/ui'
+import {css, styled} from 'styled-components'
+
+const INDICATOR_LEFT_OFFSET = 18
+const INDICATOR_WIDTH = 5
+const INDICATOR_COLOR_VAR_NAME = '--card-border-color'
+const INDICATOR_BOTTOM_OFFSET = 4
+
+export const GlobalPerspectiveMenuItemIndicator = styled.div<{
+ $inRange: boolean
+ $last: boolean
+ $first: boolean
+ $isPublished: boolean
+}>(
+ ({$inRange, $last, $first, $isPublished}) => css`
+ position: relative;
+
+ --indicator-left: ${INDICATOR_LEFT_OFFSET}px;
+ --indicator-width: ${INDICATOR_WIDTH}px;
+ --indicator-color: var(${INDICATOR_COLOR_VAR_NAME});
+ --indicator-bottom: ${INDICATOR_BOTTOM_OFFSET}px;
+
+ --indicator-in-range-height: 16.5px;
+
+ ${$inRange &&
+ !$last &&
+ css`
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ left: var(--indicator-left);
+ bottom: -var(--indicator-bottom);
+ width: var(--indicator-width);
+ height: ${$isPublished
+ ? 'calc(var(--indicator-bottom) + 12px)'
+ : 'var(--indicator-bottom)'};
+ background-color: var(--indicator-color);
+ }
+ `}
+
+ ${$inRange &&
+ css`
+ > [data-ui='MenuItem'] {
+ position: relative;
+
+ &:before,
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ left: var(--indicator-left);
+ width: var(--indicator-width);
+ background-color: var(--indicator-color);
+ }
+
+ &:before {
+ top: 0;
+ height: var(--indicator-in-range-height);
+ }
+
+ &:after {
+ top: var(--indicator-in-range-height);
+ bottom: 0;
+ }
+ }
+ `}
+
+ ${$first &&
+ css`
+ > [data-ui='MenuItem']:after {
+ margin-top: -3px;
+ border-top-left-radius: ${INDICATOR_WIDTH}px;
+ border-top-right-radius: ${INDICATOR_WIDTH}px;
+ }
+ > [data-ui='MenuItem']:before {
+ display: none;
+ }
+ `}
+
+ ${$last &&
+ css`
+ > [data-ui='MenuItem']:before {
+ // dot diameter (5px) - 1.6px stroke divided by 2
+ padding-bottom: 1.7px;
+ border-bottom-left-radius: ${INDICATOR_WIDTH}px;
+ border-bottom-right-radius: ${INDICATOR_WIDTH}px;
+ }
+ > [data-ui='MenuItem']:after {
+ display: none;
+ }
+ `}
+ `,
+)
+
+export const GlobalPerspectiveMenuLabelIndicator = styled(Box)<{$withinRange: boolean}>(
+ ({$withinRange}) => css`
+ position: relative;
+ // 4px padding + 33px release indicator width + 4px gap
+ padding-left: 41px;
+
+ ${$withinRange &&
+ css`
+ &:before {
+ content: '';
+ display: block;
+ position: absolute;
+ left: ${INDICATOR_LEFT_OFFSET}px;
+ top: 0;
+ bottom: -${INDICATOR_BOTTOM_OFFSET}px;
+ width: ${INDICATOR_WIDTH}px;
+ background-color: var(${INDICATOR_COLOR_VAR_NAME});
+ }
+ `}
+ `,
+)
diff --git a/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx
new file mode 100644
index 00000000000..8ca16282292
--- /dev/null
+++ b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx
@@ -0,0 +1,72 @@
+import {Flex, Label} from '@sanity/ui'
+import {useCallback} from 'react'
+
+import {useTranslation} from '../../i18n/hooks/useTranslation'
+import {usePerspective} from '../hooks/usePerspective'
+import {type ReleaseDocument, type ReleaseType} from '../store/types'
+import {
+ getRangePosition,
+ GlobalPerspectiveMenuItem,
+ type LayerRange,
+} from './GlobalPerspectiveMenuItem'
+import {GlobalPerspectiveMenuLabelIndicator} from './PerspectiveLayerIndicator'
+import {type ScrollElement} from './useScrollIndicatorVisibility'
+
+const RELEASE_TYPE_LABELS: Record = {
+ asap: 'release.type.asap',
+ scheduled: 'release.type.scheduled',
+ undecided: 'release.type.undecided',
+}
+
+export function ReleaseTypeMenuSection({
+ releaseType,
+ releases,
+ range,
+ currentGlobalBundleMenuItemRef,
+}: {
+ releaseType: ReleaseType
+ releases: ReleaseDocument[]
+ range: LayerRange
+ currentGlobalBundleMenuItemRef: React.RefObject
+}): JSX.Element | null {
+ const {t} = useTranslation()
+ const {currentGlobalBundleId} = usePerspective()
+
+ const getMenuItemRef = useCallback(
+ (releaseId: string) =>
+ releaseId === currentGlobalBundleId
+ ? (currentGlobalBundleMenuItemRef as React.RefObject)
+ : undefined,
+ [currentGlobalBundleId, currentGlobalBundleMenuItemRef],
+ )
+
+ if (releases.length === 0) return null
+
+ const {lastIndex, offsets} = range
+ const releaseTypeOffset = offsets[releaseType]
+
+ return (
+ <>
+ 0 && lastIndex >= releaseTypeOffset}
+ paddingRight={2}
+ paddingTop={releaseType === 'asap' ? 1 : 4}
+ paddingBottom={2}
+ >
+
+
+
+ {releases.map((release, index) => (
+
+ ))}
+
+ >
+ )
+}
diff --git a/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx
new file mode 100644
index 00000000000..be8cd8ca344
--- /dev/null
+++ b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx
@@ -0,0 +1,142 @@
+import {CalendarIcon, CloseIcon} from '@sanity/icons'
+// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button
+import {Box, Button, Card, Flex, Stack, Text} from '@sanity/ui'
+import {AnimatePresence, motion} from 'framer-motion'
+import {type PropsWithChildren, useCallback, useMemo} from 'react'
+import {useTranslation} from 'react-i18next'
+import {IntentLink, useRouterState} from 'sanity/router'
+
+import {Tooltip} from '../../../ui-components'
+import {ToolLink} from '../../studio'
+import {ReleaseAvatar} from '../components/ReleaseAvatar'
+import {usePerspective} from '../hooks/usePerspective'
+import {RELEASES_INTENT, RELEASES_TOOL_NAME} from '../plugin'
+import {LATEST} from '../util/const'
+import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId'
+import {getReleaseTone} from '../util/getReleaseTone'
+import {isDraftPerspective, isPublishedPerspective} from '../util/util'
+import {GlobalPerspectiveMenu} from './GlobalPerspectiveMenu'
+
+const AnimatedMotionDiv = ({children, ...props}: PropsWithChildren) => (
+
+ {children}
+
+)
+
+export function ReleasesNav(): JSX.Element {
+ const activeToolName = useRouterState(
+ useCallback(
+ (routerState) => (typeof routerState.tool === 'string' ? routerState.tool : undefined),
+ [],
+ ),
+ )
+
+ const {currentGlobalBundle, setPerspective} = usePerspective()
+ const {t} = useTranslation()
+
+ const handleClearPerspective = () => setPerspective(LATEST._id)
+
+ const releasesToolLink = useMemo(
+ () => (
+
+
+
+ ),
+ [activeToolName, t],
+ )
+
+ const currentGlobalPerspectiveLabel = useMemo(() => {
+ if (!currentGlobalBundle || isDraftPerspective(currentGlobalBundle)) return null
+
+ let displayTitle
+ if (isPublishedPerspective(currentGlobalBundle)) {
+ displayTitle = t('release.chip.published')
+ } else {
+ displayTitle =
+ currentGlobalBundle.metadata?.title || t('release.placeholder-untitled-release')
+ }
+
+ const visibleLabelChildren = () => {
+ const labelContent = (
+
+
+
+
+
+
+ {displayTitle}
+
+
+
+ )
+
+ if (isPublishedPerspective(currentGlobalBundle)) {
+ return {labelContent}
+ }
+
+ const releasesIntentLink = ({children, ...intentProps}: PropsWithChildren) => (
+
+ {children}
+
+ )
+
+ return (
+
+ )
+ }
+
+ 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={
+
+ }
+ 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 (
+
+ )
+ }, [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 (
+ <>
+