diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx index e169df3e9ded..59fec5890ed6 100644 --- a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx +++ b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx @@ -1,48 +1,60 @@ -import {AddIcon, CheckmarkIcon, ChevronDownIcon} from '@sanity/icons' +import {AddIcon, ChevronDownIcon} from '@sanity/icons' // eslint-disable-next-line no-restricted-imports -- MenuItem requires props, only supported by @sanity/ui -import {Box, Button, Flex, Menu, MenuDivider, MenuItem, Spinner, Text} from '@sanity/ui' +import {Box, Button, Flex, Menu, MenuDivider, MenuItem, Spinner} from '@sanity/ui' +import {compareDesc} from 'date-fns' import {useCallback, useMemo, useRef, useState} from 'react' +import {type ReleaseDocument, type ReleaseType} from 'sanity' import {styled} from 'styled-components' -import {MenuButton, Tooltip} from '../../../ui-components' +import {MenuButton} from '../../../ui-components' import {useTranslation} from '../../i18n' import {useReleases} from '../../store/release/useReleases' import {ReleaseDetailsDialog} from '../components/dialog/ReleaseDetailsDialog' import {usePerspective} from '../hooks' -import {LATEST} from '../util/const' +import {getPublishDateFromRelease} from '../util/util' +import { + getRangePosition, + GlobalPerspectiveMenuItem, + type LayerRange, +} from './GlobalPerspectiveMenuItem' +import {ReleaseTypeSection} from './ReleaseTypeMenuSection' + +type ReleaseTypeSort = (a: ReleaseDocument, b: ReleaseDocument) => number const StyledMenu = styled(Menu)` min-width: 200px; + max-width: 320px; ` const StyledBox = styled(Box)` overflow: auto; - max-height: 200px; + max-height: 75vh; ` +const sortReleaseByPublishAt: ReleaseTypeSort = (ARelease, BRelease) => + compareDesc(getPublishDateFromRelease(BRelease), getPublishDateFromRelease(ARelease)) +const sortReleaseByTitle: ReleaseTypeSort = (ARelease, BRelease) => + ARelease.metadata.title.localeCompare(BRelease.metadata.title) + +const releaseTypeSorting: Record = { + asap: sortReleaseByTitle, + scheduled: sortReleaseByPublishAt, + undecided: sortReleaseByTitle, +} + +const orderedReleaseTypes: ReleaseType[] = ['asap', 'scheduled', 'undecided'] + +const ASAP_RANGE_OFFSET = 2 + export function GlobalPerspectiveMenu(): JSX.Element { const {loading, data: releases} = useReleases() - const {currentGlobalBundle, setPerspectiveFromRelease, setPerspective} = usePerspective() + const {currentGlobalBundle} = usePerspective() + const currentGlobalBundleId = currentGlobalBundle._id const [createBundleDialogOpen, setCreateBundleDialogOpen] = useState(false) const styledMenuRef = useRef(null) const {t} = useTranslation() - const filteredReleases = useMemo(() => { - if (!releases) return [] - - return releases.filter(({_id, state}) => state !== 'archived') - }, [releases]) - - const hasBundles = filteredReleases.length > 0 - - const handleBundleChange = useCallback( - (releaseId: string) => () => { - setPerspectiveFromRelease(releaseId) - }, - [setPerspectiveFromRelease], - ) - /* create new release */ const handleCreateBundleClick = useCallback(() => { setCreateBundleDialogOpen(true) @@ -52,6 +64,75 @@ export function GlobalPerspectiveMenu(): JSX.Element { setCreateBundleDialogOpen(false) }, []) + const unarchivedReleases = useMemo( + () => releases.filter((release) => release.state !== 'archived'), + [releases], + ) + + const sortedReleaseTypeReleases = useMemo( + () => + orderedReleaseTypes.reduce>( + (ReleaseTypeReleases, releaseType) => ({ + ...ReleaseTypeReleases, + [releaseType]: unarchivedReleases + .filter(({metadata}) => metadata.releaseType === releaseType) + .sort(releaseTypeSorting[releaseType]), + }), + {} as Record, + ), + [unarchivedReleases], + ) + + const range: LayerRange = useMemo(() => { + let firstIndex = -1 + let lastIndex = 0 + + // if (!releases.published.hidden) { + firstIndex = 0 + // } + + if (currentGlobalBundleId === 'published') { + lastIndex = 0 + } + + const {asap, scheduled} = sortedReleaseTypeReleases + const countAsapReleases = asap.length + const countScheduledReleases = scheduled.length + + const offsets = { + asap: ASAP_RANGE_OFFSET, + scheduled: ASAP_RANGE_OFFSET + countAsapReleases, + undecided: ASAP_RANGE_OFFSET + countAsapReleases + countScheduledReleases, + } + + const adjustIndexForReleaseType = (type: ReleaseType) => { + const groupSubsetReleases = sortedReleaseTypeReleases[type] + const offset = offsets[type] + + groupSubsetReleases.forEach(({_id}, groupReleaseIndex) => { + const index = offset + groupReleaseIndex + + if (firstIndex === -1) { + // if (!item.hidden) { + firstIndex = index + // } + } + + if (_id === currentGlobalBundleId) { + lastIndex = index + } + }) + } + + orderedReleaseTypes.forEach(adjustIndexForReleaseType) + + return { + firstIndex, + lastIndex, + offsets, + } + }, [currentGlobalBundleId, sortedReleaseTypeReleases]) + const releasesList = useMemo(() => { if (loading) { return ( @@ -62,75 +143,31 @@ export function GlobalPerspectiveMenu(): JSX.Element { } return ( - <> + + + + {orderedReleaseTypes.map((releaseType) => ( + + ))} + + - ) : undefined - } - onClick={() => setPerspective(LATEST._id)} - pressed={false} - text={LATEST.metadata.title} - data-testid="latest-menu-item" + icon={AddIcon} + onClick={handleCreateBundleClick} + text={t('release.action.create-new')} /> - {hasBundles && ( - <> - - - {filteredReleases.map(({_id, ...release}) => ( - - - - - - {release.metadata.title} - - - - - - - - - - - ))} - - - )} - - <> - - - - + ) - }, [ - currentGlobalBundle._id, - handleBundleChange, - setPerspective, - handleCreateBundleClick, - hasBundles, - loading, - filteredReleases, - t, - ]) + }, [handleCreateBundleClick, loading, range, sortedReleaseTypeReleases, t]) return ( <> diff --git a/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx new file mode 100644 index 000000000000..4984f16b4cd0 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx @@ -0,0 +1,132 @@ +import {EyeOpenIcon} from '@sanity/icons' +// eslint-disable-next-line no-restricted-imports -- custom use for MenuItem not supported by ui-components +import {Box, Flex, MenuItem, Stack, Text} from '@sanity/ui' +import {type MouseEvent, useCallback} from 'react' +import {getReleaseTone, RelativeTime, ReleaseAvatar, type ReleaseDocument} from 'sanity' + +import {usePerspective} from '../hooks/usePerspective' +import {GlobalPerspectiveMenuItemIndicator} from './PerspectiveLayerIndicator' + +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 function GlobalPerspectiveMenuItem(props: { + release: ReleaseDocument + rangePosition: rangePosition + toggleable: boolean +}) { + const {release, rangePosition, toggleable} = props + // const {current, replace: replaceVersion, replaceToggle} = usePerspective() + const {currentGlobalBundle, setPerspectiveFromRelease, setPerspective} = usePerspective() + const active = release._id === currentGlobalBundle._id + const first = rangePosition === 'first' + const within = rangePosition === 'within' + const last = rangePosition === 'last' + const inRange = first || within || last + + const handleToggleReleaseVisibility = useCallback((event: MouseEvent) => { + event.stopPropagation() + }, []) + + const handleOnReleaseClick = useCallback( + () => + release._id === 'published' + ? setPerspective('published') + : setPerspectiveFromRelease(release._id), + [release._id, setPerspective, setPerspectiveFromRelease], + ) + + return ( + + + + + + {/* {release.hidden ? ( + + ) : ( */} + + {/* )} */} + + + + + {release.metadata.title} + + {release.metadata.releaseType !== 'undecided' && + (release.publishAt || release.metadata.intendedPublishAt) && ( + + + + )} + + + {!toggleable && ( + + + + + + )} + {/* {toggleable && ( + + )} */} + + + + + ) +} diff --git a/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx new file mode 100644 index 000000000000..8a6bdc354b0f --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/PerspectiveLayerIndicator.tsx @@ -0,0 +1,101 @@ +import {Box} from '@sanity/ui' +import {css, styled} from 'styled-components' + +const INDICATOR_LEFT_OFFSET = 18 +const INDICATOR_WIDTH = 5 +const INDICATOR_COLOR_VAR_NAME = '--card-border-color' +const INDICATOR_BOTTOM_OFFSET = 4 + +export const GlobalPerspectiveMenuItemIndicator = styled.div<{ + $inRange: boolean + $last: boolean + $first: boolean +}>( + ({$inRange, $last, $first}) => css` + position: relative; + + --indicator-left: ${INDICATOR_LEFT_OFFSET}px; + --indicator-width: ${INDICATOR_WIDTH}px; + --indicator-color: var(${INDICATOR_COLOR_VAR_NAME}); + --indicator-bottom: ${INDICATOR_BOTTOM_OFFSET}px; + + --indicator-in-range-height: 16.5px; + + ${$inRange && + !$last && + css` + &:after { + content: ''; + display: block; + position: absolute; + left: var(--indicator-left); + bottom: -var(--indicator-bottom); + width: var(--indicator-width); + height: var(--indicator-bottom); + background-color: var(--indicator-color); + } + `} + + ${$inRange && + css` + > [data-ui='MenuItem'] { + position: relative; + + &:before, + &:after { + content: ''; + display: block; + position: absolute; + left: var(--indicator-left); + width: var(--indicator-width); + background-color: var(--indicator-color); + } + + &:before { + top: 0; + height: var(--indicator-in-range-height); + } + + &:after { + top: var(--indicator-in-range-height); + bottom: 0; + } + } + `} + + ${$first && + css` + > [data-ui='MenuItem']:before { + display: none; + } + `} + + ${$last && + css` + > [data-ui='MenuItem']:after { + display: none; + } + `} + `, +) + +export const GlobalPerspectiveMenuLabelIndicator = styled(Box)<{$withinRange: boolean}>( + ({$withinRange}) => css` + position: relative; + padding-left: 40px; + + ${$withinRange && + css` + &:before { + content: ''; + display: block; + position: absolute; + left: ${INDICATOR_LEFT_OFFSET}px; + top: 0; + bottom: -${INDICATOR_BOTTOM_OFFSET}px; + width: ${INDICATOR_WIDTH}px; + background-color: var(${INDICATOR_COLOR_VAR_NAME}); + } + `} + `, +) diff --git a/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx new file mode 100644 index 000000000000..c5651a617bd5 --- /dev/null +++ b/packages/sanity/src/core/releases/navbar/ReleaseTypeMenuSection.tsx @@ -0,0 +1,55 @@ +import {Label} from '@sanity/ui' +import {type ReleaseDocument, type ReleaseType, useTranslation} from 'sanity' + +import { + getRangePosition, + GlobalPerspectiveMenuItem, + type LayerRange, +} from './GlobalPerspectiveMenuItem' +import {GlobalPerspectiveMenuLabelIndicator} from './PerspectiveLayerIndicator' + +const RELEASE_TYPE_LABELS: Record = { + asap: 'release.type.asap', + scheduled: 'release.type.scheduled', + undecided: 'release.type.undecided', +} + +export function ReleaseTypeSection({ + releaseType, + releases, + range, +}: { + releaseType: ReleaseType + releases: ReleaseDocument[] + range: LayerRange +}): JSX.Element | null { + const {t} = useTranslation() + + if (releases.length === 0) return null + + const {firstIndex, lastIndex, offsets} = range + const releaseTypeOffset = offsets[releaseType] + + return ( + <> + = releaseTypeOffset} + paddingRight={2} + paddingTop={4} + paddingBottom={2} + > + + + {releases.map((release, index) => ( + + ))} + + ) +} diff --git a/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx index f748f158c87b..3fa0473618f6 100644 --- a/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx +++ b/packages/sanity/src/core/releases/navbar/ReleasesNav.tsx @@ -16,6 +16,18 @@ import {getBundleIdFromReleaseId} from '../util/getBundleIdFromReleaseId' import {getReleaseTone} from '../util/getReleaseTone' import {GlobalPerspectiveMenu} from './GlobalPerspectiveMenu' +const AnimatedMotionDiv = ({children, ...props}: PropsWithChildren) => ( + + {children} + +) + export function ReleasesNav(): JSX.Element { const activeToolName = useRouterState( useCallback( @@ -50,41 +62,36 @@ export function ReleasesNav(): JSX.Element { const currentGlobalPerspectiveLabel = useMemo(() => { if (!currentGlobalBundle || currentGlobalBundle._id === LATEST._id) return null - if (currentGlobalBundle._id === 'published') { - return ( - - - - - {currentGlobalBundle.metadata?.title} - - - - + + const visibleLabelChildren = () => { + const labelContent = ( + + + + + + + {currentGlobalBundle.metadata?.title} + + + ) - } - const releasesIntentLink = ({children, ...intentProps}: PropsWithChildren) => ( - - {children} - - ) + if (currentGlobalBundle._id === 'published') { + return {labelContent} + } - const tone = currentGlobalBundle.metadata?.releaseType - ? getReleaseTone(currentGlobalBundle) - : 'default' + const releasesIntentLink = ({children, ...intentProps}: PropsWithChildren) => ( + + {children} + + ) - return ( - + return ( - - ) + ) + } + + return {visibleLabelChildren()} }, [currentGlobalBundle]) return ( diff --git a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx index 725c8525202e..8acb9e68622c 100644 --- a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx +++ b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx @@ -189,14 +189,14 @@ const TableInner = ({ }} {...cardRowProps} > - {amalgamatedColumnDefs.map(({cell: Cell, width, id, sorting = false}) => ( + {amalgamatedColumnDefs.map(({cell: Cell, style, width, id, sorting = false}) => ( } cellProps={{ as: 'td', id: String(id), - style: {width: width || undefined}, + style: {...style, width: width || undefined}, }} sorting={sorting} /> diff --git a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx index fe6fba7847db..30208870088d 100644 --- a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx +++ b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx @@ -88,13 +88,13 @@ export const TableHeader = ({headers, searchDisabled}: TableHeaderProps) => { )`, }} > - {headers.map(({header: Header, width, id, sorting}) => ( + {headers.map(({header: Header, style, width, id, sorting}) => (
{ }) => React.ReactNode id: keyof TableData | string width: number | null + style?: CSSProperties sorting?: boolean sortTransform?: (value: TableData) => number } diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx index 4edbb5130ec9..65e2f71935a6 100644 --- a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx @@ -124,6 +124,7 @@ export const releasesOverviewColumnDefs: ( id: 'title', sorting: false, width: null, + style: {minWidth: '50%'}, header: ({headerProps}) => ( diff --git a/packages/sanity/src/core/releases/util/util.ts b/packages/sanity/src/core/releases/util/util.ts index 26272b7e6520..cccca41272fa 100644 --- a/packages/sanity/src/core/releases/util/util.ts +++ b/packages/sanity/src/core/releases/util/util.ts @@ -60,3 +60,14 @@ export function getCreateVersionOrigin(documentId: string): VersionOriginTypes { if (isPublishedId(documentId)) return 'published' return 'version' } + +/** @internal */ +export function getPublishDateFromRelease(release: ReleaseDocument): Date { + const dateString = release.publishAt || release.metadata.intendedPublishAt + if (!dateString) { + console.error('No publish date found on release', release) + return new Date() + } + + return new Date(dateString) +}