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/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 2ce26600c67..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 { const {publishedId, draftId, versionId} = getIdPair(id, {version}) @@ -144,28 +145,30 @@ export function getReferenceInfo( refSchemaType, ) - const versions$ = from(perspective.bundleIds).pipe( - mergeMap>((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: undefined}], + ] + : [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/utils/getPreviewStateObservable.ts b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts index cc62f875c0a..9403c05deff 100644 --- a/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts +++ b/packages/sanity/src/core/preview/utils/getPreviewStateObservable.ts @@ -4,15 +4,16 @@ 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 +export type VersionsRecord = Record -export type VersionTuple = [bundleId: string, snapshot: PreparedSnapshot] +export type VersionTuple = [bundleId: ReleaseId, snapshot: PreparedSnapshot] export interface PreviewState { isLoading?: boolean @@ -33,77 +34,50 @@ export function getPreviewStateObservable( documentPreviewStore: DocumentPreviewStore, schemaType: SchemaType, documentId: string, - 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 { const draft$ = isLiveEditEnabled(schemaType) ? of({snapshot: null}) : documentPreviewStore.observeForPreview({_id: getDraftId(documentId)}, schemaType) - const versions$ = from(perspective.bundleIds).pipe( - mergeMap>((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((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({}), ) - // 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: undefined} - }), - startWith({snapshot: undefined}), - ) - const published$ = documentPreviewStore.observeForPreview( {_id: getPublishedId(documentId)}, schemaType, ) - return combineLatest([draft$, published$, version$, versions$]).pipe( - map(([draft, published, version, versions]) => ({ + return combineLatest([draft$, published$, versions$]).pipe( + map(([draft, published, versions]) => ({ draft: draft.snapshot, isLoading: false, published: published.snapshot, - version: version.snapshot, versions, })), startWith({ - draft: null, + draft: undefined, isLoading: true, - published: null, - version: null, + published: undefined, versions: {}, }), ) 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 ( { - 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 - discardVersion: (releaseId: string, documentId: string) => Promise + createVersion: (releaseId: ReleaseId, documentId: string) => Promise + discardVersion: (releaseId: ReleaseId, documentId: string) => Promise } { 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. 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(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 { - <> 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 = () => ( - - - - - -) - -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) => { - 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 ( - - - - - - {isReleasePerspectiveExcluded ? ( - - ) : ( - - )} - {/* )} */} - - - - - {displayTitle} - - {!isPublishedPerspective(release) && - release.metadata.releaseType === 'scheduled' && - (release.publishAt || release.metadata.intendedPublishAt) && ( - - {formatRelativeLocale(getPublishDateFromRelease(release), new Date())} - - )} - - - {canReleaseBeExcluded && ( - - - - )} - {!isPublishedPerspective(release) && isReleaseScheduledOrScheduling(release) && ( - - - - - - )} - - - - - ) -}) - -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 = () => ( + + + + + +) + +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) => { + event.stopPropagation() + toggle(perspective) + }, + [perspective, toggle], + ) + + const handleOnReleaseClick = useCallback(() => setCurrent(perspective), [perspective, setCurrent]) + + const canReleaseBeExcluded = inRange && !last + + return ( + + ) +}) + +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) => { + event.stopPropagation() + toggle(releaseId) + }, + [toggle, releaseId], + ) + + const handleOnReleaseClick = useCallback(() => setCurrent(releaseId), [releaseId, setCurrent]) + + const canReleaseBeExcluded = !isReleaseScheduledOrScheduling(release) && inRange && !last + + return ( + + ) +}) + +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 = () => ( + + + + + +) + +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) => 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 ( + + + + + {excluded ? : } + + + + {title} + + {date && ( + + {formatRelativeLocale(date, new Date())} + + )} + + + {canBeExcluded && ( + + + + )} + {locked && ( + + + + + + )} + + + + + ) +}) + +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 }): 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) : undefined, - [currentGlobalBundleId, currentGlobalBundleMenuItemRef], + [current, currentGlobalBundleMenuItemRef], ) if (releases.length === 0) return null @@ -59,7 +59,7 @@ export function ReleaseTypeMenuSection({ {releases.map((release, index) => ( - 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 = ( - + @@ -86,7 +86,7 @@ export function ReleasesNav(): JSX.Element { ) - if (isPublishedPerspective(currentGlobalBundle)) { + if (isPublishedPerspective(current)) { return {labelContent} } @@ -94,7 +94,7 @@ export function ReleasesNav(): JSX.Element { {children} @@ -116,7 +116,7 @@ export function ReleasesNav(): JSX.Element { } return {visibleLabelChildren()} - }, [currentGlobalBundle, t]) + }, [currentGlobalRelease, current, t]) return ( @@ -124,7 +124,7 @@ export function ReleasesNav(): JSX.Element { {releasesToolLink} {currentGlobalPerspectiveLabel} - {!isDraftPerspective(currentGlobalBundle) && ( + {!isDraftPerspective(current) && (