diff --git a/packages/@sanity/types/src/schema/preview.ts b/packages/@sanity/types/src/schema/preview.ts index d297ce1c056..4e38ec051ce 100644 --- a/packages/@sanity/types/src/schema/preview.ts +++ b/packages/@sanity/types/src/schema/preview.ts @@ -1,5 +1,6 @@ import {type ElementType, type ReactNode} from 'react' +import {type UploadState} from '../upload' import {type SortOrdering} from './types' /** @public */ @@ -9,10 +10,7 @@ export interface PrepareViewOptions { } /** @public */ -export interface PreviewValue { - _id?: string - _createdAt?: string - _updatedAt?: string +export interface UserPreparedPreviewValue { title?: string subtitle?: string description?: string @@ -20,11 +18,20 @@ export interface PreviewValue { imageUrl?: string } +/** @public */ +export interface PreviewValue extends UserPreparedPreviewValue { + _id?: string + _type?: string + _upload?: UploadState + _createdAt?: string + _updatedAt?: string +} + /** @public */ export interface PreviewConfig< Select extends Record<string, string> = Record<string, string>, PrepareValue extends Record<keyof Select, any> = Record<keyof Select, any>, > { select?: Select - prepare?: (value: PrepareValue, viewOptions?: PrepareViewOptions) => PreviewValue + prepare?: (value: PrepareValue, viewOptions?: PrepareViewOptions) => UserPreparedPreviewValue } diff --git a/packages/@sanity/types/src/upload/uploadState.ts b/packages/@sanity/types/src/upload/uploadState.ts index 44fdbd0a9df..cf1253e945b 100644 --- a/packages/@sanity/types/src/upload/uploadState.ts +++ b/packages/@sanity/types/src/upload/uploadState.ts @@ -1,4 +1,4 @@ -/** @internal */ +/** @public */ export interface UploadState { progress: number /** @deprecated use createdAt instead */ diff --git a/packages/sanity/package.json b/packages/sanity/package.json index a09a9f596c1..0b42efbba94 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -167,6 +167,7 @@ "@sanity/eventsource": "^5.0.0", "@sanity/export": "^3.41.0", "@sanity/icons": "^3.4.0", + "@sanity/id-utils": "^1.0.0", "@sanity/image-url": "^1.0.2", "@sanity/import": "^3.37.3", "@sanity/insert-menu": "1.0.11", @@ -260,7 +261,9 @@ "speakingurl": "^14.0.1", "tar-fs": "^2.1.1", "tar-stream": "^3.1.7", + "ts-brand": "^0.2.0", "use-device-pixel-ratio": "^1.1.0", + "use-effect-event": "^1.0.2", "use-hot-module-reload": "^2.0.0", "use-sync-external-store": "^1.2.0", "vite": "^4.5.1", diff --git a/packages/sanity/src/core/comments/hooks/useNotificationTarget.ts b/packages/sanity/src/core/comments/hooks/useNotificationTarget.ts index 1eba3829160..dafd19a6b19 100644 --- a/packages/sanity/src/core/comments/hooks/useNotificationTarget.ts +++ b/packages/sanity/src/core/comments/hooks/useNotificationTarget.ts @@ -39,7 +39,7 @@ export function useNotificationTarget( const previewStateObservable = useMemo(() => { if (!documentId || !schemaType) return of(null) - return getPreviewStateObservable(documentPreviewStore, schemaType, documentId, '') + return getPreviewStateObservable(documentPreviewStore, schemaType, documentId) }, [documentId, documentPreviewStore, schemaType]) const previewState = useObservable(previewStateObservable) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx index c15d7a513d6..9a093eaec83 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/useReferenceInput.tsx @@ -8,10 +8,11 @@ import { useMemo, useRef, } from 'react' -import {usePerspective, useReleases} from 'sanity' +import {useReleases} from 'sanity' import {type FIXME} from '../../../FIXME' import {useSchema} from '../../../hooks' +import {useReleasesStack} from '../../../releases/store/useReleasesStack' import {useDocumentPreviewStore} from '../../../store' import {isNonNullable} from '../../../util' import {useFormValue} from '../../contexts/FormValue' @@ -35,7 +36,7 @@ interface Options { export function useReferenceInput(options: Options) { const {path, schemaType, version} = options const schema = useSchema() - const perspective = usePerspective() + const releases = useReleases() const documentPreviewStore = useDocumentPreviewStore() const {EditReferenceLinkComponent, onEditReference, activePath, initialValueTemplateItems} = @@ -116,6 +117,7 @@ export function useReferenceInput(options: Options) { ) }, [disableNew, initialValueTemplateItems, schemaType.to]) + const releasesStack = useReleasesStack() const getReferenceInfo = useCallback( (id: string) => adapter.getReferenceInfo( @@ -124,17 +126,11 @@ export function useReferenceInput(options: Options) { schemaType, {version}, { - bundleIds: releases.releasesIds, - bundleStack: perspective.bundlesPerspective, + releaseIds: releases.releasesIds, + bundleStack: releasesStack, }, ), - [ - documentPreviewStore, - schemaType, - version, - releases.releasesIds, - perspective.bundlesPerspective, - ], + [documentPreviewStore, schemaType, version, releases.releasesIds, releasesStack], ) return { 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 aba54c92a58..835a01b170e 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 @@ -10,6 +10,7 @@ import { type VersionsRecord, type VersionTuple, } from '../../../../preview/utils/getPreviewStateObservable' +import {type ReleaseId} from '../../../../releases' import {createSearch} from '../../../../search' import { collate, @@ -48,7 +49,7 @@ export function getReferenceInfo( id: string, referenceType: ReferenceSchemaType, {version}: {version?: string} = {}, - perspective: {bundleIds: string[]; bundleStack: string[]} = {bundleIds: [], bundleStack: []}, + perspective: {releaseIds: ReleaseId[]; bundleStack: string[]} = {releaseIds: [], bundleStack: []}, ): Observable<ReferenceInfo> { const {publishedId, draftId, versionId} = getIdPair(id, {version}) @@ -144,28 +145,30 @@ export function getReferenceInfo( refSchemaType, ) - const versions$ = from(perspective.bundleIds).pipe( - mergeMap<string, Observable<VersionTuple>>((bundleId) => + const versions$ = from(perspective.releaseIds).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), + map( + // eslint-disable-next-line max-nested-callbacks + (result): VersionTuple => + result + ? [ + bundleId, + { + snapshot: { + _id: versionId, + ...prepareForPreview(result, refSchemaType), + }, }, - }, - ] - : [bundleId, {snapshot: null}], + ] + : [bundleId, {snapshot: undefined}], ), ), ), - scan((byBundleId, [bundleId, value]) => { + + scan((byBundleId: VersionsRecord, [bundleId, value]) => { if (value.snapshot === null) { return omit({...byBundleId}, [bundleId]) } diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index fb778de8f60..fd23f110b76 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -26,17 +26,22 @@ export { AddedVersion, DiscardVersionDialog, getBundleIdFromReleaseDocumentId, + getPerspectiveTone, getPublishDateFromRelease, + getReleaseIdFromReleaseDocumentId, getReleaseTone, isDraftPerspective, isPublishedPerspective, isReleaseDocument, isReleaseScheduledOrScheduling, LATEST, + PUBLISHED_PERSPECTIVE, type ReleaseDocument, + useCurrentRelease, useDocumentVersions, - usePerspective, useReleases, + useReleasesStack, + useStudioPerspectiveState, useVersionOperations, VersionChip, versionDocumentExists, diff --git a/packages/sanity/src/core/preview/createPathObserver.ts b/packages/sanity/src/core/preview/createPathObserver.ts index 3400973382a..16c281f2d28 100644 --- a/packages/sanity/src/core/preview/createPathObserver.ts +++ b/packages/sanity/src/core/preview/createPathObserver.ts @@ -36,12 +36,7 @@ function observePaths( paths: PreviewPath[], observeFields: ObserveFieldsFn, apiConfig?: ApiConfig, -): Observable<Record<string, unknown> | null> { - if (!value || typeof value !== 'object') { - // Reached a leaf. Return as is - return observableOf(value as null) // @todo - } - +): Observable<Record<string, unknown> | undefined> { const id = getDocumentId(value) const currentValue: Record<string, unknown> = id ? {...value, _id: id} : {...value} @@ -70,7 +65,7 @@ function observePaths( return observeFields(id, nextHeads, refApiConfig).pipe( switchMap((snapshot) => { if (snapshot === null) { - return observableOf(null) + return observableOf(undefined) } return observePaths( @@ -132,7 +127,7 @@ export function createPathObserver(options: {observeFields: ObserveFieldsFn}) { value: Previewable, paths: (FieldName | PreviewPath)[], apiConfig?: ApiConfig, - ): Observable<Record<string, unknown> | null> => { + ): Observable<Record<string, unknown> | undefined> => { return observePaths(value, normalizePaths(paths), observeFields, apiConfig) } } diff --git a/packages/sanity/src/core/preview/createPreviewObserver.ts b/packages/sanity/src/core/preview/createPreviewObserver.ts index 20bde34d05f..2966cd31652 100644 --- a/packages/sanity/src/core/preview/createPreviewObserver.ts +++ b/packages/sanity/src/core/preview/createPreviewObserver.ts @@ -18,7 +18,7 @@ import { type PreviewableType, } from './types' import {getPreviewPaths} from './utils/getPreviewPaths' -import {invokePrepare, prepareForPreview} from './utils/prepareForPreview' +import {prepareForPreview} from './utils/prepareForPreview' function isRecord(value: unknown): value is Record<string, unknown> { return isPlainObject(value) @@ -99,7 +99,7 @@ export function createPreviewObserver(context: { return observePaths(value, paths, apiConfig).pipe( map((snapshot) => ({ type: type, - snapshot: snapshot ? prepareForPreview(snapshot, type, viewOptions) : null, + snapshot: snapshot ? prepareForPreview(snapshot, type, viewOptions) : undefined, })), ) } @@ -110,8 +110,7 @@ export function createPreviewObserver(context: { // `file`s, and `document`s return of({ type, - snapshot: - value && isRecord(value) ? invokePrepare(type, value, viewOptions).returnValue : null, + snapshot: value && isRecord(value) ? prepareForPreview(value, type, viewOptions) : undefined, }) } } diff --git a/packages/sanity/src/core/preview/documentPair.ts b/packages/sanity/src/core/preview/documentPair.ts index 1d618af1a38..40bfa926242 100644 --- a/packages/sanity/src/core/preview/documentPair.ts +++ b/packages/sanity/src/core/preview/documentPair.ts @@ -39,7 +39,7 @@ export function createObservePathsDocumentPair(options: { // short circuit, neither draft nor published is available so no point in trying to get a snapshot return of({ id: publishedId, - type: null, + type: undefined, draft: { availability: availability.draft, snapshot: undefined, @@ -74,11 +74,11 @@ export function createObservePathsDocumentPair(options: { (isRecord(publishedSnapshot) && '_type' in publishedSnapshot && publishedSnapshot._type) || - null + undefined return { id: publishedId, - type: typeof type === 'string' ? type : null, + type: typeof type === 'string' ? type : undefined, draft: { availability: availability.draft, snapshot: draftSnapshot as T, diff --git a/packages/sanity/src/core/preview/types.ts b/packages/sanity/src/core/preview/types.ts index 37d57011b17..39ddd478033 100644 --- a/packages/sanity/src/core/preview/types.ts +++ b/packages/sanity/src/core/preview/types.ts @@ -1,7 +1,6 @@ import { type CrossDatasetType, type PreviewValue, - type Reference, type SanityDocumentLike, type SchemaType, } from '@sanity/types' @@ -39,6 +38,11 @@ export type PreviewPath = FieldName[] /** @internal */ export type Selection = [Id, FieldName[]] +/** @internal */ +export interface PartialPreviewDocument { + _id: string + _type: string +} /** * @hidden * @beta */ @@ -104,7 +108,7 @@ export interface DraftsModelDocumentAvailability { * @beta */ export interface DraftsModelDocument<T extends SanityDocumentLike = SanityDocumentLike> { id: string - type: string | null + type: string | undefined draft: { availability: DocumentAvailability snapshot: T | undefined @@ -135,7 +139,7 @@ export type InvalidationChannelEvent = * @beta */ export interface PreparedSnapshot { type?: PreviewableType - snapshot: PreviewValue | null | undefined + snapshot: PreviewValue | undefined } /** @internal */ @@ -152,7 +156,7 @@ export interface ObservePathsFn { value: Previewable, paths: (string | PreviewPath)[], apiConfig?: ApiConfig, - ): Observable<PreviewValue | SanityDocumentLike | Reference | string | null> + ): Observable<Record<string, unknown> | undefined> } /** diff --git a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts index 12c93ed5fcc..9403c05deff 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -1,19 +1,19 @@ import {type PreviewValue, type SanityDocument, type SchemaType} from '@sanity/types' import {omit} from 'lodash' -import {type ReactNode} from 'react' import {combineLatest, from, type Observable, of} from 'rxjs' import {map, mergeMap, scan, startWith} from 'rxjs/operators' import {type PreparedSnapshot} from 'sanity' +import {type ReleaseId} from '../../releases' import {getDraftId, getPublishedId, getVersionId} from '../../util/draftUtils' import {type DocumentPreviewStore} from '../documentPreviewStore' /** * @internal */ -export type VersionsRecord = Record<string, PreparedSnapshot> +export type VersionsRecord = Record<ReleaseId, PreparedSnapshot> -export type VersionTuple = [bundleId: string, snapshot: PreparedSnapshot] +export type VersionTuple = [bundleId: ReleaseId, snapshot: PreparedSnapshot] export interface PreviewState { isLoading?: boolean @@ -34,78 +34,50 @@ export function getPreviewStateObservable( documentPreviewStore: DocumentPreviewStore, 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[] - } = { - bundleIds: [], - bundleStack: [], - }, + /** + * What additional releases to fetch versions from + */ + releases: ReleaseId[] = [], ): Observable<PreviewState> { const draft$ = isLiveEditEnabled(schemaType) ? of({snapshot: null}) : documentPreviewStore.observeForPreview({_id: getDraftId(documentId)}, schemaType) - const versions$ = from(perspective.bundleIds).pipe( - mergeMap<string, Observable<VersionTuple>>((bundleId) => + const versions$ = from(releases).pipe( + mergeMap((release) => documentPreviewStore - .observeForPreview({_id: getVersionId(documentId, bundleId)}, schemaType) - .pipe(map((storeValue) => [bundleId, storeValue])), + .observeForPreview({_id: getVersionId(documentId, release)}, schemaType) + .pipe(map((storeValue): VersionTuple => [release, storeValue])), ), - scan<VersionTuple, VersionsRecord>((byBundleId, [bundleId, value]) => { - if (value.snapshot === null) { - return omit({...byBundleId}, [bundleId]) + scan((byVersionId, [releaseId, value]) => { + if (value.snapshot === undefined) { + return omit({...byVersionId}, [releaseId]) } return { - ...byBundleId, - [bundleId]: value, + ...byVersionId, + [releaseId]: value, } }, {}), startWith<VersionsRecord>({}), ) - // Iterate the release stack in descending precedence, returning the highest precedence existing - // version document. - const version$ = versions$.pipe( - map((versions) => { - for (const bundleId of perspective.bundleStack) { - if (bundleId in versions) { - return versions[bundleId] - } - } - return {snapshot: null} - }), - startWith<PreparedSnapshot>({snapshot: null}), - ) - const published$ = documentPreviewStore.observeForPreview( {_id: getPublishedId(documentId)}, schemaType, ) - return combineLatest([draft$, published$, version$, versions$]).pipe( - map(([draft, published, version, versions]) => ({ - draft: draft.snapshot ? {title, ...(draft.snapshot || {})} : null, + return combineLatest([draft$, published$, versions$]).pipe( + map(([draft, published, versions]) => ({ + draft: draft.snapshot, isLoading: false, - published: published.snapshot ? {title, ...(published.snapshot || {})} : null, - version: version.snapshot ? {title, ...(version.snapshot || {})} : null, + published: published.snapshot, versions, })), startWith({ - draft: null, + draft: undefined, isLoading: true, - published: null, - version: null, + published: undefined, versions: {}, }), ) diff --git a/packages/sanity/src/core/preview/utils/prepareForPreview.ts b/packages/sanity/src/core/preview/utils/prepareForPreview.ts index f07f16f421b..ef998e40b4d 100644 --- a/packages/sanity/src/core/preview/utils/prepareForPreview.ts +++ b/packages/sanity/src/core/preview/utils/prepareForPreview.ts @@ -4,6 +4,7 @@ import { type PreviewValue, type SchemaType, type TitledListValue, + type UserPreparedPreviewValue, } from '@sanity/types' import {debounce, flatten, get, isPlainObject, pick, uniqBy} from 'lodash' @@ -13,14 +14,14 @@ import {type PreviewableType} from '../types' import {keysOf} from './keysOf' import {extractTextFromBlocks, isPortableTextPreviewValue} from './portableText' -const PRESERVE_KEYS = ['_id', '_type', '_upload', '_createdAt', '_updatedAt'] +const PRESERVE_KEYS = ['_id', '_type', '_upload', '_createdAt', '_updatedAt'] as const const EMPTY: never[] = [] type SelectedValue = Record<string, unknown> export type PrepareInvocationResult = { selectedValue?: SelectedValue - returnValue: null | PreviewValue + returnValue: UserPreparedPreviewValue | undefined errors: Error[] } @@ -163,8 +164,8 @@ function assignType(type: string, error: Error) { return Object.assign(error, {type}) } -function validatePreparedValue(preparedValue: PreviewValue | null) { - if (!isPlainObject(preparedValue) || preparedValue === null) { +function validatePreparedValue(preparedValue: UserPreparedPreviewValue | undefined) { + if (!isPlainObject(preparedValue) || preparedValue === undefined) { return [ assignType( 'returnValueError', @@ -189,7 +190,7 @@ function validateReturnedPreview(result: PrepareInvocationResult) { } } -function defaultPrepare(value: SelectedValue) { +function defaultPrepare(value: SelectedValue): UserPreparedPreviewValue { return keysOf(value).reduce((acc: SelectedValue, fieldName: keyof SelectedValue) => { const val = value[fieldName] return { @@ -207,14 +208,12 @@ export function invokePrepare( const prepare = type.preview?.prepare try { return { - returnValue: prepare - ? (prepare(value, viewOptions) as Record<string, unknown>) - : defaultPrepare(value), + returnValue: prepare ? prepare(value, viewOptions) : defaultPrepare(value), errors: EMPTY, } } catch (error) { return { - returnValue: null, + returnValue: undefined, errors: [assignType('prepareError', error)], } } @@ -298,7 +297,9 @@ export function prepareForPreview( } const returnValueResult = validateReturnedPreview(prepareResult) - return returnValueResult.errors.length > 0 - ? withErrors(returnValueResult, type, selectedValue) - : {...pick(rawValue, PRESERVE_KEYS), ...prepareResult.returnValue} + return ( + returnValueResult.errors.length > 0 + ? withErrors(returnValueResult, type, selectedValue) + : {...pick(rawValue, PRESERVE_KEYS), ...prepareResult.returnValue} + ) as PreviewValue } diff --git a/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx index b4e384692ce..7ff6d93a12c 100644 --- a/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx +++ b/packages/sanity/src/core/releases/components/dialog/CreateReleaseDialog.tsx @@ -9,8 +9,8 @@ import {CreatedRelease, type OriginInfo} from '../../__telemetry__/releases.tele 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 {generateReleaseDocumentId} from '../../util/releaseId' import {ReleaseForm} from './ReleaseForm' interface CreateReleaseDialogProps { @@ -28,7 +28,7 @@ export function CreateReleaseDialog(props: CreateReleaseDialogProps): JSX.Elemen const [value, setValue] = useState((): EditableReleaseDocument => { return { - _id: createReleaseId(), + _id: generateReleaseDocumentId(), metadata: { releaseType: DEFAULT_RELEASE_TYPE, }, diff --git a/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx index 1b6af447508..68587e06c9b 100644 --- a/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx +++ b/packages/sanity/src/core/releases/components/dialog/DiscardVersionDialog.tsx @@ -1,3 +1,4 @@ +import {DocumentId, getPublishedId, getVersionNameFromId, isVersionId} from '@sanity/id-utils' import {Box} from '@sanity/ui' import {useCallback, useState} from 'react' @@ -6,11 +7,9 @@ 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 {useVersionOperations} from '../../hooks' import {releasesLocaleNamespace} from '../../i18n' -import {type ReleaseDocument} from '../../store' -import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' +import {ReleaseId} from '../../util/releaseId' /** * @internal @@ -20,11 +19,11 @@ export function DiscardVersionDialog(props: { documentId: string documentType: string }): JSX.Element { - const {onClose, documentId, documentType} = props + const {onClose, documentType} = props + const documentId = DocumentId(props.documentId) const {t} = useTranslation(releasesLocaleNamespace) const {discardChanges} = useDocumentOperation(getPublishedId(documentId), documentType) - const {currentGlobalBundle} = usePerspective() const {discardVersion} = useVersionOperations() const schema = useSchema() const [isDiscarding, setIsDiscarding] = useState(false) @@ -35,11 +34,7 @@ export function DiscardVersionDialog(props: { setIsDiscarding(true) if (isVersionId(documentId)) { - await discardVersion( - getVersionFromId(documentId) || - getBundleIdFromReleaseDocumentId((currentGlobalBundle as ReleaseDocument)._id), - documentId, - ) + await discardVersion(ReleaseId(getVersionNameFromId(documentId)), documentId) } else { // on the document header you can also discard the draft discardChanges.execute() @@ -48,7 +43,7 @@ export function DiscardVersionDialog(props: { setIsDiscarding(false) onClose() - }, [currentGlobalBundle, discardChanges, discardVersion, documentId, onClose]) + }, [discardChanges, discardVersion, documentId, onClose]) return ( <Dialog diff --git a/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx index 4d3199333a1..47ab42051a3 100644 --- a/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx +++ b/packages/sanity/src/core/releases/components/documentHeader/VersionChip.tsx @@ -17,6 +17,7 @@ import {getVersionId} from '../../../util/draftUtils' import {useVersionOperations} from '../../hooks/useVersionOperations' import {type ReleaseDocument} from '../../store/types' import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' +import {getReleaseIdFromReleaseDocumentId, type ReleaseDocumentId} from '../../util/releaseId' import {DiscardVersionDialog} from '../dialog/DiscardVersionDialog' import {ReleaseAvatar} from '../ReleaseAvatar' import {VersionContextMenu} from './contextMenu/VersionContextMenu' @@ -134,8 +135,8 @@ export const VersionChip = memo(function VersionChip(props: { }, [setIsCreateReleaseDialogOpen]) const handleAddVersion = useCallback( - async (targetRelease: string) => { - await createVersion(getBundleIdFromReleaseDocumentId(targetRelease), docId) + async (targetRelease: ReleaseDocumentId) => { + await createVersion(getReleaseIdFromReleaseDocumentId(targetRelease), docId) close() }, [createVersion, docId, close], diff --git a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx index 673ebfcd04c..c6431137e7c 100644 --- a/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx +++ b/packages/sanity/src/core/releases/components/documentHeader/contextMenu/VersionContextMenu.tsx @@ -9,6 +9,7 @@ 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 {type ReleaseDocumentId} from '../../../util/releaseId' import {isReleaseScheduledOrScheduling} from '../../../util/util' import {VersionContextMenuItem} from './VersionContextMenuItem' @@ -26,7 +27,7 @@ export const VersionContextMenu = memo(function VersionContextMenu(props: { isVersion: boolean onDiscard: () => void onCreateRelease: () => void - onCreateVersion: (targetId: string) => void + onCreateVersion: (targetId: ReleaseDocumentId) => void disabled?: boolean locked?: boolean }) { diff --git a/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx index 2798299014d..0ee757c9117 100644 --- a/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx +++ b/packages/sanity/src/core/releases/components/documentHeader/dialog/CopyToNewReleaseDialog.tsx @@ -11,7 +11,7 @@ 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 {generateReleaseDocumentId, type ReleaseDocumentId} from '../../../util/releaseId' import {ReleaseForm} from '../../dialog/ReleaseForm' import {ReleaseAvatar} from '../../ReleaseAvatar' @@ -21,7 +21,7 @@ export function CopyToNewReleaseDialog(props: { documentType: string tone: BadgeTone title: string - onCreateVersion: (releaseId: string) => void + onCreateVersion: (releaseId: ReleaseDocumentId) => void }): JSX.Element { const {onClose, documentId, documentType, tone, title, onCreateVersion} = props const {t} = useTranslation() @@ -30,7 +30,7 @@ export function CopyToNewReleaseDialog(props: { const schema = useSchema() const schemaType = schema.get(documentType) - const [newReleaseId] = useState(createReleaseId()) + const [newReleaseId] = useState(generateReleaseDocumentId()) const [value, setValue] = useState((): EditableReleaseDocument => { return { diff --git a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts index 79acfd79365..aa26ecb4c1d 100644 --- a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts +++ b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts @@ -2,9 +2,10 @@ 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' +import {type SelectableReleasePerspective} from '../../util/perspective' +import {generateReleaseDocumentId, ReleaseDocumentId} from '../../util/releaseId' +import {getReleasesStack, sortReleases} from '../utils' function createReleaseMock( value: Partial< @@ -13,10 +14,10 @@ function createReleaseMock( } >, ): ReleaseDocument { - const id = value._id || createReleaseId() + const id = value._id || generateReleaseDocumentId() const name = getBundleIdFromReleaseDocumentId(id) return { - _id: id, + _id: ReleaseDocumentId(id), _type: RELEASE_DOCUMENT_TYPE, _createdAt: new Date().toISOString(), _updatedAt: new Date().toISOString(), @@ -35,14 +36,14 @@ describe('sortReleases()', () => { it('should return the asap releases ordered by createdAt', () => { const releases: ReleaseDocument[] = [ createReleaseMock({ - _id: '_.releases.asap1', + _id: ReleaseDocumentId('_.releases.asap1'), _createdAt: '2024-10-24T00:00:00Z', metadata: { releaseType: 'asap', }, }), createReleaseMock({ - _id: '_.releases.asap2', + _id: ReleaseDocumentId('_.releases.asap2'), _createdAt: '2024-10-25T00:00:00Z', metadata: { releaseType: 'asap', @@ -58,21 +59,21 @@ describe('sortReleases()', () => { it('should return the scheduled releases ordered by intendedPublishAt or publishAt', () => { const releases: ReleaseDocument[] = [ createReleaseMock({ - _id: '_.releases.future2', + _id: ReleaseDocumentId('_.releases.future2'), metadata: { releaseType: 'scheduled', intendedPublishAt: '2024-11-25T00:00:00Z', }, }), createReleaseMock({ - _id: '_.releases.future1', + _id: ReleaseDocumentId('_.releases.future1'), metadata: { releaseType: 'scheduled', intendedPublishAt: '2024-11-23T00:00:00Z', }, }), createReleaseMock({ - _id: '_.releases.future4', + _id: ReleaseDocumentId('_.releases.future4'), state: 'scheduled', publishAt: '2024-11-31T00:00:00Z', metadata: { @@ -81,7 +82,7 @@ describe('sortReleases()', () => { }, }), createReleaseMock({ - _id: '_.releases.future3', + _id: ReleaseDocumentId('_.releases.future3'), state: 'scheduled', publishAt: '2024-11-26T00:00:00Z', metadata: { @@ -99,14 +100,14 @@ describe('sortReleases()', () => { it('should return the undecided releases ordered by createdAt', () => { const releases: ReleaseDocument[] = [ createReleaseMock({ - _id: '_.releases.undecided1', + _id: ReleaseDocumentId('_.releases.undecided1'), _createdAt: '2024-10-25T00:00:00Z', metadata: { releaseType: 'undecided', }, }), createReleaseMock({ - _id: '_.releases.undecided2', + _id: ReleaseDocumentId('_.releases.undecided2'), _createdAt: '2024-10-26T00:00:00Z', metadata: { releaseType: 'undecided', @@ -122,28 +123,28 @@ describe('sortReleases()', () => { it("should gracefully combine all release types, and sort them by 'undecided', 'scheduled', 'asap'", () => { const releases = [ createReleaseMock({ - _id: '_.releases.asap2', + _id: ReleaseDocumentId('_.releases.asap2'), _createdAt: '2024-10-25T00:00:00Z', metadata: { releaseType: 'asap', }, }), createReleaseMock({ - _id: '_.releases.asap1', + _id: ReleaseDocumentId('_.releases.asap1'), _createdAt: '2024-10-24T00:00:00Z', metadata: { releaseType: 'asap', }, }), createReleaseMock({ - _id: '_.releases.undecided2', + _id: ReleaseDocumentId('_.releases.undecided2'), _createdAt: '2024-10-26T00:00:00Z', metadata: { releaseType: 'undecided', }, }), createReleaseMock({ - _id: '_.releases.future4', + _id: ReleaseDocumentId('_.releases.future4'), state: 'scheduled', publishAt: '2024-11-31T00:00:00Z', metadata: { @@ -152,7 +153,7 @@ describe('sortReleases()', () => { }, }), createReleaseMock({ - _id: '_.releases.future1', + _id: ReleaseDocumentId('_.releases.future1'), metadata: { releaseType: 'scheduled', intendedPublishAt: '2024-11-23T00:00:00Z', @@ -170,28 +171,28 @@ describe('sortReleases()', () => { describe('getReleasesPerspective()', () => { const releases = [ createReleaseMock({ - _id: '_.releases.asap2', + _id: ReleaseDocumentId('_.releases.asap2'), _createdAt: '2024-10-25T00:00:00Z', metadata: { releaseType: 'asap', }, }), createReleaseMock({ - _id: '_.releases.asap1', + _id: ReleaseDocumentId('_.releases.asap1'), _createdAt: '2024-10-24T00:00:00Z', metadata: { releaseType: 'asap', }, }), createReleaseMock({ - _id: '_.releases.undecided2', + _id: ReleaseDocumentId('_.releases.undecided2'), _createdAt: '2024-10-26T00:00:00Z', metadata: { releaseType: 'undecided', }, }), createReleaseMock({ - _id: '_.releases.future4', + _id: ReleaseDocumentId('_.releases.future4'), state: 'scheduled', publishAt: '2024-11-31T00:00:00Z', metadata: { @@ -200,7 +201,7 @@ describe('getReleasesPerspective()', () => { }, }), createReleaseMock({ - _id: '_.releases.future1', + _id: ReleaseDocumentId('_.releases.future1'), metadata: { releaseType: 'scheduled', intendedPublishAt: '2024-11-23T00:00:00Z', @@ -219,13 +220,13 @@ describe('getReleasesPerspective()', () => { { perspective: 'bundle.undecided2', excluded: ['future1', 'drafts'], - expected: ['undecided2', 'future4', 'asap2', 'asap1'], + expected: ['undecided2', 'future4', 'asap2', 'asap1'] as SelectableReleasePerspective[], }, ] it.each(testCases)( 'should return the correct release stack for %s', ({perspective, excluded, expected}) => { - const result = getReleasesPerspective({releases, perspective, excluded}) + const result = getReleasesStack({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 index 0ed88aee8c3..642e8468382 100644 --- a/packages/sanity/src/core/releases/hooks/index.ts +++ b/packages/sanity/src/core/releases/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useCurrentRelease' export * from './useDocumentVersions' -export * from './usePerspective' +export * from './useStudioPerspectiveState' export * from './useVersionOperations' diff --git a/packages/sanity/src/core/releases/hooks/useCurrentRelease.ts b/packages/sanity/src/core/releases/hooks/useCurrentRelease.ts new file mode 100644 index 00000000000..7cc51825576 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useCurrentRelease.ts @@ -0,0 +1,9 @@ +import {useReleases} from '../store' +import {getReleaseIdFromReleaseDocumentId} from '../util/releaseId' +import {useStudioPerspectiveState} from './useStudioPerspectiveState' + +export function useCurrentRelease() { + const {current} = useStudioPerspectiveState() + const releases = useReleases() + return releases.data.find((release) => getReleaseIdFromReleaseDocumentId(release._id) === current) +} diff --git a/packages/sanity/src/core/releases/hooks/usePerspective.tsx b/packages/sanity/src/core/releases/hooks/usePerspective.tsx deleted file mode 100644 index 146a58f2232..00000000000 --- a/packages/sanity/src/core/releases/hooks/usePerspective.tsx +++ /dev/null @@ -1,173 +0,0 @@ -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/useStudioPerspectiveState.ts b/packages/sanity/src/core/releases/hooks/useStudioPerspectiveState.ts new file mode 100644 index 00000000000..d63602ccba0 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useStudioPerspectiveState.ts @@ -0,0 +1,111 @@ +/* eslint-disable no-nested-ternary */ +import {useCallback, useMemo} from 'react' +import {useRouter} from 'sanity/router' +import {useEffectEvent} from 'use-effect-event' + +import { + DRAFTS_PERSPECTIVE, + type DraftsPerspective, + PUBLISHED_PERSPECTIVE, + SelectableReleasePerspective, +} from '../util/perspective' +import {type ReleaseId} from '../util/releaseId' + +export interface StudioPerspectiveState { + current: SelectableReleasePerspective | undefined + excluded: SelectableReleasePerspective[] + toggle: (perspective: SelectableReleasePerspective) => void + include: (perspective: SelectableReleasePerspective) => void + exclude: (perspective: SelectableReleasePerspective) => void + setCurrent: (perspective: DraftsPerspective | SelectableReleasePerspective) => void +} + +const EMPTY: never[] = [] + +const RELEASE_PARAM_PREFIX = 'release.' +function encodeReleasePerspective(releaseId: ReleaseId) { + return RELEASE_PARAM_PREFIX + releaseId +} +function decodeReleasePerspective(param: string) { + if (!param.startsWith(RELEASE_PARAM_PREFIX)) { + throw new Error(`Expected release perspective parameter to start with ${RELEASE_PARAM_PREFIX}`) + } + return param.slice(RELEASE_PARAM_PREFIX.length) +} + +function parsePerspectiveParam(param: string | undefined) { + if (!param) { + return undefined + } + return param === 'drafts' + ? undefined + : param === 'published' + ? PUBLISHED_PERSPECTIVE + : SelectableReleasePerspective(decodeReleasePerspective(param)) +} + +export function useStudioPerspectiveState(): StudioPerspectiveState { + const router = useRouter() + const setCurrent = useCallback( + (nextRelease: DraftsPerspective | SelectableReleasePerspective) => { + router.navigateStickyParams({ + // drafts is the default perspective so will not be written to the url + perspective: + nextRelease === DRAFTS_PERSPECTIVE + ? undefined + : nextRelease === PUBLISHED_PERSPECTIVE + ? 'published' + : encodeReleasePerspective(nextRelease as ReleaseId /*Why typescript why?*/), + excludedPerspectives: undefined, + }) + }, + [router], + ) + const excluded = parseExcludedReleases(router.stickyParams.excludedPerspectives) + const current = useMemo(() => { + return parsePerspectiveParam(router.stickyParams.perspective) + }, [router.stickyParams.perspective]) + + const exclude = useEffectEvent((toExclude: SelectableReleasePerspective) => { + if (excluded.includes(toExclude)) { + return + } + router.navigateStickyParams({excludedPerspectives: [toExclude, ...excluded].join(',')}) + }) + + const include = useEffectEvent((toInclude: SelectableReleasePerspective) => { + if (!excluded.includes(toInclude)) { + return + } + router.navigateStickyParams({ + excludedReleases: excluded.filter((release) => release === toInclude).join(','), + }) + }) + + const toggle = useEffectEvent((toToggle: SelectableReleasePerspective) => { + if (excluded.includes(toToggle)) { + include(toToggle) + } else { + exclude(toToggle) + } + }) + + return useMemo( + () => ({ + current: current, + excluded, + toggle, + setCurrent, + include, + exclude, + }), + [current, exclude, excluded, include, setCurrent, toggle], + ) +} + +function parseExcludedReleases(input: string | undefined) { + if (!input) { + return EMPTY + } + return input.split(',').map((id) => SelectableReleasePerspective(id)) +} diff --git a/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx index adbcf80c48e..3cfb7117093 100644 --- a/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx +++ b/packages/sanity/src/core/releases/hooks/useVersionOperations.tsx @@ -4,26 +4,27 @@ import {useToast} from '@sanity/ui' import {Translate, useTranslation} from '../../i18n' import {AddedVersion} from '../__telemetry__/releases.telemetry' import {useReleaseOperations} from '../store/useReleaseOperations' +import {type ReleaseId} from '../util/releaseId' import {getCreateVersionOrigin} from '../util/util' -import {usePerspective} from './usePerspective' +import {useStudioPerspectiveState} from './useStudioPerspectiveState' /** @internal */ export function useVersionOperations(): { - createVersion: (releaseId: string, documentId: string) => Promise<void> - discardVersion: (releaseId: string, documentId: string) => Promise<void> + createVersion: (releaseId: ReleaseId, documentId: string) => Promise<void> + discardVersion: (releaseId: ReleaseId, documentId: string) => Promise<void> } { const telemetry = useTelemetry() const {createVersion, discardVersion} = useReleaseOperations() - const {setPerspectiveFromReleaseId} = usePerspective() + const {setCurrent} = useStudioPerspectiveState() const toast = useToast() const {t} = useTranslation() - const handleCreateVersion = async (releaseId: string, documentId: string) => { + const handleCreateVersion = async (releaseId: ReleaseId, documentId: string) => { const origin = getCreateVersionOrigin(documentId) try { await createVersion(releaseId, documentId) - setPerspectiveFromReleaseId(releaseId) + setCurrent(releaseId) telemetry.log(AddedVersion, { documentOrigin: origin, }) diff --git a/packages/sanity/src/core/releases/hooks/utils.ts b/packages/sanity/src/core/releases/hooks/utils.ts index adf392c2f94..5a4ddde884f 100644 --- a/packages/sanity/src/core/releases/hooks/utils.ts +++ b/packages/sanity/src/core/releases/hooks/utils.ts @@ -1,7 +1,10 @@ -import {DRAFTS_FOLDER} from '../../util/draftUtils' -import {resolveBundlePerspective} from '../../util/resolvePerspective' import {type ReleaseDocument} from '../store/types' -import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId' +import { + PUBLISHED_PERSPECTIVE, + type PublishedPerspective, + type SelectableReleasePerspective, +} from '../util/perspective' +import {getReleaseIdFromReleaseDocumentId, type ReleaseId} from '../util/releaseId' export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[] { // The order should always be: @@ -48,32 +51,28 @@ export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[ }) } -export function getReleasesPerspective({ +export function getReleasesStack({ releases, - perspective, + current, excluded, }: { releases: ReleaseDocument[] - perspective: string | undefined // Includes the bundle.<releaseName> or 'published' - excluded: string[] -}): string[] { - if (!perspective?.startsWith('bundle.')) { - return [] - } - const perspectiveId = resolveBundlePerspective(perspective) - if (!perspectiveId) { + current: SelectableReleasePerspective | undefined + excluded: SelectableReleasePerspective[] +}): SelectableReleasePerspective[] { + if (!current) { return [] } - const sorted = sortReleases(releases).map((release) => - getBundleIdFromReleaseDocumentId(release._id), + const sorted: (ReleaseId | PublishedPerspective)[] = sortReleases(releases).map((release) => + getReleaseIdFromReleaseDocumentId(release._id), ) - const selectedIndex = sorted.indexOf(perspectiveId) + const selectedIndex = sorted.indexOf(current) if (selectedIndex === -1) { return [] } return sorted .slice(selectedIndex) - .concat(DRAFTS_FOLDER) + .concat(PUBLISHED_PERSPECTIVE) .filter((name) => !excluded.includes(name)) } diff --git a/packages/sanity/src/core/releases/index.ts b/packages/sanity/src/core/releases/index.ts index 22de4d47251..ef78587f91b 100644 --- a/packages/sanity/src/core/releases/index.ts +++ b/packages/sanity/src/core/releases/index.ts @@ -5,7 +5,8 @@ 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/perspective' +export * from './util/releaseId' export * from './util/util' diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx index 2e26abd8cc5..59836efccda 100644 --- a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx +++ b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx @@ -7,14 +7,13 @@ 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' +import {useStudioPerspectiveState} from '../hooks' import {type ReleaseDocument, type ReleaseType} from '../store/types' import {useReleases} from '../store/useReleases' -import { - getRangePosition, - GlobalPerspectiveMenuItem, - type LayerRange, -} from './GlobalPerspectiveMenuItem' +import {PUBLISHED_PERSPECTIVE} from '../util/perspective' +import {getReleaseIdFromReleaseDocumentId} from '../util/releaseId' +import {GlobalPublishedPerspectiveMenuItem} from './GlobalPublishedPerspectiveMenuItem' +import {getRangePosition, type LayerRange} from './GlobalReleasePerspectiveMenuItem' import {ReleaseTypeMenuSection} from './ReleaseTypeMenuSection' import {useScrollIndicatorVisibility} from './useScrollIndicatorVisibility' @@ -44,7 +43,7 @@ const ASAP_RANGE_OFFSET = 2 export function GlobalPerspectiveMenu(): JSX.Element { const {loading, data: releases} = useReleases() - const {currentGlobalBundleId} = usePerspective() + const {current} = useStudioPerspectiveState() const [createBundleDialogOpen, setCreateBundleDialogOpen] = useState(false) const styledMenuRef = useRef<HTMLDivElement>(null) @@ -82,7 +81,7 @@ export function GlobalPerspectiveMenu(): JSX.Element { firstIndex = 0 // } - if (currentGlobalBundleId === 'published') { + if (current === 'published') { lastIndex = 0 } @@ -109,7 +108,7 @@ export function GlobalPerspectiveMenu(): JSX.Element { // } } - if (_id === currentGlobalBundleId) { + if (getReleaseIdFromReleaseDocumentId(_id) === current) { lastIndex = index } }) @@ -122,7 +121,7 @@ export function GlobalPerspectiveMenu(): JSX.Element { lastIndex, offsets, } - }, [currentGlobalBundleId, sortedReleaseTypeReleases]) + }, [current, sortedReleaseTypeReleases]) const releasesList = useMemo(() => { if (loading) { @@ -137,9 +136,9 @@ export function GlobalPerspectiveMenu(): JSX.Element { <Box> <StyledBox ref={setScrollContainer} onScroll={onScroll}> <StyledPublishedBox $removePadding={!releases.length}> - <GlobalPerspectiveMenuItem + <GlobalPublishedPerspectiveMenuItem rangePosition={isRangeVisible ? getRangePosition(range, 0) : undefined} - release={'published'} + perspective={PUBLISHED_PERSPECTIVE} /> </StyledPublishedBox> <> diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx deleted file mode 100644 index 51863539b3f..00000000000 --- a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx +++ /dev/null @@ -1,211 +0,0 @@ -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 { - firstIndex: number - 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 = () => ( - <Box padding={3}> - <Text size={1}> - <DotIcon - style={ - { - opacity: 0, - } as CSSProperties - } - /> - </Text> - </Box> -) - -type rangePosition = 'first' | 'within' | 'last' | undefined - -export function getRangePosition(range: LayerRange, index: number): rangePosition { - const {firstIndex, lastIndex} = range - - if (firstIndex === lastIndex) return undefined - if (index === firstIndex) return 'first' - if (index === lastIndex) return 'last' - if (index > firstIndex && 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<HTMLDivElement>) => { - 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 ( - <GlobalPerspectiveMenuItemIndicator - $isPublished={isReleasePublishedPerspective} - $first={first} - $last={last} - $inRange={inRange} - ref={ref} - > - <MenuItem onClick={handleOnReleaseClick} padding={1} pressed={active}> - <Flex align="flex-start" gap={1}> - <Box - flex="none" - style={{ - position: 'relative', - zIndex: 1, - }} - > - <Text size={1}> - {isReleasePerspectiveExcluded ? ( - <ExcludedLayerDot /> - ) : ( - <ReleaseAvatar tone={getReleaseTone(release)} /> - )} - {/* )} */} - </Text> - </Box> - <Stack - flex={1} - paddingY={2} - paddingRight={2} - space={2} - style={{ - opacity: isReleasePerspectiveExcluded ? 0.5 : undefined, - }} - > - <Text size={1} weight="medium"> - {displayTitle} - </Text> - {!isPublishedPerspective(release) && - release.metadata.releaseType === 'scheduled' && - (release.publishAt || release.metadata.intendedPublishAt) && ( - <Text muted size={1}> - {formatRelativeLocale(getPublishDateFromRelease(release), new Date())} - </Text> - )} - </Stack> - <Box flex="none"> - {canReleaseBeExcluded && ( - <Tooltip portal content={t('release.layer.hide')} placement="bottom"> - <ToggleLayerButton - $visible={!isReleasePerspectiveExcluded} - forwardedAs="div" - icon={isReleasePerspectiveExcluded ? EyeClosedIcon : EyeOpenIcon} - mode="bleed" - onClick={handleToggleReleaseVisibility} - padding={2} - /> - </Tooltip> - )} - {!isPublishedPerspective(release) && isReleaseScheduledOrScheduling(release) && ( - <Box padding={2}> - <Text size={1}> - <LockIcon /> - </Text> - </Box> - )} - </Box> - </Flex> - </MenuItem> - </GlobalPerspectiveMenuItemIndicator> - ) -}) - -GlobalPerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem' diff --git a/packages/sanity/src/core/releases/navbar/GlobalPublishedPerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/GlobalPublishedPerspectiveMenuItem.tsx new file mode 100644 index 00000000000..c75346bb77d --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/GlobalPublishedPerspectiveMenuItem.tsx @@ -0,0 +1,107 @@ +import {DotIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- custom use for MenuItem & Button not supported by ui-components +import {Box, Button, Text} from '@sanity/ui' +import {type CSSProperties, forwardRef, type MouseEvent, useCallback} from 'react' +import {css, styled} from 'styled-components' + +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {useStudioPerspectiveState} from '../hooks/useStudioPerspectiveState' +import {getPerspectiveTone} from '../util/getReleaseTone' +import {type PublishedPerspective} from '../util/perspective' +import {PerspectiveMenuItem} from './PerspectiveMenuItem' + +export interface LayerRange { + firstIndex: number + 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 = () => ( + <Box padding={3}> + <Text size={1}> + <DotIcon + style={ + { + opacity: 0, + } as CSSProperties + } + /> + </Text> + </Box> +) + +type rangePosition = 'first' | 'within' | 'last' | undefined + +export const GlobalPublishedPerspectiveMenuItem = forwardRef< + HTMLDivElement, + { + perspective: PublishedPerspective + rangePosition: rangePosition + } +>((props, ref) => { + const {perspective, rangePosition} = props + const {current, setCurrent, toggle, excluded} = useStudioPerspectiveState() + + const active = perspective === current + const first = rangePosition === 'first' + const within = rangePosition === 'within' + const last = rangePosition === 'last' + const inRange = first || within || last + + const {t} = useTranslation() + + const isReleasePerspectiveExcluded = excluded.includes(perspective) + + const handleToggleReleaseVisibility = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + event.stopPropagation() + toggle(perspective) + }, + [perspective, toggle], + ) + + const handleOnReleaseClick = useCallback(() => setCurrent(perspective), [perspective, setCurrent]) + + const canReleaseBeExcluded = inRange && !last + + return ( + <PerspectiveMenuItem + onClick={handleOnReleaseClick} + tone={getPerspectiveTone(perspective)} + onToggleVisibility={handleToggleReleaseVisibility} + title={t('release.navbar.published')} + active={active} + excluded={isReleasePerspectiveExcluded} + canBeExcluded={canReleaseBeExcluded} + rangePosition={rangePosition} + extraPadding + /> + ) +}) + +GlobalPublishedPerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem' diff --git a/packages/sanity/src/core/releases/navbar/GlobalReleasePerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/GlobalReleasePerspectiveMenuItem.tsx new file mode 100644 index 00000000000..c162a4ccec3 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/GlobalReleasePerspectiveMenuItem.tsx @@ -0,0 +1,93 @@ +// eslint-disable-next-line no-restricted-imports -- custom use for MenuItem & Button not supported by ui-components +import {forwardRef, type MouseEvent, useCallback, useMemo} from 'react' + +import {useTranslation} from '../../i18n/hooks/useTranslation' +import {useStudioPerspectiveState} from '../hooks/useStudioPerspectiveState' +import {type ReleaseDocument} from '../store/types' +import {getReleaseTone} from '../util/getReleaseTone' +import {getReleaseIdFromReleaseDocumentId} from '../util/releaseId' +import {isPublishedPerspective, isReleaseScheduledOrScheduling} from '../util/util' +import {PerspectiveMenuItem} from './PerspectiveMenuItem' + +export interface LayerRange { + firstIndex: number + lastIndex: number + offsets: { + asap: number + scheduled: number + undecided: number + } +} + +type rangePosition = 'first' | 'within' | 'last' | undefined + +export function getRangePosition(range: LayerRange, index: number): rangePosition { + const {firstIndex, lastIndex} = range + + if (firstIndex === lastIndex) return undefined + if (index === firstIndex) return 'first' + if (index === lastIndex) return 'last' + if (index > firstIndex && index < lastIndex) return 'within' + + return undefined +} + +export const GlobalReleasePerspectiveMenuItem = forwardRef< + HTMLDivElement, + { + release: ReleaseDocument + rangePosition: rangePosition + } +>((props, ref) => { + const {release, rangePosition} = props + const {current, setCurrent, toggle, excluded} = useStudioPerspectiveState() + + const isReleasePublishedPerspective = current && isPublishedPerspective(current) + const isUnnamedRelease = !isReleasePublishedPerspective && !release.metadata.title + + const active = getReleaseIdFromReleaseDocumentId(release._id) === current + const first = rangePosition === 'first' + const within = rangePosition === 'within' + const last = rangePosition === 'last' + const inRange = first || within || last + + const releaseId = getReleaseIdFromReleaseDocumentId(release._id) + + 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<HTMLDivElement>) => { + event.stopPropagation() + toggle(releaseId) + }, + [toggle, releaseId], + ) + + const handleOnReleaseClick = useCallback(() => setCurrent(releaseId), [releaseId, setCurrent]) + + const canReleaseBeExcluded = !isReleaseScheduledOrScheduling(release) && inRange && !last + + return ( + <PerspectiveMenuItem + canBeExcluded={canReleaseBeExcluded} + onToggleVisibility={handleToggleReleaseVisibility} + rangePosition={rangePosition} + tone={getReleaseTone(release)} + title={displayTitle} + onClick={handleOnReleaseClick} + active={active} + excluded={excluded.includes(releaseId)} + locked={isReleaseScheduledOrScheduling(release)} + /> + ) +}) + +GlobalReleasePerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem' diff --git a/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx index f195d7b6ea3..d602bd5f1aa 100644 --- a/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx +++ b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx @@ -10,9 +10,9 @@ export const GlobalPerspectiveMenuItemIndicator = styled.div<{ $inRange: boolean $last: boolean $first: boolean - $isPublished: boolean + $extraPadding?: boolean }>( - ({$inRange, $last, $first, $isPublished}) => css` + ({$inRange, $last, $first, $extraPadding}) => css` position: relative; --indicator-left: ${INDICATOR_LEFT_OFFSET}px; @@ -32,7 +32,7 @@ export const GlobalPerspectiveMenuItemIndicator = styled.div<{ left: var(--indicator-left); bottom: -var(--indicator-bottom); width: var(--indicator-width); - height: ${$isPublished + height: ${$extraPadding ? 'calc(var(--indicator-bottom) + 12px)' : 'var(--indicator-bottom)'}; background-color: var(--indicator-color); diff --git a/packages/sanity/src/core/releases/navbar/PerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/PerspectiveMenuItem.tsx new file mode 100644 index 00000000000..8d7f548baf6 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/PerspectiveMenuItem.tsx @@ -0,0 +1,172 @@ +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 {type BadgeTone, Box, Button, Flex, MenuItem, Stack, Text} from '@sanity/ui' +import {type CSSProperties, forwardRef, type MouseEvent} 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 {GlobalPerspectiveMenuItemIndicator} from './PerspectiveLayerIndicator' + +export interface LayerRange { + firstIndex: number + 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 = () => ( + <Box padding={3}> + <Text size={1}> + <DotIcon + style={ + { + opacity: 0, + } as CSSProperties + } + /> + </Text> + </Box> +) + +type rangePosition = 'first' | 'within' | 'last' | undefined + +export function getRangePosition(range: LayerRange, index: number): rangePosition { + const {firstIndex, lastIndex} = range + + if (firstIndex === lastIndex) return undefined + if (index === firstIndex) return 'first' + if (index === lastIndex) return 'last' + if (index > firstIndex && index < lastIndex) return 'within' + + return undefined +} + +export const PerspectiveMenuItem = forwardRef< + HTMLDivElement, + { + title: string + canBeExcluded: boolean + active?: boolean + locked?: boolean + date?: Date + tone: BadgeTone + excluded?: boolean + extraPadding?: boolean + rangePosition: rangePosition + onToggleVisibility: (event: MouseEvent<HTMLDivElement>) => void + onClick: () => void + } +>((props, ref) => { + const { + canBeExcluded, + locked, + date, + rangePosition, + active, + tone, + title, + excluded, + extraPadding, + onToggleVisibility, + onClick, + } = props + + const first = rangePosition === 'first' + const within = rangePosition === 'within' + const last = rangePosition === 'last' + const inRange = first || within || last + + const {t} = useTranslation() + + return ( + <GlobalPerspectiveMenuItemIndicator + $extraPadding={extraPadding} + $first={first} + $last={last} + $inRange={inRange} + ref={ref} + > + <MenuItem onClick={onClick} padding={1} pressed={active}> + <Flex align="flex-start" gap={1}> + <Box + flex="none" + style={{ + position: 'relative', + zIndex: 1, + }} + > + <Text size={1}>{excluded ? <ExcludedLayerDot /> : <ReleaseAvatar tone={tone} />}</Text> + </Box> + <Stack + flex={1} + paddingY={2} + paddingRight={2} + space={2} + style={{ + opacity: excluded ? 0.5 : undefined, + }} + > + <Text size={1} weight="medium"> + {title} + </Text> + {date && ( + <Text muted size={1}> + {formatRelativeLocale(date, new Date())} + </Text> + )} + </Stack> + <Box flex="none"> + {canBeExcluded && ( + <Tooltip portal content={t('release.layer.hide')} placement="bottom"> + <ToggleLayerButton + $visible={!excluded} + forwardedAs="div" + icon={excluded ? EyeClosedIcon : EyeOpenIcon} + mode="bleed" + onClick={onToggleVisibility} + padding={2} + /> + </Tooltip> + )} + {locked && ( + <Box padding={2}> + <Text size={1}> + <LockIcon /> + </Text> + </Box> + )} + </Box> + </Flex> + </MenuItem> + </GlobalPerspectiveMenuItemIndicator> + ) +}) + +PerspectiveMenuItem.displayName = 'GlobalPerspectiveMenuItem' diff --git a/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx index 4d632305e58..bf709b1e4f1 100644 --- a/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx +++ b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx @@ -2,13 +2,13 @@ import {Flex, Label} from '@sanity/ui' import {useCallback} from 'react' import {useTranslation} from '../../i18n/hooks/useTranslation' -import {usePerspective} from '../hooks/usePerspective' +import {useStudioPerspectiveState} from '../hooks/useStudioPerspectiveState' import {type ReleaseDocument, type ReleaseType} from '../store/types' import { getRangePosition, - GlobalPerspectiveMenuItem, + GlobalReleasePerspectiveMenuItem, type LayerRange, -} from './GlobalPerspectiveMenuItem' +} from './GlobalReleasePerspectiveMenuItem' import {GlobalPerspectiveMenuLabelIndicator} from './PerspectiveLayerIndicator' import {type ScrollElement} from './useScrollIndicatorVisibility' @@ -30,14 +30,14 @@ export function ReleaseTypeMenuSection({ currentGlobalBundleMenuItemRef: React.RefObject<ScrollElement> }): JSX.Element | null { const {t} = useTranslation() - const {currentGlobalBundleId} = usePerspective() + const {current} = useStudioPerspectiveState() const getMenuItemRef = useCallback( (releaseId: string) => - releaseId === currentGlobalBundleId + releaseId === current ? (currentGlobalBundleMenuItemRef as React.RefObject<HTMLDivElement>) : undefined, - [currentGlobalBundleId, currentGlobalBundleMenuItemRef], + [current, currentGlobalBundleMenuItemRef], ) if (releases.length === 0) return null @@ -59,7 +59,7 @@ export function ReleaseTypeMenuSection({ </GlobalPerspectiveMenuLabelIndicator> <Flex direction="column" gap={1}> {releases.map((release, index) => ( - <GlobalPerspectiveMenuItem + <GlobalReleasePerspectiveMenuItem release={release} key={release._id} ref={getMenuItemRef(release._id)} diff --git a/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx index be8cd8ca344..6f417087a9b 100644 --- a/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx +++ b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx @@ -9,11 +9,11 @@ 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 {useStudioPerspectiveState} from '../hooks/useStudioPerspectiveState' import {RELEASES_INTENT, RELEASES_TOOL_NAME} from '../plugin' -import {LATEST} from '../util/const' import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId' import {getReleaseTone} from '../util/getReleaseTone' +import {PUBLISHED_PERSPECTIVE} from '../util/perspective' import {isDraftPerspective, isPublishedPerspective} from '../util/util' import {GlobalPerspectiveMenu} from './GlobalPerspectiveMenu' @@ -37,10 +37,10 @@ export function ReleasesNav(): JSX.Element { ), ) - const {currentGlobalBundle, setPerspective} = usePerspective() + const {currentGlobalRelease, current, setCurrent} = useStudioPerspectiveState() const {t} = useTranslation() - const handleClearPerspective = () => setPerspective(LATEST._id) + const handleClearPerspective = () => setCurrent(PUBLISHED_PERSPECTIVE) const releasesToolLink = useMemo( () => ( @@ -62,21 +62,21 @@ export function ReleasesNav(): JSX.Element { ) const currentGlobalPerspectiveLabel = useMemo(() => { - if (!currentGlobalBundle || isDraftPerspective(currentGlobalBundle)) return null + if (!currentGlobalRelease || isDraftPerspective(current)) return null let displayTitle - if (isPublishedPerspective(currentGlobalBundle)) { + if (isPublishedPerspective(current)) { displayTitle = t('release.chip.published') } else { displayTitle = - currentGlobalBundle.metadata?.title || t('release.placeholder-untitled-release') + currentGlobalRelease.metadata?.title || t('release.placeholder-untitled-release') } const visibleLabelChildren = () => { const labelContent = ( <Flex align="flex-start" gap={0}> <Box flex="none"> - <ReleaseAvatar padding={2} tone={getReleaseTone(currentGlobalBundle)} /> + <ReleaseAvatar padding={2} tone={getReleaseTone(currentGlobalRelease)} /> </Box> <Stack flex={1} paddingY={2} paddingRight={2} space={2}> <Text size={1} textOverflow="ellipsis" weight="medium"> @@ -86,7 +86,7 @@ export function ReleasesNav(): JSX.Element { </Flex> ) - if (isPublishedPerspective(currentGlobalBundle)) { + if (isPublishedPerspective(current)) { return <Card tone="inherit">{labelContent}</Card> } @@ -94,7 +94,7 @@ export function ReleasesNav(): JSX.Element { <IntentLink {...intentProps} intent={RELEASES_INTENT} - params={{id: getBundleIdFromReleaseDocumentId(currentGlobalBundle._id!)}} + params={{id: getBundleIdFromReleaseDocumentId(currentGlobalRelease._id!)}} > {children} </IntentLink> @@ -116,7 +116,7 @@ export function ReleasesNav(): JSX.Element { } return <AnimatedMotionDiv>{visibleLabelChildren()}</AnimatedMotionDiv> - }, [currentGlobalBundle, t]) + }, [currentGlobalRelease, current, t]) return ( <Card flex="none" border marginRight={1} radius="full" tone="inherit" style={{margin: -1}}> @@ -124,7 +124,7 @@ export function ReleasesNav(): JSX.Element { <Box flex="none">{releasesToolLink}</Box> <AnimatePresence>{currentGlobalPerspectiveLabel}</AnimatePresence> <GlobalPerspectiveMenu /> - {!isDraftPerspective(currentGlobalBundle) && ( + {!isDraftPerspective(current) && ( <div> <Button icon={CloseIcon} diff --git a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx index b66c9df3301..58ae7d4a471 100644 --- a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx +++ b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx @@ -2,7 +2,7 @@ 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 {usePerspective} from '../../hooks/usePerspective' +import {useStudioPerspectiveState} from '../../hooks/useStudioPerspectiveState' import {LATEST} from '../../util/const' import {ReleasesNav} from '../ReleasesNav' @@ -24,7 +24,7 @@ vi.mock('../../../store/releases/useBundles', () => ({ }), })) -const mockUsePerspective = usePerspective as Mock<typeof usePerspective> +const mockUsePerspective = useStudioPerspectiveState as Mock<typeof useStudioPerspectiveState> const renderTest = async () => { const wrapper = await createTestProvider({ @@ -38,8 +38,8 @@ const mockSetPerspective = vi.fn() describe('ReleasesNav', () => { beforeEach(() => { mockUsePerspective.mockReturnValue({ - currentGlobalBundle: LATEST, - setPerspective: mockSetPerspective, + currentGlobalRelease: LATEST, + setCurrent: mockSetPerspective, }) }) @@ -66,11 +66,11 @@ describe('ReleasesNav', () => { it('should have clear button to unset perspective when a perspective is chosen', async () => { mockUsePerspective.mockReturnValue({ - currentGlobalBundle: { + currentGlobalRelease: { _id: '_.releases.a-release', metadata: {title: 'Test Release'}, }, - setPerspective: mockSetPerspective, + setCurrent: mockSetPerspective, }) await renderTest() @@ -82,13 +82,13 @@ describe('ReleasesNav', () => { it('should list the title of the chosen perspective', async () => { mockUsePerspective.mockReturnValue({ - currentGlobalBundle: { + currentGlobalRelease: { _id: '_.releases.a-release', metadata: { title: 'Test Bundle', }, }, - setPerspective: mockSetPerspective, + setCurrent: mockSetPerspective, }) await renderTest() @@ -98,11 +98,11 @@ describe('ReleasesNav', () => { it('should show release avatar for chosen perspective', async () => { mockUsePerspective.mockReturnValue({ - currentGlobalBundle: { + currentGlobalRelease: { _id: '_.releases.a-release', metadata: {title: 'Test Bundle', releaseType: 'asap'}, }, - setPerspective: mockSetPerspective, + setCurrent: mockSetPerspective, }) await renderTest() diff --git a/packages/sanity/src/core/releases/store/constants.ts b/packages/sanity/src/core/releases/store/constants.ts index 02d29f8a6ec..b168d2a846e 100644 --- a/packages/sanity/src/core/releases/store/constants.ts +++ b/packages/sanity/src/core/releases/store/constants.ts @@ -2,3 +2,14 @@ // eslint-disable-next-line @typescript-eslint/prefer-as-const export const RELEASE_DOCUMENT_TYPE: 'system.release' = 'system.release' export const RELEASE_DOCUMENTS_PATH = '_.releases' +export const PATH_SEPARATOR = '.' +export const RELEASE_DOCUMENTS_PATH_PREFIX = `${RELEASE_DOCUMENTS_PATH}${PATH_SEPARATOR}` + +/** @internal */ +export const DRAFTS_FOLDER = 'drafts' +/** @internal */ +export const VERSION_FOLDER = 'versions' +/** @internal */ +export const DRAFTS_PREFIX = `${DRAFTS_FOLDER}${PATH_SEPARATOR}` +/** @internal */ +export const VERSION_PREFIX = `${VERSION_FOLDER}${PATH_SEPARATOR}` diff --git a/packages/sanity/src/core/releases/store/index.ts b/packages/sanity/src/core/releases/store/index.ts index f8dc7aaebca..b629c6aab18 100644 --- a/packages/sanity/src/core/releases/store/index.ts +++ b/packages/sanity/src/core/releases/store/index.ts @@ -1,3 +1,4 @@ export * from './types' export * from './useReleaseOperations' export * from './useReleases' +export * from './useReleasesStack' diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts index a1fdc2b80b8..abb8902d056 100644 --- a/packages/sanity/src/core/releases/store/types.ts +++ b/packages/sanity/src/core/releases/store/types.ts @@ -2,6 +2,7 @@ import {type Dispatch} from 'react' import {type Observable} from 'rxjs' import {type PartialExcept} from '../../util' +import {type ReleaseDocumentId} from '../util/releaseId' import {RELEASE_DOCUMENT_TYPE} from './constants' import {type MetadataWrapper} from './createReleaseMetadataAggregator' import {type ReleasesReducerAction, type ReleasesReducerState} from './reducer' @@ -30,7 +31,7 @@ export interface ReleaseDocument { * typically * _.releases.<name> */ - _id: string + _id: ReleaseDocumentId _type: typeof RELEASE_DOCUMENT_TYPE _createdAt: string _updatedAt: string diff --git a/packages/sanity/src/core/releases/store/useReleases.ts b/packages/sanity/src/core/releases/store/useReleases.ts index 6a8c1ff42dd..1c778757e06 100644 --- a/packages/sanity/src/core/releases/store/useReleases.ts +++ b/packages/sanity/src/core/releases/store/useReleases.ts @@ -2,7 +2,7 @@ import {useMemo} from 'react' import {useObservable} from 'react-rx' import {sortReleases} from '../hooks/utils' -import {getBundleIdFromReleaseDocumentId} from '../util/getBundleIdFromReleaseDocumentId' +import {getReleaseIdFromReleaseDocumentId, type ReleaseId} from '../util/releaseId' import {type ReleasesReducerAction} from './reducer' import {type ReleaseDocument} from './types' import {useReleasesStore} from './useReleasesStore' @@ -15,7 +15,7 @@ interface ReleasesState { /** * Sorted array of release IDs, excluding archived releases */ - releasesIds: string[] + releasesIds: ReleaseId[] /** * Array of archived releases */ @@ -50,7 +50,7 @@ export function useReleases(): ReleasesState { [state.releases], ) const releasesIds = useMemo( - () => releasesAsArray.map((release) => getBundleIdFromReleaseDocumentId(release._id)), + () => releasesAsArray.map((release) => getReleaseIdFromReleaseDocumentId(release._id)), [releasesAsArray], ) return { diff --git a/packages/sanity/src/core/releases/store/useReleasesStack.ts b/packages/sanity/src/core/releases/store/useReleasesStack.ts new file mode 100644 index 00000000000..3bd46aa0f81 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleasesStack.ts @@ -0,0 +1,14 @@ +import {useMemo} from 'react' + +import {useStudioPerspectiveState} from '../hooks' +import {getReleasesStack} from '../hooks/utils' +import {useReleases} from './useReleases' + +export function useReleasesStack() { + const {data: releases} = useReleases() + const {current, excluded} = useStudioPerspectiveState() + return useMemo( + () => getReleasesStack({releases, current, excluded}), + [current, excluded, releases], + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx index 69dea809a6e..bbbd3555a5a 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx @@ -11,10 +11,12 @@ import { import {useCallback} from 'react' import {useTranslation} from '../../../i18n' -import {usePerspective} from '../../hooks' +import {useStudioPerspectiveState} from '../../hooks/useStudioPerspectiveState' import {releasesLocaleNamespace} from '../../i18n' import {type ReleaseDocument} from '../../index' import {getReleaseTone} from '../../util/getReleaseTone' +import {DRAFTS_PERSPECTIVE} from '../../util/perspective' +import {getReleaseIdFromReleaseDocumentId} from '../../util/releaseId' import {ReleaseDetailsEditor} from './ReleaseDetailsEditor' import {ReleaseTypePicker} from './ReleaseTypePicker' @@ -23,16 +25,17 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) { const {t: tRelease} = useTranslation(releasesLocaleNamespace) - const {currentGlobalBundleId, setPerspective, setPerspectiveFromReleaseDocumentId} = - usePerspective() + const {current, setCurrent} = useStudioPerspectiveState() + const selected = getReleaseIdFromReleaseDocumentId(_id) === current const handlePinRelease = useCallback(() => { - if (_id === currentGlobalBundleId) { - setPerspective('drafts') + if (selected) { + // toggle off + setCurrent(DRAFTS_PERSPECTIVE) } else { - setPerspectiveFromReleaseDocumentId(_id) + setCurrent(getReleaseIdFromReleaseDocumentId(_id)) } - }, [_id, currentGlobalBundleId, setPerspective, setPerspectiveFromReleaseDocumentId]) + }, [_id, selected, setCurrent]) return ( <Container width={3}> @@ -40,12 +43,12 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) { <Flex gap={1}> <Button disabled={state === 'archived' || state === 'published'} - icon={_id === currentGlobalBundleId ? PinFilledIcon : PinIcon} + icon={selected ? PinFilledIcon : PinIcon} mode="bleed" onClick={handlePinRelease} padding={2} radius="full" - selected={_id === currentGlobalBundleId} + selected={selected} space={2} text={tRelease('dashboard.details.pin-release')} tone={getReleaseTone(release)} diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx index 0f95b50cf04..c687011d809 100644 --- a/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx @@ -15,9 +15,9 @@ import { import {useTranslation} from '../../../i18n' import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' import {CreateReleaseDialog} from '../../components/dialog/CreateReleaseDialog' -import {usePerspective} from '../../hooks/usePerspective' +import {useStudioPerspectiveState} from '../../hooks/useStudioPerspectiveState' import {releasesLocaleNamespace} from '../../i18n' -import {type ReleaseDocument, useReleases} from '../../index' +import {getReleaseIdFromReleaseDocumentId, type ReleaseDocument, useReleases} from '../../index' import {type ReleasesMetadata, useReleasesMetadata} from '../../store/useReleasesMetadata' import {getReleaseTone} from '../../util/getReleaseTone' import {ReleaseMenuButton} from '../components/ReleaseMenuButton/ReleaseMenuButton' @@ -134,16 +134,19 @@ export function ReleasesOverview() { const {t} = useTranslation(releasesLocaleNamespace) const {t: tCore} = useTranslation() const {timeZone} = useTimeZone() - const {currentGlobalBundleId} = usePerspective() + const {current} = useStudioPerspectiveState() const getRowProps = useCallback( (datum: TableRelease): Partial<TableRowProps> => datum.isDeleted ? {tone: 'transparent'} : { - tone: currentGlobalBundleId === datum._id ? getReleaseTone(datum) : 'default', + tone: + current === getReleaseIdFromReleaseDocumentId(datum._id) + ? getReleaseTone(datum) + : 'default', }, - [currentGlobalBundleId], + [current], ) const scrollContainerRef = useRef<HTMLDivElement | null>(null) diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx index 3b1f40ee291..855857de9bd 100644 --- a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx @@ -9,10 +9,12 @@ import {Button, Tooltip} from '../../../../ui-components' import {RelativeTime} from '../../../components' import {Translate, useTranslation} from '../../../i18n' import {ReleaseAvatar} from '../../components/ReleaseAvatar' -import {usePerspective} from '../../hooks/usePerspective' +import {useStudioPerspectiveState} from '../../hooks/useStudioPerspectiveState' import {releasesLocaleNamespace} from '../../i18n' import {getBundleIdFromReleaseDocumentId} from '../../util/getBundleIdFromReleaseDocumentId' import {getReleaseTone} from '../../util/getReleaseTone' +import {DRAFTS_PERSPECTIVE} from '../../util/perspective' +import {getReleaseIdFromReleaseDocumentId} from '../../util/releaseId' import {getPublishDateFromRelease, isReleaseScheduledOrScheduling} from '../../util/util' import {type TableRowProps} from '../components/Table/Table' import {Headers} from '../components/Table/TableHeader' @@ -48,18 +50,17 @@ const ReleaseNameCell: Column<TableRelease>['cell'] = ({cellProps, datum: releas const router = useRouter() const {t} = useTranslation(releasesLocaleNamespace) const {t: tCore} = useTranslation() - const {currentGlobalBundleId, setPerspective, setPerspectiveFromReleaseDocumentId} = - usePerspective() + const {current, setCurrent} = useStudioPerspectiveState() const {state, _id} = release const isArchived = state === 'archived' - + const releaseId = getReleaseIdFromReleaseDocumentId(_id) const handlePinRelease = useCallback(() => { - if (_id === currentGlobalBundleId) { - setPerspective('drafts') + if (releaseId === current) { + setCurrent(DRAFTS_PERSPECTIVE) } else { - setPerspectiveFromReleaseDocumentId(_id) + setCurrent(getReleaseIdFromReleaseDocumentId(_id)) } - }, [_id, currentGlobalBundleId, setPerspective, setPerspectiveFromReleaseDocumentId]) + }, [_id, current, releaseId, setCurrent]) const cardProps: TableRowProps = release.isDeleted ? {tone: 'transparent'} @@ -70,7 +71,7 @@ const ReleaseNameCell: Column<TableRelease>['cell'] = ({cellProps, datum: releas tone: 'inherit', } - const isReleasePinned = _id === currentGlobalBundleId + const isReleasePinned = releaseId === current const pinButtonIcon = isReleasePinned ? PinFilledIcon : PinIcon const displayTitle = release.metadata.title || tCore('release.placeholder-untitled-release') @@ -96,7 +97,7 @@ const ReleaseNameCell: Column<TableRelease>['cell'] = ({cellProps, datum: releas onClick={handlePinRelease} padding={2} round - selected={_id === currentGlobalBundleId} + selected={releaseId === current} /> <Card {...cardProps} padding={2} radius={2} flex={1}> <Flex align="center" gap={2}> diff --git a/packages/sanity/src/core/releases/util/const.ts b/packages/sanity/src/core/releases/util/const.ts index f8670e8e365..58430a8bb61 100644 --- a/packages/sanity/src/core/releases/util/const.ts +++ b/packages/sanity/src/core/releases/util/const.ts @@ -1,11 +1,13 @@ /* TEMPORARY DUMMY DATA */ +import {DRAFTS_PERSPECTIVE} from './perspective' + /** * @internal */ export const LATEST = { // this exists implicitly - _id: 'drafts', + _id: DRAFTS_PERSPECTIVE, metadata: { title: 'Latest', }, diff --git a/packages/sanity/src/core/releases/util/getReleaseTone.ts b/packages/sanity/src/core/releases/util/getReleaseTone.ts index 6747b91cf58..454982ae686 100644 --- a/packages/sanity/src/core/releases/util/getReleaseTone.ts +++ b/packages/sanity/src/core/releases/util/getReleaseTone.ts @@ -1,18 +1,15 @@ import {type BadgeTone} from '@sanity/ui' import {type ReleaseDocument} from '../store/types' -import {type LATEST} from './const' -import {isDraftPerspective, isPublishedPerspective} from './util' +import {PUBLISHED_PERSPECTIVE, type SelectableReleasePerspective} from './perspective' +import {isPublishedPerspective} from './util' /** @internal */ -export function getReleaseTone(release: ReleaseDocument | 'published' | typeof LATEST): BadgeTone { +export function getReleaseTone(release: ReleaseDocument): BadgeTone { /* conflicts with the type scheduled, maybe confusion with published? if (release.publishedAt !== undefined) { return 'positive' }*/ - if (isPublishedPerspective(release)) return 'positive' - if (isDraftPerspective(release)) return 'default' - if (release.state === 'archived') { return 'default' } @@ -30,3 +27,14 @@ export function getReleaseTone(release: ReleaseDocument | 'published' | typeof L } return 'default' } + +/** @internal */ +export function getPerspectiveTone( + perspective: SelectableReleasePerspective = PUBLISHED_PERSPECTIVE, +): BadgeTone { + /* conflicts with the type scheduled, maybe confusion with published? + if (release.publishedAt !== undefined) { + return 'positive' + }*/ + return isPublishedPerspective(perspective) ? 'positive' : 'default' +} diff --git a/packages/sanity/src/core/releases/util/perspective.ts b/packages/sanity/src/core/releases/util/perspective.ts new file mode 100644 index 00000000000..0fa5e790ec9 --- /dev/null +++ b/packages/sanity/src/core/releases/util/perspective.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ +import {type Brand, make} from 'ts-brand' + +import {ReleaseId} from './releaseId' + +export type DraftsPerspective = Brand<'drafts', 'draftsPerspective'> +export type PublishedPerspective = Brand<'published', 'publishedPerspective'> +/** @internal */ +export const DRAFTS_PERSPECTIVE = 'drafts' as DraftsPerspective +/** @internal */ +export const PUBLISHED_PERSPECTIVE = 'published' as PublishedPerspective + +export type ReleasePerspective = DraftsPerspective | PublishedPerspective | ReleaseId +export type SelectableReleasePerspective = PublishedPerspective | ReleaseId + +export const SelectableReleasePerspective = make<SelectableReleasePerspective>((id) => { + return id === DRAFTS_PERSPECTIVE ? id : ReleaseId(id) +}) + +export const ReleasePerspective = make<ReleasePerspective>((id) => { + return id === DRAFTS_PERSPECTIVE || id === PUBLISHED_PERSPECTIVE ? id : ReleaseId(id) +}) diff --git a/packages/sanity/src/core/releases/util/releaseId.ts b/packages/sanity/src/core/releases/util/releaseId.ts new file mode 100644 index 00000000000..15f7b93e04a --- /dev/null +++ b/packages/sanity/src/core/releases/util/releaseId.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ +import {type DocumentId, isVersionId} from '@sanity/id-utils' +import {customAlphabet} from 'nanoid' +import {type Brand, make} from 'ts-brand' + +import {getPublishedId} from '../../util' +import { + PATH_SEPARATOR, + RELEASE_DOCUMENTS_PATH, + RELEASE_DOCUMENTS_PATH_PREFIX, + VERSION_PREFIX, +} from '../store/constants' + +/** + * @internal + */ +export type ReleaseId = Brand<string, 'releaseId'> +/** + * @internal + */ +export type ReleaseDocumentId = Brand<string, 'releaseDocumentId'> + +/** + * @internal + */ +export const ReleaseId = make<ReleaseId>((input: string) => { + validateBundleId(input) + return input +}) +/** + * @internal + */ +export const ReleaseDocumentId = make<ReleaseDocumentId>((input: string) => { + validateReleaseDocumentId(input) + return input +}) + +function validateBundleId(input: string) { + // todo: consider validation here + //if (!input.startsWith('r') || input.length !== 9) { + // throw new Error( + // `'Invalid release id "${input}" – Release Ids should be 9 characters and start with 'r'`, + // ) + // } +} + +function validateReleaseDocumentId(input: string) { + if (!input.startsWith(RELEASE_DOCUMENTS_PATH_PREFIX)) { + throw new Error( + `'Invalid release document id "${input}" – Release document Ids must be in the ${RELEASE_DOCUMENTS_PATH} path`, + ) + } + validateBundleId(input.slice(RELEASE_DOCUMENTS_PATH_PREFIX.length)) +} + +/** + * ~24 years (or 7.54e+8 seconds) needed, in order to have a 1% probability of at least one collision if 10 ID's are generated every hour. + */ +const _generateReleaseId = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 8, +) + +export function generateReleaseId() { + return ReleaseId(_generateReleaseId()) +} + +/** + * Create a unique release id. This is used as the release id for documents included in the release. + * @internal + */ +export function generateReleaseDocumentId() { + return ReleaseDocumentId(`${RELEASE_DOCUMENTS_PATH}.r${_generateReleaseId()}`) +} + +/** + * @internal + * @param releaseId - the document id of the release + */ +export function getReleaseIdFromReleaseDocumentId(releaseId: ReleaseDocumentId) { + return ReleaseId(releaseId.slice(RELEASE_DOCUMENTS_PATH_PREFIX.length)) +} + +/** @internal */ +export function getVersionId(id: DocumentId, release: ReleaseId): string { + if (isVersionId(id)) { + const [, versionId, ...publishedId] = id.split(PATH_SEPARATOR) + if (versionId === release) return id + return `${VERSION_PREFIX}${release}${PATH_SEPARATOR}${publishedId}` + } + + const publishedId = getPublishedId(id) + + return `${VERSION_PREFIX}${release}${PATH_SEPARATOR}${publishedId}` +} diff --git a/packages/sanity/src/core/releases/util/util.ts b/packages/sanity/src/core/releases/util/util.ts index 078ef6f92fe..6d9ab5078c0 100644 --- a/packages/sanity/src/core/releases/util/util.ts +++ b/packages/sanity/src/core/releases/util/util.ts @@ -5,10 +5,18 @@ import { isVersionId, resolveBundlePerspective, } from '../../util' -import {type CurrentPerspective} from '../hooks/usePerspective' -import {type VersionOriginTypes} from '../index' +import { + type ReleasePerspective, + type SelectableReleasePerspective, + type VersionOriginTypes, +} from '../index' import {type ReleaseDocument} from '../store/types' -import {LATEST} from './const' +import { + DRAFTS_PERSPECTIVE, + type DraftsPerspective, + PUBLISHED_PERSPECTIVE, + type PublishedPerspective, +} from './perspective' /** * @beta @@ -76,13 +84,17 @@ export function getPublishDateFromRelease(release: ReleaseDocument): Date { } /** @internal */ -export function isPublishedPerspective(bundle: CurrentPerspective | string): bundle is 'published' { - return bundle === 'published' +export function isPublishedPerspective( + perspective: SelectableReleasePerspective, +): perspective is PublishedPerspective { + return perspective === PUBLISHED_PERSPECTIVE } /** @internal */ -export function isDraftPerspective(bundle: CurrentPerspective | string): bundle is typeof LATEST { - return bundle === LATEST +export function isDraftPerspective( + perspective: ReleasePerspective | string, +): perspective is DraftsPerspective { + return perspective === DRAFTS_PERSPECTIVE } /** @internal */ diff --git a/packages/sanity/src/core/studio/components/navbar/StudioNavbar.tsx b/packages/sanity/src/core/studio/components/navbar/StudioNavbar.tsx index 53f0222e966..a7fd9063be0 100644 --- a/packages/sanity/src/core/studio/components/navbar/StudioNavbar.tsx +++ b/packages/sanity/src/core/studio/components/navbar/StudioNavbar.tsx @@ -19,7 +19,7 @@ import {Button, TooltipDelayGroupProvider} from '../../../../ui-components' import {type NavbarProps} from '../../../config/studio/types' import {isDev} from '../../../environment' import {useTranslation} from '../../../i18n' -import {usePerspective} from '../../../releases' +import {getPerspectiveTone, useCurrentRelease, useStudioPerspectiveState} from '../../../releases' import {getReleaseTone} from '../../../releases/util/getReleaseTone' import {useToolMenuComponent} from '../../studio-components-hooks' import {useWorkspace} from '../../workspace' @@ -85,7 +85,8 @@ export function StudioNavbar(props: Omit<NavbarProps, 'renderDefault'>) { searchOpen, } = useContext(NavbarContext) - const {currentGlobalBundle} = usePerspective() + const {current} = useStudioPerspectiveState() + const currentGlobalRelease = useCurrentRelease() const ToolMenu = useToolMenuComponent() @@ -189,7 +190,11 @@ export function StudioNavbar(props: Omit<NavbarProps, 'renderDefault'>) { <FreeTrialProvider> <RootLayer zOffset={100} data-search-open={searchFullscreenOpen}> <RootCard - tone={getReleaseTone(currentGlobalBundle)} + tone={ + currentGlobalRelease + ? getReleaseTone(currentGlobalRelease) + : getPerspectiveTone(current) + } borderBottom data-testid="studio-navbar" data-ui="Navbar" diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ReferencePreviewTitle.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ReferencePreviewTitle.tsx index d43381032c0..a9d59daaf60 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ReferencePreviewTitle.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/filters/common/ReferencePreviewTitle.tsx @@ -16,7 +16,7 @@ export function ReferencePreviewTitle({ const documentPreviewStore = useDocumentPreviewStore() const observable = useMemo( - () => getPreviewStateObservable(documentPreviewStore, schemaType, documentId, ''), + () => getPreviewStateObservable(documentPreviewStore, schemaType, documentId), [documentId, documentPreviewStore, schemaType], ) const {draft, published, isLoading} = useObservable(observable, { diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/searchResults/item/SearchResultItemPreview.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/searchResults/item/SearchResultItemPreview.tsx index 00dffc26b09..4c955ef7cde 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/searchResults/item/SearchResultItemPreview.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/searchResults/item/SearchResultItemPreview.tsx @@ -3,7 +3,7 @@ import {type SchemaType} from '@sanity/types' import {Badge, Box, Flex} from '@sanity/ui' import {useMemo} from 'react' import {useObservable} from 'react-rx' -import {getPublishedId, useReleases} from 'sanity' +import {getPublishedId, getReleaseIdFromReleaseDocumentId, useReleases} from 'sanity' import {styled} from 'styled-components' import {type GeneralPreviewLayoutKey} from '../../../../../../../components' @@ -15,7 +15,6 @@ import { getPreviewValueWithFallback, SanityDefaultPreview, } from '../../../../../../../preview' -import {usePerspective} from '../../../../../../../releases/hooks/usePerspective' import {type DocumentPresence, useDocumentPreviewStore} from '../../../../../../../store' interface SearchResultItemPreviewProps { @@ -51,16 +50,17 @@ export function SearchResultItemPreview({ showBadge = true, }: SearchResultItemPreviewProps) { const documentPreviewStore = useDocumentPreviewStore() - const releases = useReleases() - const {bundlesPerspective} = usePerspective() + const {loading: releasesIsLoading, data: releases} = useReleases() const observable = useMemo( () => - getPreviewStateObservable(documentPreviewStore, schemaType, getPublishedId(documentId), '', { - bundleIds: releases.releasesIds, - bundleStack: bundlesPerspective, - }), - [documentPreviewStore, schemaType, documentId, releases.releasesIds, bundlesPerspective], + getPreviewStateObservable( + documentPreviewStore, + schemaType, + getPublishedId(documentId), + releases.map((release) => getReleaseIdFromReleaseDocumentId(release._id)), + ), + [documentPreviewStore, schemaType, documentId, releases], ) const { @@ -77,7 +77,7 @@ export function SearchResultItemPreview({ versions: {}, }) - const isLoading = previewIsLoading || releases.loading + const isLoading = previewIsLoading || releasesIsLoading const sanityDocument = useMemo(() => { return { diff --git a/packages/sanity/src/core/studio/components/navbar/search/contexts/search/SearchProvider.tsx b/packages/sanity/src/core/studio/components/navbar/search/contexts/search/SearchProvider.tsx index 616b2435e5f..d65c8dc98b4 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/contexts/search/SearchProvider.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/contexts/search/SearchProvider.tsx @@ -4,7 +4,7 @@ import {SearchContext} from 'sanity/_singletons' import {type CommandListHandle} from '../../../../../../components' import {useSchema} from '../../../../../../hooks' -import {usePerspective} from '../../../../../../releases/hooks/usePerspective' +import {useStudioPerspectiveState} from '../../../../../../releases/hooks/useStudioPerspectiveState' import {type SearchTerms} from '../../../../../../search' import {useCurrentUser} from '../../../../../../store' import {resolvePerspectiveOptions} from '../../../../../../util/resolvePerspective' @@ -32,7 +32,7 @@ interface SearchProviderProps { export function SearchProvider({children, fullscreen}: SearchProviderProps) { const [onClose, setOnClose] = useState<(() => void) | null>(null) const [searchCommandList, setSearchCommandList] = useState<CommandListHandle | null>(null) - const {bundlesPerspective} = usePerspective() + const {bundlesPerspective} = useStudioPerspectiveState() const schema = useSchema() const currentUser = useCurrentUser() const { diff --git a/packages/sanity/src/core/tasks/hooks/useDocumentPreviewValues.ts b/packages/sanity/src/core/tasks/hooks/useDocumentPreviewValues.ts index 2f5ff558094..d7b4ed0cdea 100644 --- a/packages/sanity/src/core/tasks/hooks/useDocumentPreviewValues.ts +++ b/packages/sanity/src/core/tasks/hooks/useDocumentPreviewValues.ts @@ -26,7 +26,7 @@ export function useDocumentPreviewValues(options: PreviewHookOptions): PreviewHo const previewStateObservable = useMemo(() => { if (!documentId || !schemaType) return of(null) - return getPreviewStateObservable(documentPreviewStore, schemaType, documentId, '') + return getPreviewStateObservable(documentPreviewStore, schemaType, documentId) }, [documentId, documentPreviewStore, schemaType]) const previewState = useObservable(previewStateObservable) diff --git a/packages/sanity/src/structure/components/paneHeaderActions/PaneHeaderCreateButton.tsx b/packages/sanity/src/structure/components/paneHeaderActions/PaneHeaderCreateButton.tsx index 464c4fe39d4..e10868f2b76 100644 --- a/packages/sanity/src/structure/components/paneHeaderActions/PaneHeaderCreateButton.tsx +++ b/packages/sanity/src/structure/components/paneHeaderActions/PaneHeaderCreateButton.tsx @@ -3,15 +3,14 @@ import {type Schema} from '@sanity/types' import {Menu} from '@sanity/ui' import {type ComponentProps, type ForwardedRef, forwardRef, useMemo} from 'react' import { - getBundleIdFromReleaseDocumentId, type InitialValueTemplateItem, + isDraftPerspective, isPublishedPerspective, - LATEST, type Template, type TemplatePermissionsResult, useGetI18nText, - usePerspective, useSchema, + useStudioPerspectiveState, useTemplatePermissions, useTemplates, useTranslation, @@ -37,23 +36,22 @@ const getIntent = ( item: InitialValueTemplateItem, version?: string, ): PaneHeaderIntentProps | null => { - const isBundleIntent = version && version !== LATEST._id && !isPublishedPerspective(version) + const isReleaseIntent = + version && !isDraftPerspective(version) && !isPublishedPerspective(version) const typeName = templates.find((t) => t.id === item.templateId)?.schemaType if (!typeName) return null const baseParams = { template: item.templateId, type: typeName, - version: isBundleIntent ? version : undefined, + version: isReleaseIntent ? version : undefined, id: item.initialDocumentId, } return { type: 'create', params: item.parameters ? [baseParams, item.parameters] : baseParams, - searchParams: isBundleIntent - ? [['perspective', `bundle.${getBundleIdFromReleaseDocumentId(version)}`]] - : undefined, + searchParams: isReleaseIntent ? [['perspective', version]] : undefined, } } @@ -64,7 +62,7 @@ interface PaneHeaderCreateButtonProps { export function PaneHeaderCreateButton({templateItems}: PaneHeaderCreateButtonProps) { const schema = useSchema() const templates = useTemplates() - const {currentGlobalBundleId} = usePerspective() + const {current} = useStudioPerspectiveState() const {t} = useTranslation(structureLocaleNamespace) const getI18nText = useGetI18nText([...templateItems, ...templates]) @@ -115,7 +113,7 @@ export function PaneHeaderCreateButton({templateItems}: PaneHeaderCreateButtonPr const firstItem = templateItems[0] const permissions = permissionsById[firstItem.id] const disabled = !permissions?.granted - const intent = getIntent(schema, templates, firstItem, currentGlobalBundleId) + const intent = getIntent(schema, templates, firstItem, current) if (!intent) return null return ( @@ -153,7 +151,7 @@ export function PaneHeaderCreateButton({templateItems}: PaneHeaderCreateButtonPr {templateItems.map((item, itemIndex) => { const permissions = permissionsById[item.id] const disabled = !permissions?.granted - const intent = getIntent(schema, templates, item, currentGlobalBundleId) + const intent = getIntent(schema, templates, item, current) const template = templates.find((i) => i.id === item.templateId) if (!template || !intent) return null diff --git a/packages/sanity/src/structure/components/paneItem/PaneItemPreview.tsx b/packages/sanity/src/structure/components/paneItem/PaneItemPreview.tsx index d427adf48aa..a8779431abc 100644 --- a/packages/sanity/src/structure/components/paneItem/PaneItemPreview.tsx +++ b/packages/sanity/src/structure/components/paneItem/PaneItemPreview.tsx @@ -1,7 +1,6 @@ import {type SanityDocument, type SchemaType} from '@sanity/types' import {Flex} from '@sanity/ui' -import {isNumber, isString} from 'lodash' -import {type ComponentType, isValidElement, useMemo} from 'react' +import {type ComponentType, useMemo} from 'react' import {useObservable} from 'react-rx' import { type DocumentPresence, @@ -12,10 +11,9 @@ import { type GeneralPreviewLayoutKey, getPreviewStateObservable, getPreviewValueWithFallback, - isRecord, SanityDefaultPreview, - usePerspective, useReleases, + useStudioPerspectiveState, } from 'sanity' import {TooltipDelayGroupProvider} from '../../../ui-components' @@ -38,29 +36,18 @@ export interface PaneItemPreviewProps { */ export function PaneItemPreview(props: PaneItemPreviewProps) { const {icon, layout, presence, schemaType, value} = props - const title = - (isRecord(value.title) && isValidElement(value.title)) || - isString(value.title) || - isNumber(value.title) - ? value.title - : null const releases = useReleases() - const {bundlesPerspective, perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const previewStateObservable = useMemo( () => - getPreviewStateObservable(props.documentPreviewStore, schemaType, value._id, title, { - bundleIds: releases.releasesIds, - bundleStack: bundlesPerspective, - }), - [ - props.documentPreviewStore, - schemaType, - value._id, - title, - releases.releasesIds, - bundlesPerspective, - ], + getPreviewStateObservable( + props.documentPreviewStore, + schemaType, + value._id, + releases.releasesIds, + ), + [props.documentPreviewStore, schemaType, value._id, releases.releasesIds], ) const { @@ -75,7 +62,7 @@ export function PaneItemPreview(props: PaneItemPreviewProps) { published: null, version: null, versions: {}, - perspective, + current: current, }) const isLoading = previewIsLoading || releases.loading @@ -93,7 +80,7 @@ export function PaneItemPreview(props: PaneItemPreviewProps) { return ( <SanityDefaultPreview - {...getPreviewValueWithFallback({value, draft, published, version, perspective})} + {...getPreviewValueWithFallback({value, draft, published, version})} isPlaceholder={isLoading} icon={icon} layout={layout} diff --git a/packages/sanity/src/structure/components/structureTool/StructureTitle.tsx b/packages/sanity/src/structure/components/structureTool/StructureTitle.tsx index 5941df863bc..dc20dc5c348 100644 --- a/packages/sanity/src/structure/components/structureTool/StructureTitle.tsx +++ b/packages/sanity/src/structure/components/structureTool/StructureTitle.tsx @@ -4,8 +4,8 @@ import { resolveBundlePerspective, unstable_useValuePreview as useValuePreview, useEditState, - usePerspective, useSchema, + useStudioPerspectiveState, useTranslation, } from 'sanity' @@ -22,13 +22,13 @@ interface StructureTitleProps { // TODO: Fix state jank when editing different versions inside panes. const DocumentTitle = (props: {documentId: string; documentType: string}) => { const {documentId, documentType} = props - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const editState = useEditState( documentId, documentType, 'default', - resolveBundlePerspective(perspective), + resolveBundlePerspective(current), ) const schema = useSchema() const {t} = useTranslation(structureLocaleNamespace) diff --git a/packages/sanity/src/structure/panes/document/DocumentPane.tsx b/packages/sanity/src/structure/panes/document/DocumentPane.tsx index ed3cad8f7b9..5cc53198830 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPane.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPane.tsx @@ -8,8 +8,8 @@ import { SourceProvider, Translate, useDocumentType, - usePerspective, useSource, + useStudioPerspectiveState, useTemplatePermissions, useTemplates, useTranslation, @@ -45,7 +45,7 @@ export const DocumentPane = memo(function DocumentPane(props: DocumentPaneProvid function DocumentPaneInner(props: DocumentPaneProviderProps) { const {pane, paneKey} = props const {resolveNewDocumentOptions} = useSource().document - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const paneRouter = usePaneRouter() const options = usePaneOptions(pane.options, paneRouter.params) const {documentType, isLoaded: isDocumentLoaded} = useDocumentType(options.id, options.type) @@ -132,7 +132,7 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) { <DocumentPaneProvider // this needs to be here to avoid formState from being re-used across (incompatible) document types // see https://github.com/sanity-io/sanity/discussions/3794 for a description of the problem - key={`${documentType}-${options.id}-${perspective || ''}`} + key={`${documentType}-${options.id}-${current || ''}`} {...providerProps} > {/* NOTE: this is a temporary location for this provider until we */} diff --git a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx index ffbeb7bf329..fcae5db68a9 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx @@ -33,16 +33,17 @@ import { toMutationPatches, useConnectionState, useCopyPaste, + useCurrentRelease, useDocumentOperation, useDocumentValuePermissions, useDocumentVersions, useEditState, useFormState, useInitialValue, - usePerspective, usePresenceStore, useSchema, useSource, + useStudioPerspectiveState, useTemplates, useTimelineSelector, useTimelineStore, @@ -103,9 +104,10 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { const documentId = getPublishedId(documentIdRaw) const documentType = options.type const params = useUnique(paneRouter.params) || EMPTY_PARAMS - const {perspective, currentGlobalBundle} = usePerspective() + const {current} = useStudioPerspectiveState() + const currentGlobalRelease = useCurrentRelease() - const bundlePerspective = resolveBundlePerspective(perspective) + const bundlePerspective = resolveBundlePerspective(current) /* Version and the global perspective should match. * If user clicks on add document, and then switches to another version, he should click again on create document. @@ -150,7 +152,7 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { case typeof bundlePerspective !== 'undefined': value = editState.version || editState.draft || editState.published || value break - case perspective === 'published': + case current === 'published': value = editState.published || editState.draft || value break default: @@ -172,7 +174,7 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { case Boolean(params): version = 'revision' break - case perspective === 'published': + case current === 'published': version = 'published' break default: @@ -180,7 +182,7 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { } return version - }, [bundlePerspective, params, perspective, value._id]) + }, [bundlePerspective, params, current, value._id]) const actionsPerspective = useMemo(() => getDocumentPerspective(), [getDocumentPerspective]) @@ -598,11 +600,11 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { // in cases where the document has drafts but the schema is live edit, // there is a risk of data loss, so we disable editing in this case const isLiveEditAndDraft = Boolean(liveEdit && editState.draft) - const isSystemPerspectiveApplied = perspective && typeof bundlePerspective === 'undefined' + const isSystemPerspectiveApplied = current && typeof bundlePerspective === 'undefined' const isReleaseLocked = - typeof currentGlobalBundle === 'object' && 'state' in currentGlobalBundle - ? isReleaseScheduledOrScheduling(currentGlobalBundle) + typeof currentGlobalRelease === 'object' && 'state' in currentGlobalRelease + ? isReleaseScheduledOrScheduling(currentGlobalRelease) : false return ( @@ -630,9 +632,9 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { editState.transactionSyncLock?.enabled, editState.draft, liveEdit, - perspective, + current, bundlePerspective, - currentGlobalBundle, + currentGlobalRelease, existsInBundle, ready, revTime, diff --git a/packages/sanity/src/structure/panes/document/comments/CommentsWrapper.tsx b/packages/sanity/src/structure/panes/document/comments/CommentsWrapper.tsx index 5965b43b748..baade00b928 100644 --- a/packages/sanity/src/structure/panes/document/comments/CommentsWrapper.tsx +++ b/packages/sanity/src/structure/panes/document/comments/CommentsWrapper.tsx @@ -5,7 +5,7 @@ import { CommentsProvider, getVersionId, useCommentsEnabled, - usePerspective, + useStudioPerspectiveState, } from 'sanity' import {usePaneRouter} from '../../../components' @@ -40,7 +40,7 @@ function CommentsProviderWrapper(props: CommentsWrapperProps) { const {enabled} = useCommentsEnabled() const {connectionState, onPathOpen, inspector, openInspector, version} = useDocumentPane() const {params, setParams, createPathWithParams} = usePaneRouter() - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const versionOrPublishedId = useMemo( () => (version ? getVersionId(documentId, version) : documentId), [documentId, version], @@ -97,7 +97,7 @@ function CommentsProviderWrapper(props: CommentsWrapperProps) { selectedCommentId={selectedCommentId} sortOrder="desc" type="field" - perspective={perspective} + perspective={current} > {children} </CommentsProvider> diff --git a/packages/sanity/src/structure/panes/document/document-layout/useBundleDeletedToast.tsx b/packages/sanity/src/structure/panes/document/document-layout/useBundleDeletedToast.tsx index 24f49c2a6a3..4762068666f 100644 --- a/packages/sanity/src/structure/panes/document/document-layout/useBundleDeletedToast.tsx +++ b/packages/sanity/src/structure/panes/document/document-layout/useBundleDeletedToast.tsx @@ -1,31 +1,23 @@ import {Text, useToast} from '@sanity/ui' import {useEffect} from 'react' -import { - isDraftPerspective, - isPublishedPerspective, - Translate, - usePerspective, - useReleases, - useTranslation, -} from 'sanity' +import {Translate, useCurrentRelease, useReleases, useTranslation} from 'sanity' export const useBundleDeletedToast = () => { - const {currentGlobalBundle} = usePerspective() + const currentRelease = useCurrentRelease() const {data: bundles} = useReleases() const toast = useToast() const {t} = useTranslation() useEffect(() => { - if (isPublishedPerspective(currentGlobalBundle) || isDraftPerspective(currentGlobalBundle)) - return + if (!currentRelease) return - const hasCheckedOutBundleBeenArchived = currentGlobalBundle.state === 'archived' + const hasCheckedOutBundleBeenArchived = currentRelease.state === 'archived' if (hasCheckedOutBundleBeenArchived) { const { metadata: {title: deletedBundleTitle}, _id: deletedBundleId, - } = currentGlobalBundle + } = currentRelease toast.push({ id: `bundle-deleted-toast-${deletedBundleId}`, @@ -42,5 +34,5 @@ export const useBundleDeletedToast = () => { duration: 10000, }) } - }, [bundles?.length, toast, t, currentGlobalBundle]) + }, [bundles?.length, toast, t, currentRelease]) } diff --git a/packages/sanity/src/structure/panes/document/documentPanel/DocumentPanel.tsx b/packages/sanity/src/structure/panes/document/documentPanel/DocumentPanel.tsx index 94293fb61fc..078e9637197 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/DocumentPanel.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/DocumentPanel.tsx @@ -4,9 +4,9 @@ import { isDraftPerspective, isPublishedPerspective, isReleaseScheduledOrScheduling, - type ReleaseDocument, ScrollContainer, - usePerspective, + useCurrentRelease, + useStudioPerspectiveState, VirtualizerScrollInstanceProvider, } from 'sanity' import {css, styled} from 'styled-components' @@ -69,10 +69,10 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { permissions, isPermissionsLoading, existsInBundle, - documentType, } = useDocumentPane() const {collapsed: layoutCollapsed} = usePaneLayout() const {collapsed} = usePane() + const currentRelease = useCurrentRelease() const parentPortal = usePortal() const {features} = useStructureTool() const portalRef = useRef<HTMLDivElement | null>(null) @@ -140,23 +140,18 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { }, [isInspectOpen, displayed, value]) const showInspector = Boolean(!collapsed && inspector) - const {currentGlobalBundle} = usePerspective() + const {current} = useStudioPerspectiveState() const currentPerspectiveIsRelease = - !isPublishedPerspective(currentGlobalBundle) && !isDraftPerspective(currentGlobalBundle) + current && !isPublishedPerspective(current) && !isDraftPerspective(current) const isScheduledRelease = - typeof currentGlobalBundle === 'object' && 'state' in currentGlobalBundle - ? isReleaseScheduledOrScheduling(currentGlobalBundle) + typeof current === 'object' && 'state' in current + ? isReleaseScheduledOrScheduling(current) : false const banners = useMemo(() => { - if ((!existsInBundle && currentPerspectiveIsRelease) || isScheduledRelease) { - return ( - <AddToReleaseBanner - documentId={value._id} - currentRelease={currentGlobalBundle as ReleaseDocument} - /> - ) + if (currentRelease && (!existsInBundle || isScheduledRelease)) { + return <AddToReleaseBanner documentId={value._id} currentRelease={currentRelease} /> } if (activeView.type === 'form' && isLiveEdit && ready) { @@ -184,8 +179,7 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) { ) }, [ activeView.type, - currentGlobalBundle, - currentPerspectiveIsRelease, + currentRelease, displayed, documentId, existsInBundle, diff --git a/packages/sanity/src/structure/panes/document/documentPanel/banners/AddToReleaseBanner.tsx b/packages/sanity/src/structure/panes/document/documentPanel/banners/AddToReleaseBanner.tsx index f1bf0f8b56b..3684adf88a7 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/banners/AddToReleaseBanner.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/banners/AddToReleaseBanner.tsx @@ -3,11 +3,12 @@ import {Flex, Text} from '@sanity/ui' import {type CSSProperties, useCallback} from 'react' import { formatRelativeLocale, - getBundleIdFromReleaseDocumentId, + getPerspectiveTone, getPublishDateFromRelease, + getReleaseIdFromReleaseDocumentId, getReleaseTone, isReleaseScheduledOrScheduling, - LATEST, + PUBLISHED_PERSPECTIVE, type ReleaseDocument, Translate, useTranslation, @@ -25,7 +26,9 @@ export function AddToReleaseBanner({ documentId: string currentRelease: ReleaseDocument }): JSX.Element { - const tone = getReleaseTone(currentRelease ?? LATEST) + const tone = currentRelease + ? getReleaseTone(currentRelease) + : getPerspectiveTone(PUBLISHED_PERSPECTIVE) const {t} = useTranslation(structureLocaleNamespace) const {t: tCore} = useTranslation() @@ -37,7 +40,7 @@ export function AddToReleaseBanner({ const handleAddToRelease = useCallback(async () => { if (currentRelease._id) { - await createVersion(getBundleIdFromReleaseDocumentId(currentRelease._id), documentId) + await createVersion(getReleaseIdFromReleaseDocumentId(currentRelease._id), documentId) } }, [createVersion, currentRelease._id, documentId]) diff --git a/packages/sanity/src/structure/panes/document/documentPanel/banners/DeletedDocumentBanners.tsx b/packages/sanity/src/structure/panes/document/documentPanel/banners/DeletedDocumentBanners.tsx index 98bbb9d537f..0377839522d 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/banners/DeletedDocumentBanners.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/banners/DeletedDocumentBanners.tsx @@ -2,12 +2,10 @@ import {DocumentRemoveIcon, ReadOnlyIcon} from '@sanity/icons' import {Text} from '@sanity/ui' import {useCallback} from 'react' import { - isDraftPerspective, - isPublishedPerspective, type ReleaseDocument, Translate, useDocumentOperation, - usePerspective, + useStudioPerspectiveState, useTimelineSelector, useTranslation, } from 'sanity' @@ -19,14 +17,10 @@ import {Banner} from './Banner' export function DeletedDocumentBanners() { const {isDeleted, isDeleting} = useDocumentPane() - const {currentGlobalBundle} = usePerspective() + const {currentGlobalRelease} = useStudioPerspectiveState() - if ( - !isPublishedPerspective(currentGlobalBundle) && - !isDraftPerspective(currentGlobalBundle) && - currentGlobalBundle.state === 'archived' - ) { - return <ArchivedReleaseBanner release={currentGlobalBundle as ReleaseDocument} /> + if (currentGlobalRelease?.state === 'archived') { + return <ArchivedReleaseBanner release={currentGlobalRelease as ReleaseDocument} /> } if (isDeleted && !isDeleting) return <DeletedDocumentBanner /> } diff --git a/packages/sanity/src/structure/panes/document/documentPanel/banners/ReferenceChangedBanner.tsx b/packages/sanity/src/structure/panes/document/documentPanel/banners/ReferenceChangedBanner.tsx index 7e0f2b060a2..74363530d7f 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/banners/ReferenceChangedBanner.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/banners/ReferenceChangedBanner.tsx @@ -11,7 +11,7 @@ import { getPublishedId, resolveBundlePerspective, useDocumentPreviewStore, - usePerspective, + useStudioPerspectiveState, useTranslation, } from 'sanity' @@ -34,7 +34,7 @@ interface ParentReferenceInfo { export const ReferenceChangedBanner = memo(() => { const documentPreviewStore = useDocumentPreviewStore() - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const {params, groupIndex, routerPanesState, replaceCurrent, BackLink} = usePaneRouter() const routerReferenceId = routerPanesState[groupIndex]?.[0].id const parentGroup = routerPanesState[groupIndex - 1] as RouterPaneGroup | undefined @@ -46,7 +46,7 @@ export const ReferenceChangedBanner = memo(() => { }, [params?.parentRefPath]) const {t} = useTranslation(structureLocaleNamespace) - const bundlePerspective = resolveBundlePerspective(perspective) + const bundlePerspective = resolveBundlePerspective(current) /** * Loads information regarding the reference field of the parent pane. This diff --git a/packages/sanity/src/structure/panes/document/documentPanel/banners/__tests__/DeletedDocumentBanners.test.tsx b/packages/sanity/src/structure/panes/document/documentPanel/banners/__tests__/DeletedDocumentBanners.test.tsx index 0d32a522ea8..570be7bf703 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/banners/__tests__/DeletedDocumentBanners.test.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/banners/__tests__/DeletedDocumentBanners.test.tsx @@ -1,5 +1,5 @@ import {render, screen, waitFor} from '@testing-library/react' -import {LATEST, type ReleaseDocument, usePerspective, useReleases} from 'sanity' +import {LATEST, type ReleaseDocument, useReleases, useStudioPerspectiveState} from 'sanity' import {useDocumentPane} from 'sanity/structure' import {describe, expect, it, type Mock, vi} from 'vitest' @@ -25,7 +25,7 @@ vi.mock('../../../../../../core/store/_legacy/history/useTimelineSelector', () = const mockUseDocumentPane = useDocumentPane as Mock<typeof useDocumentPane> const mockUseReleases = useReleases as Mock<typeof useReleases> -const mockUsePerspective = usePerspective as Mock<typeof usePerspective> +const mockUsePerspective = useStudioPerspectiveState as Mock<typeof useStudioPerspectiveState> const renderTest = async () => { const wrapper = await createTestProvider({resources: [structureUsEnglishLocaleBundle]}) @@ -35,8 +35,8 @@ const renderTest = async () => { describe('DeletedDocumentBanners', () => { it('does not show either banner when document is not deleted', async () => { - mockUsePerspective.mockReturnValue({currentGlobalBundle: {_id: 'test'}} as ReturnType< - typeof usePerspective + mockUsePerspective.mockReturnValue({currentGlobalRelease: {_id: 'test'}} as ReturnType< + typeof useStudioPerspectiveState >) mockUseReleases.mockReturnValue({ data: [], @@ -58,8 +58,8 @@ describe('DeletedDocumentBanners', () => { it('prefers to show release deleted banner when document was in a release', async () => { const mockReleaseDocument = {_id: 'test', state: 'archived'} as ReleaseDocument - mockUsePerspective.mockReturnValue({currentGlobalBundle: mockReleaseDocument} as ReturnType< - typeof usePerspective + mockUsePerspective.mockReturnValue({currentGlobalRelease: mockReleaseDocument} as ReturnType< + typeof useStudioPerspectiveState >) mockUseReleases.mockReturnValue({ data: [mockReleaseDocument], @@ -85,9 +85,9 @@ describe('DeletedDocumentBanners', () => { const mockBundleDocument: ReleaseDocument = {_id: 'test', state: 'archived'} as ReleaseDocument mockUsePerspective.mockReturnValue({ - currentGlobalBundle: LATEST, - setPerspective: vi.fn(), - } as ReturnType<typeof usePerspective>) + currentGlobalRelease: LATEST, + setCurrent: vi.fn(), + } as ReturnType<typeof useStudioPerspectiveState>) mockUseReleases.mockReturnValue({ data: [mockBundleDocument], diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveList.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveList.tsx index 543ec821541..8ec2e7f7b0c 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveList.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveList.tsx @@ -1,21 +1,26 @@ import {Text} from '@sanity/ui' -import {memo, useCallback, useMemo} from 'react' +import {memo, useMemo} from 'react' import { formatRelativeLocale, getPublishDateFromRelease, getReleaseTone, getVersionFromId, isReleaseScheduledOrScheduling, + PUBLISHED_PERSPECTIVE, type ReleaseDocument, Translate, useDateTimeFormat, - usePerspective, useReleases, + useStudioPerspectiveState, useTranslation, VersionChip, versionDocumentExists, } from 'sanity' +import { + DRAFTS_PERSPECTIVE, + getReleaseIdFromReleaseDocumentId, +} from '../../../../../../core/releases' import {useDocumentPane} from '../../../useDocumentPane' type FilterReleases = { @@ -64,9 +69,9 @@ const TooltipContent = ({release}: {release: ReleaseDocument}) => { } export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const {t} = useTranslation() - const {setPerspective} = usePerspective() + const {setCurrent} = useStudioPerspectiveState() const dateTimeFormat = useDateTimeFormat({ dateStyle: 'medium', timeStyle: 'short', @@ -92,13 +97,6 @@ export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { ) }, [documentVersions, releases]) - const handleBundleChange = useCallback( - (bundleId: string) => () => { - setPerspective(bundleId) - }, - [setPerspective], - ) - return ( <> <VersionChip @@ -116,7 +114,9 @@ export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { </Text> } disabled={!editState?.published} - onClick={handleBundleChange('published')} + onClick={() => () => { + setCurrent(PUBLISHED_PERSPECTIVE) + }} selected={ /** the publish is selected when: * when the document displayed is a published document, but has no draft and the perspective that is @@ -124,8 +124,8 @@ export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { * when the perspective is published */ !!( - (editState?.published?._id === displayed?._id && !editState?.draft && perspective) || - perspective === 'published' + (editState?.published?._id === displayed?._id && !editState?.draft && current) || + current === 'published' ) } text={t('release.chip.published')} @@ -172,18 +172,16 @@ export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { * when the document is not published and the displayed version is draft, * when there is no draft (new document), */ - !!( - editState?.draft?._id === displayed?._id || - !perspective || - (!editState?.published && - editState?.draft && - editState?.draft?._id === displayed?._id) || - (!editState?.published && !editState?.draft) - ) + editState?.draft?._id === displayed?._id || + !current || + (!editState?.published && editState?.draft && editState?.draft?._id === displayed?._id) || + (!editState?.published && !editState?.draft) } text={t('release.chip.draft')} tone="caution" - onClick={handleBundleChange('drafts')} + onClick={() => () => { + setCurrent(DRAFTS_PERSPECTIVE) + }} contextValues={{ documentId: editState?.draft?._id || editState?.published?._id || editState?.id || '', menuReleaseId: editState?.draft?._id || editState?.published?._id || editState?.id || '', @@ -202,7 +200,9 @@ export const DocumentPerspectiveList = memo(function DocumentPerspectiveList() { key={release._id} tooltipContent={<TooltipContent release={release} />} selected={release.name === getVersionFromId(displayed?._id || '')} - onClick={handleBundleChange(release.name)} + onClick={() => () => { + setCurrent(getReleaseIdFromReleaseDocumentId(release._id)) + }} text={release.metadata.title || t('release.placeholder-untitled-release')} tone={getReleaseTone(release)} locked={isReleaseScheduledOrScheduling(release)} diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/__tests__/DocumentPerspectiveList.test.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/__tests__/DocumentPerspectiveList.test.tsx index 38d3f54a10a..b3d14be74f5 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/__tests__/DocumentPerspectiveList.test.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/__tests__/DocumentPerspectiveList.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react' import {type HTMLProps} from 'react' -import {type ReleaseDocument, usePerspective, useReleases} from 'sanity' +import {type ReleaseDocument, useReleases, useStudioPerspectiveState} from 'sanity' import {type IntentLinkProps} from 'sanity/router' import {beforeEach, describe, expect, it, type Mock, type MockedFunction, vi} from 'vitest' @@ -46,7 +46,7 @@ const mockUseDocumentPane = useDocumentPane as MockedFunction< () => Partial<DocumentPaneContextValue> > -const mockUsePerspective = usePerspective as Mock<typeof usePerspective> +const mockUsePerspective = useStudioPerspectiveState as Mock<typeof useStudioPerspectiveState> const mockUseReleases = useReleases as Mock<typeof useReleases> describe('DocumentPerspectiveList', () => { @@ -68,16 +68,16 @@ describe('DocumentPerspectiveList', () => { beforeEach(() => { vi.clearAllMocks() mockUsePerspective.mockReturnValue({ - currentGlobalBundle: mockCurrent, - setPerspective: vi.fn(), + currentGlobalRelease: mockCurrent, + setCurrent: vi.fn(), bundlesPerspective: ['drafts'], - currentGlobalBundleId: '_.releases.spring-drop', - excludedPerspectives: [], + current: '_.releases.spring-drop', + excluded: [], isPerspectiveExcluded: vi.fn().mockReturnValue(false), - perspective: undefined, - toggleExcludedPerspective: vi.fn(), + current: undefined, + toggle: vi.fn(), setPerspectiveFromReleaseDocumentId: vi.fn(), - setPerspectiveFromReleaseId: vi.fn(), + setCurrent: vi.fn(), }) mockUseDocumentPane.mockReturnValue({ diff --git a/packages/sanity/src/structure/panes/document/inspectors/validation/index.ts b/packages/sanity/src/structure/panes/document/inspectors/validation/index.ts index 93bd08349bd..b02b2c8d9b8 100644 --- a/packages/sanity/src/structure/panes/document/inspectors/validation/index.ts +++ b/packages/sanity/src/structure/panes/document/inspectors/validation/index.ts @@ -8,7 +8,7 @@ import { isValidationError, isValidationWarning, resolveBundlePerspective, - usePerspective, + useStudioPerspectiveState, useTranslation, useValidationStatus, } from 'sanity' @@ -19,11 +19,11 @@ import {ValidationInspector} from './ValidationInspector' function useMenuItem(props: DocumentInspectorUseMenuItemProps): DocumentInspectorMenuItem { const {documentId, documentType} = props const {t} = useTranslation('validation') - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const {validation: validationMarkers} = useValidationStatus( documentId, documentType, - resolveBundlePerspective(perspective), + resolveBundlePerspective(current), ) const validation: FormNodeValidation[] = useMemo( diff --git a/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusBar.tsx b/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusBar.tsx index 9101b9c8e39..cc8c55820f0 100644 --- a/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusBar.tsx +++ b/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusBar.tsx @@ -5,8 +5,8 @@ import { isDraftPerspective, isPublishedPerspective, isSanityCreateLinked, - usePerspective, useSanityCreateConfig, + useStudioPerspectiveState, useTimelineSelector, } from 'sanity' @@ -30,7 +30,7 @@ const CONTAINER_BREAKPOINT = 480 // px export function DocumentStatusBar(props: DocumentStatusBarProps) { const {actionsBoxRef, createLinkMetadata} = props const {editState, timelineStore, onChange: onDocumentChange, existsInBundle} = useDocumentPane() - const {currentGlobalBundle} = usePerspective() + const {current} = useStudioPerspectiveState() const {title} = useDocumentTitle() const CreateLinkedActions = useSanityCreateConfig().components?.documentLinkedActions @@ -64,8 +64,8 @@ export function DocumentStatusBar(props: DocumentStatusBarProps) { actions = <HistoryStatusBarActions /> } else if ( (existsInBundle && showingVersion) || - isDraftPerspective(currentGlobalBundle) || - isPublishedPerspective(currentGlobalBundle) + isDraftPerspective(current) || + isPublishedPerspective(current) ) { actions = <DocumentStatusBarActions /> } diff --git a/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusLine.tsx b/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusLine.tsx index 393421b6dd8..42b65f8354d 100644 --- a/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusLine.tsx +++ b/packages/sanity/src/structure/panes/document/statusBar/DocumentStatusLine.tsx @@ -4,17 +4,17 @@ import {useObservable} from 'react-rx' import {of} from 'rxjs' import { DocumentStatus, - getBundleIdFromReleaseDocumentId, getPreviewStateObservable, - isDraftPerspective, + getReleaseIdFromReleaseDocumentId, isPublishedPerspective, useDocumentPreviewStore, - usePerspective, useReleases, useSchema, + useStudioPerspectiveState, useSyncState, } from 'sanity' +import {useCurrentRelease} from '../../../../core/releases/hooks/useCurrentRelease' import {Tooltip} from '../../../../ui-components' import {useDocumentPane} from '../useDocumentPane' import {DocumentStatusPulse} from './DocumentStatusPulse' @@ -29,19 +29,17 @@ export function DocumentStatusLine() { const schema = useSchema() const schemaType = schema.get(documentType) const releases = useReleases() - const {currentGlobalBundle, bundlesPerspective} = usePerspective() - const previewStateObservable = useMemo( - () => - schemaType - ? getPreviewStateObservable(documentPreviewStore, schemaType, value._id, 'Untitled', { - bundleIds: (releases.data ?? []).map((release) => - getBundleIdFromReleaseDocumentId(release._id), - ), - bundleStack: bundlesPerspective, - }) - : of({versions: {}}), - [documentPreviewStore, schemaType, value._id, releases.data, bundlesPerspective], - ) + + const {current: currentPerspective} = useStudioPerspectiveState() + const currentRelease = useCurrentRelease() + const previewStateObservable = useMemo(() => { + const releaseIds = (releases.data ?? []).map((release) => + getReleaseIdFromReleaseDocumentId(release._id), + ) + return schemaType + ? getPreviewStateObservable(documentPreviewStore, schemaType, value._id, releaseIds) + : of({versions: {}}) + }, [documentPreviewStore, schemaType, value._id, releases.data]) const {versions} = useObservable(previewStateObservable, {versions: {}}) const syncState = useSyncState(documentId, documentType, {version: editState?.bundleId}) @@ -76,21 +74,17 @@ export function DocumentStatusLine() { }, [syncState.isSyncing, lastUpdated]) const getMode = () => { - if (isPublishedPerspective(currentGlobalBundle)) { - return 'published' - } - if (editState?.version) { - return 'version' - } - if (editState?.draft) { + if (!currentPerspective) { return 'draft' } - return 'published' + if (isPublishedPerspective(currentPerspective)) { + return 'published' + } + return 'version' } const mode = getMode() - const isReleasePerspective = - !isPublishedPerspective(currentGlobalBundle) && !isDraftPerspective(currentGlobalBundle) + const isReleasePerspective = currentPerspective && !isPublishedPerspective(currentPerspective) if (status) { return <DocumentStatusPulse status={status || undefined} /> @@ -112,12 +106,9 @@ export function DocumentStatusLine() { draft={mode === 'draft' ? editState?.draft : undefined} published={mode === 'published' ? editState?.published : undefined} versions={ - mode === 'version' && - isReleasePerspective && - currentGlobalBundle.name && - editState?.version + mode === 'version' && isReleasePerspective && currentRelease?.name && editState?.version ? { - [currentGlobalBundle.name]: {snapshot: editState?.version}, + [currentRelease.name]: {snapshot: editState?.version}, } : undefined } diff --git a/packages/sanity/src/structure/panes/document/useDocumentTitle.ts b/packages/sanity/src/structure/panes/document/useDocumentTitle.ts index a14f02415b6..6b1158c721c 100644 --- a/packages/sanity/src/structure/panes/document/useDocumentTitle.ts +++ b/packages/sanity/src/structure/panes/document/useDocumentTitle.ts @@ -1,7 +1,7 @@ import { isPublishedPerspective, unstable_useValuePreview as useValuePreview, - usePerspective, + useStudioPerspectiveState, } from 'sanity' import {useDocumentPane} from './useDocumentPane' @@ -27,9 +27,9 @@ interface UseDocumentTitle { */ export function useDocumentTitle(): UseDocumentTitle { const {connectionState, schemaType, title, editState} = useDocumentPane() - const {perspective} = usePerspective() + const {current} = useStudioPerspectiveState() const documentValue = - perspective && isPublishedPerspective(perspective) + current && isPublishedPerspective(current) ? editState?.published : editState?.version || editState?.draft || editState?.published const subscribed = Boolean(documentValue) diff --git a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx index 48c1cca7a50..af7273891ae 100644 --- a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx +++ b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx @@ -6,8 +6,8 @@ import {debounce, map, type Observable, of, tap, timer} from 'rxjs' import { type GeneralPreviewLayoutKey, useI18nText, - usePerspective, useReleases, + useReleasesStack, useSchema, useTranslation, useUnique, @@ -76,7 +76,7 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi const {childItemId, isActive, pane, paneKey, sortOrder: sortOrderRaw, layout} = props const schema = useSchema() const releases = useReleases() - const {bundlesPerspective} = usePerspective() + const stack = useReleasesStack() const {displayOptions, options} = pane const {apiVersion, filter} = options const params = useShallowUnique(options.params || EMPTY_RECORD) @@ -113,7 +113,7 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi } = useDocumentList({ apiVersion, filter, - perspective: bundlesPerspective, + perspective: stack, params, searchQuery: searchQuery?.trim(), sortOrder, diff --git a/packages/sanity/src/structure/panes/documentList/sheetList/useDocumentSheetColumns.tsx b/packages/sanity/src/structure/panes/documentList/sheetList/useDocumentSheetColumns.tsx index 3f648aa1186..049129ba23d 100644 --- a/packages/sanity/src/structure/panes/documentList/sheetList/useDocumentSheetColumns.tsx +++ b/packages/sanity/src/structure/panes/documentList/sheetList/useDocumentSheetColumns.tsx @@ -30,9 +30,9 @@ const PreviewCell = (props: { } }) => { const {documentPreviewStore, row, schemaType} = props - const title = 'Document title' + const previewStateObservable = useMemo( - () => getPreviewStateObservable(documentPreviewStore, schemaType, row.original._id, title), + () => getPreviewStateObservable(documentPreviewStore, schemaType, row.original._id), [documentPreviewStore, row.original._id, schemaType], ) const {draft, published, isLoading} = useObservable(previewStateObservable, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a0e6b83c69..e9e62b1c40c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1458,6 +1458,9 @@ importers: '@sanity/icons': specifier: 3.5.0-corel.0 version: 3.5.0-corel.0(react@18.3.1) + '@sanity/id-utils': + specifier: ^1.0.0 + version: 1.0.0 '@sanity/image-url': specifier: ^1.0.2 version: 1.1.0 @@ -1737,9 +1740,15 @@ importers: tar-stream: specifier: ^3.1.7 version: 3.1.7 + ts-brand: + specifier: ^0.2.0 + version: 0.2.0 use-device-pixel-ratio: specifier: ^1.1.0 version: 1.1.2(react@18.3.1) + use-effect-event: + specifier: ^1.0.2 + version: 1.0.2(react@18.3.1) use-hot-module-reload: specifier: ^2.0.0 version: 2.0.0(react@18.3.1) @@ -4452,6 +4461,10 @@ packages: peerDependencies: react: ^18.3 || >=19.0.0-rc + '@sanity/id-utils@1.0.0': + resolution: {integrity: sha512-2sb7tbdMDuUuVyocJPKG0gZBiOML/ovCe+mJiLrv1j69ODOfa2LfUjDVR+dRw/A/+XuxoJSSP8ebG7NiwTOgIA==} + engines: {node: '>=18'} + '@sanity/image-url@1.1.0': resolution: {integrity: sha512-JHumVRxzzaZAJyOimntdukA9TjjzsJiaiq/uUBdTknMLCNvtM6KQ5OCp6W5fIdY78uyFxtQjz+MPXwK8WBIxWg==} engines: {node: '>=10.0.0'} @@ -10949,6 +10962,9 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-brand@0.2.0: + resolution: {integrity: sha512-H5uo7OqMvd91D2EefFmltBP9oeNInNzWLAZUSt6coGDn8b814Eis6SnEtzyXORr9ccEb38PfzyiRVDacdkycSQ==} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -14414,6 +14430,12 @@ snapshots: dependencies: react: 19.0.0-rc-f994737d14-20240522 + '@sanity/id-utils@1.0.0': + dependencies: + '@sanity/uuid': 3.0.2 + lodash: 4.17.21 + ts-brand: 0.2.0 + '@sanity/image-url@1.1.0': {} '@sanity/import@3.37.5': @@ -22670,6 +22692,8 @@ snapshots: dependencies: typescript: 5.6.3 + ts-brand@0.2.0: {} + ts-node@10.9.2(@swc/core@1.7.14(@swc/helpers@0.5.13))(@types/node@18.19.44)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1