diff --git a/packages/@sanity/vision/package.json b/packages/@sanity/vision/package.json
index eca179c6be2..6a5f9afaead 100644
--- a/packages/@sanity/vision/package.json
+++ b/packages/@sanity/vision/package.json
@@ -70,7 +70,8 @@
"json5": "^2.2.3",
"lodash": "^4.17.21",
"quick-lru": "^5.1.1",
- "react-compiler-runtime": "19.0.0-beta-55955c9-20241229"
+ "react-compiler-runtime": "19.0.0-beta-55955c9-20241229",
+ "react-fast-compare": "^3.2.2"
},
"devDependencies": {
"@repo/package.config": "workspace:*",
diff --git a/packages/@sanity/vision/src/SanityVision.tsx b/packages/@sanity/vision/src/SanityVision.tsx
index 545a01bc71f..d78399ea529 100644
--- a/packages/@sanity/vision/src/SanityVision.tsx
+++ b/packages/@sanity/vision/src/SanityVision.tsx
@@ -1,4 +1,4 @@
-import {type Tool, useClient} from 'sanity'
+import {type Tool, useClient, usePerspective} from 'sanity'
import {DEFAULT_API_VERSION} from './apiVersions'
import {VisionContainer} from './containers/VisionContainer'
@@ -11,6 +11,7 @@ interface SanityVisionProps {
function SanityVision(props: SanityVisionProps) {
const client = useClient({apiVersion: '1'})
+ const perspective = usePerspective()
const config: VisionConfig = {
defaultApiVersion: DEFAULT_API_VERSION,
...props.tool.options,
@@ -18,7 +19,7 @@ function SanityVision(props: SanityVisionProps) {
return (
-
+
)
}
diff --git a/packages/@sanity/vision/src/components/VisionGui.tsx b/packages/@sanity/vision/src/components/VisionGui.tsx
index 7db5d1bf068..3cb39229254 100644
--- a/packages/@sanity/vision/src/components/VisionGui.tsx
+++ b/packages/@sanity/vision/src/components/VisionGui.tsx
@@ -1,6 +1,11 @@
/* eslint-disable complexity */
import {SplitPane} from '@rexxars/react-split-pane'
-import {type ListenEvent, type MutationEvent, type SanityClient} from '@sanity/client'
+import {
+ type ClientPerspective,
+ type ListenEvent,
+ type MutationEvent,
+ type SanityClient,
+} from '@sanity/client'
import {CopyIcon, ErrorOutlineIcon, PlayIcon, StopIcon} from '@sanity/icons'
import {
Box,
@@ -19,14 +24,23 @@ import {
} from '@sanity/ui'
import {isHotkey} from 'is-hotkey-esm'
import {debounce} from 'lodash'
-import {type ChangeEvent, createRef, PureComponent, type RefObject} from 'react'
-import {type TFunction, Translate} from 'sanity'
+import {
+ type ChangeEvent,
+ type ComponentType,
+ createRef,
+ PureComponent,
+ type RefObject,
+ useMemo,
+} from 'react'
+import isEqual from 'react-fast-compare'
+import {type PerspectiveValue, type TFunction, Translate} from 'sanity'
import {API_VERSIONS, DEFAULT_API_VERSION} from '../apiVersions'
import {VisionCodeMirror} from '../codemirror/VisionCodeMirror'
import {
DEFAULT_PERSPECTIVE,
isSupportedPerspective,
+ isVirtualPerspective,
SUPPORTED_PERSPECTIVES,
type SupportedPerspective,
} from '../perspectives'
@@ -192,6 +206,14 @@ export class VisionGui extends PureComponent {
perspective = DEFAULT_PERSPECTIVE
}
+ if (perspective == 'pinnedRelease' && !hasPinnedPerspective(this.props.pinnedPerspective)) {
+ perspective = DEFAULT_PERSPECTIVE
+ }
+
+ if (perspective !== 'pinnedRelease' && hasPinnedPerspective(this.props.pinnedPerspective)) {
+ perspective = 'pinnedRelease'
+ }
+
if (typeof lastQuery !== 'string') {
lastQuery = ''
}
@@ -209,7 +231,10 @@ export class VisionGui extends PureComponent {
this._client = props.client.withConfig({
apiVersion: customApiVersion || apiVersion,
dataset,
- perspective: perspective,
+ perspective: getActivePerspective({
+ visionPerspective: perspective,
+ pinnedPerspective: this.props.pinnedPerspective,
+ }),
allowReconfigure: true,
})
@@ -264,6 +289,7 @@ export class VisionGui extends PureComponent {
this.handleKeyDown = this.handleKeyDown.bind(this)
this.handleResize = this.handleResize.bind(this)
this.handleOnPasteCapture = this.handleOnPasteCapture.bind(this)
+ this.setPerspective = this.setPerspective.bind(this)
}
componentDidMount() {
@@ -280,6 +306,30 @@ export class VisionGui extends PureComponent {
this.cancelResizeListener()
}
+ componentDidUpdate(prevProps: Readonly): void {
+ if (hasPinnedPerspectiveChanged(prevProps.pinnedPerspective, this.props.pinnedPerspective)) {
+ if (
+ this.state.perspective !== 'pinnedRelease' &&
+ hasPinnedPerspective(this.props.pinnedPerspective)
+ ) {
+ this.setPerspective('pinnedRelease')
+ return
+ }
+
+ if (
+ this.state.perspective === 'pinnedRelease' &&
+ !hasPinnedPerspective(this.props.pinnedPerspective)
+ ) {
+ this.setPerspective('raw')
+ return
+ }
+
+ if (this.state.perspective === 'pinnedRelease') {
+ this.setPerspective('pinnedRelease')
+ }
+ }
+ }
+
handleResizeListen() {
if (!this._visionRoot.current) {
return
@@ -338,11 +388,17 @@ export class VisionGui extends PureComponent {
}
}
- const perspective = isSupportedPerspective(parts.options.perspective)
- ? parts.options.perspective
- : undefined
-
- if (perspective && !isSupportedPerspective(perspective)) {
+ const perspective =
+ isSupportedPerspective(parts.options.perspective) &&
+ !isVirtualPerspective(parts.options.perspective)
+ ? parts.options.perspective
+ : undefined
+
+ if (
+ perspective &&
+ (!isSupportedPerspective(parts.options.perspective) ||
+ isVirtualPerspective(parts.options.perspective))
+ ) {
this.props.toast.push({
closable: true,
id: 'vision-paste-unsupported-perspective',
@@ -378,7 +434,10 @@ export class VisionGui extends PureComponent {
this._client.config({
dataset: this.state.dataset,
apiVersion: customApiVersion || apiVersion,
- perspective: this.state.perspective,
+ perspective: getActivePerspective({
+ visionPerspective: this.state.perspective,
+ pinnedPerspective: this.props.pinnedPerspective,
+ }),
})
this.handleQueryExecution()
this.props.toast.push({
@@ -399,7 +458,6 @@ export class VisionGui extends PureComponent {
if (!this._querySubscription) {
return
}
-
this._querySubscription.unsubscribe()
this._querySubscription = undefined
}
@@ -466,6 +524,10 @@ export class VisionGui extends PureComponent {
handleChangePerspective(evt: ChangeEvent) {
const perspective = evt.target.value
+ this.setPerspective(perspective)
+ }
+
+ setPerspective(perspective: string): void {
if (!isSupportedPerspective(perspective)) {
return
}
@@ -473,7 +535,10 @@ export class VisionGui extends PureComponent {
this.setState({perspective}, () => {
this._localStorage.set('perspective', this.state.perspective)
this._client.config({
- perspective: this.state.perspective,
+ perspective: getActivePerspective({
+ visionPerspective: this.state.perspective,
+ pinnedPerspective: this.props.pinnedPerspective,
+ }),
})
this.handleQueryExecution()
})
@@ -598,9 +663,13 @@ export class VisionGui extends PureComponent {
this.ensureSelectedApiVersion()
- const urlQueryOpts: Record = {}
+ const urlQueryOpts: Record = {}
if (this.state.perspective !== 'raw') {
- urlQueryOpts.perspective = this.state.perspective
+ urlQueryOpts.perspective =
+ getActivePerspective({
+ visionPerspective: this.state.perspective,
+ pinnedPerspective: this.props.pinnedPerspective,
+ }) ?? []
}
const url = this._client.getUrl(
@@ -613,20 +682,22 @@ export class VisionGui extends PureComponent {
this._querySubscription = this._client.observable
.fetch(query, params, {filterResponse: false, tag: 'vision'})
.subscribe({
- next: (res) =>
+ next: (res) => {
this.setState({
queryTime: res.ms,
e2eTime: Date.now() - queryStart,
queryResult: res.result,
queryInProgress: false,
error: undefined,
- }),
- error: (error) =>
+ })
+ },
+ error: (error) => {
this.setState({
error,
query,
queryInProgress: false,
- }),
+ })
+ },
})
return true
@@ -670,7 +741,7 @@ export class VisionGui extends PureComponent {
}
render() {
- const {datasets, t} = this.props
+ const {datasets, t, pinnedPerspective} = this.props
const {
apiVersion,
customApiVersion,
@@ -778,9 +849,21 @@ export class VisionGui extends PureComponent {
@@ -1027,3 +1110,62 @@ export class VisionGui extends PureComponent {
)
}
}
+
+function getActivePerspective({
+ visionPerspective,
+ pinnedPerspective,
+}: {
+ visionPerspective: ClientPerspective | SupportedPerspective
+ pinnedPerspective: PerspectiveValue
+}): ClientPerspective | undefined {
+ if (visionPerspective !== 'pinnedRelease') {
+ return visionPerspective
+ }
+
+ if (pinnedPerspective.perspectiveStack.length !== 0) {
+ return pinnedPerspective.perspectiveStack
+ }
+
+ if (typeof pinnedPerspective.selectedPerspectiveName !== 'undefined') {
+ return [pinnedPerspective.selectedPerspectiveName]
+ }
+
+ return undefined
+}
+
+const PinnedReleasePerspectiveOption: ComponentType<{
+ pinnedPerspective: PerspectiveValue
+ t: TFunction
+}> = ({pinnedPerspective, t}) => {
+ const name =
+ typeof pinnedPerspective.selectedPerspective === 'object'
+ ? pinnedPerspective.selectedPerspective.metadata.title
+ : pinnedPerspective.selectedPerspectiveName
+
+ const label = hasPinnedPerspective(pinnedPerspective)
+ ? `(${t('settings.perspectives.pinned-release-label')})`
+ : t('settings.perspectives.pinned-release-label')
+
+ const text = useMemo(
+ () => [name, label].filter((value) => typeof value !== 'undefined').join(' '),
+ [label, name],
+ )
+
+ return (
+
+ )
+}
+
+function hasPinnedPerspective({selectedPerspectiveName}: PerspectiveValue): boolean {
+ return typeof selectedPerspectiveName !== 'undefined'
+}
+
+function hasPinnedPerspectiveChanged(previous: PerspectiveValue, next: PerspectiveValue): boolean {
+ const hasPerspectiveStackChanged = !isEqual(previous.perspectiveStack, next.perspectiveStack)
+
+ return (
+ previous.selectedPerspectiveName !== next.selectedPerspectiveName || hasPerspectiveStackChanged
+ )
+}
diff --git a/packages/@sanity/vision/src/i18n/resources.ts b/packages/@sanity/vision/src/i18n/resources.ts
index e28df6fee7f..e1281aea726 100644
--- a/packages/@sanity/vision/src/i18n/resources.ts
+++ b/packages/@sanity/vision/src/i18n/resources.ts
@@ -74,6 +74,8 @@ const visionLocaleStrings = defineLocalesResources('vision', {
/** Description for popover that explains what "Perspectives" are */
'settings.perspectives.description':
'Perspectives allow your query to run against different "views" of the content in your dataset',
+ /** Label for the pinned release perspective */
+ 'settings.perspectives.pinned-release-label': 'pinned release',
/** Title for popover that explains what "Perspectives" are */
'settings.perspectives.title': 'Perspectives',
} as const)
diff --git a/packages/@sanity/vision/src/perspectives.ts b/packages/@sanity/vision/src/perspectives.ts
index 7993a2f49b0..472422511a4 100644
--- a/packages/@sanity/vision/src/perspectives.ts
+++ b/packages/@sanity/vision/src/perspectives.ts
@@ -1,15 +1,36 @@
-import {type ClientPerspective} from '@sanity/client'
-
-export type SupportedPerspective = 'raw' | 'previewDrafts' | 'published' | 'drafts'
-
export const SUPPORTED_PERSPECTIVES = [
+ 'pinnedRelease',
'raw',
'previewDrafts',
'published',
'drafts',
-] satisfies ClientPerspective[]
-export const DEFAULT_PERSPECTIVE = SUPPORTED_PERSPECTIVES[0]
+] as const
+
+export type SupportedPerspective = (typeof SUPPORTED_PERSPECTIVES)[number]
+
+/**
+ * Virtual perspectives are recognised by Vision, but do not concretely reflect the names of real
+ * perspectives. Virtual perspectives are transformed into real perspectives before being used to
+ * interact with data.
+ *
+ * For example, the `pinnedRelease` virtual perspective is transformed to the real perspective
+ * currently pinned in Studio.
+ */
+export const VIRTUAL_PERSPECTIVES = ['pinnedRelease'] as const
+
+export type VirtualPerspective = (typeof VIRTUAL_PERSPECTIVES)[number]
+
+export const DEFAULT_PERSPECTIVE: SupportedPerspective = 'raw'
export function isSupportedPerspective(p: string): p is SupportedPerspective {
return SUPPORTED_PERSPECTIVES.includes(p as SupportedPerspective)
}
+
+export function isVirtualPerspective(
+ maybeVirtualPerspective: unknown,
+): maybeVirtualPerspective is VirtualPerspective {
+ return (
+ typeof maybeVirtualPerspective === 'string' &&
+ VIRTUAL_PERSPECTIVES.includes(maybeVirtualPerspective as VirtualPerspective)
+ )
+}
diff --git a/packages/@sanity/vision/src/types.ts b/packages/@sanity/vision/src/types.ts
index 5484086acdb..5f10a530818 100644
--- a/packages/@sanity/vision/src/types.ts
+++ b/packages/@sanity/vision/src/types.ts
@@ -1,9 +1,11 @@
import {type SanityClient} from '@sanity/client'
import {type ComponentType} from 'react'
+import {type PerspectiveValue} from 'sanity'
export interface VisionProps {
client: SanityClient
config: VisionConfig
+ pinnedPerspective: PerspectiveValue
}
export interface VisionConfig {
diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts
index 2752021fab5..c2fd62689b8 100644
--- a/packages/sanity/src/core/index.ts
+++ b/packages/sanity/src/core/index.ts
@@ -34,6 +34,7 @@ export {
isReleaseDocument,
isReleaseScheduledOrScheduling,
LATEST,
+ type PerspectiveValue,
type ReleaseDocument,
RELEASES_INTENT,
useDocumentVersions,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f05bce00281..6908aa75995 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1261,6 +1261,9 @@ importers:
react-compiler-runtime:
specifier: 19.0.0-beta-55955c9-20241229
version: 19.0.0-beta-55955c9-20241229(react@18.3.1)
+ react-fast-compare:
+ specifier: ^3.2.2
+ version: 3.2.2
devDependencies:
'@repo/package.config':
specifier: workspace:*