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:*