Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(releases): global perspective release type grouped menu #7677

Merged
merged 8 commits into from
Oct 28, 2024
211 changes: 124 additions & 87 deletions packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<ReleaseType, ReleaseTypeSort> = {
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<HTMLDivElement>(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)
Expand All @@ -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<Record<ReleaseType, ReleaseDocument[]>>(
(ReleaseTypeReleases, releaseType) => ({
...ReleaseTypeReleases,
[releaseType]: unarchivedReleases
.filter(({metadata}) => metadata.releaseType === releaseType)
.sort(releaseTypeSorting[releaseType]),
}),
{} as Record<ReleaseType, ReleaseDocument[]>,
),
[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 (
Expand All @@ -62,75 +143,31 @@ export function GlobalPerspectiveMenu(): JSX.Element {
}

return (
<>
<Box>
<GlobalPerspectiveMenuItem
rangePosition={getRangePosition(range, 0)}
release={{_id: 'published', metadata: {title: 'Published'}} as ReleaseDocument}
toggleable
/>
<StyledBox>
{orderedReleaseTypes.map((releaseType) => (
<ReleaseTypeSection
key={releaseType}
releaseType={releaseType}
releases={sortedReleaseTypeReleases[releaseType]}
range={range}
/>
))}
</StyledBox>
<MenuDivider />
<MenuItem
iconRight={
currentGlobalBundle._id === LATEST._id ? (
<CheckmarkIcon data-testid="latest-checkmark-icon" />
) : 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 && (
<>
<MenuDivider />
<StyledBox data-testid="releases-list">
{filteredReleases.map(({_id, ...release}) => (
<MenuItem
key={_id}
onClick={handleBundleChange(_id)}
padding={1}
pressed={false}
data-testid={`release-${_id}`}
>
<Tooltip content={t('release.deleted-tooltip')} placement="bottom-start">
<Flex>
<Box flex={1} padding={2} style={{minWidth: 100}}>
<Text size={1} weight="medium">
{release.metadata.title}
</Text>
</Box>
<Box padding={2}>
<Text size={1}>
<CheckmarkIcon
style={{
opacity: currentGlobalBundle._id === _id ? 1 : 0,
}}
data-testid={`${_id}-checkmark-icon`}
/>
</Text>
</Box>
</Flex>
</Tooltip>
</MenuItem>
))}
</StyledBox>
</>
)}

<>
<MenuDivider />
<MenuItem
icon={AddIcon}
onClick={handleCreateBundleClick}
text={t('release.action.create-new')}
/>
</>
</>
</Box>
)
}, [
currentGlobalBundle._id,
handleBundleChange,
setPerspective,
handleCreateBundleClick,
hasBundles,
loading,
filteredReleases,
t,
])
}, [handleCreateBundleClick, loading, range, sortedReleaseTypeReleases, t])

return (
<>
Expand Down
132 changes: 132 additions & 0 deletions packages/sanity/src/core/releases/navbar/GlobalPerspectiveMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) => {
event.stopPropagation()
}, [])

const handleOnReleaseClick = useCallback(
() =>
release._id === 'published'
? setPerspective('published')
: setPerspectiveFromRelease(release._id),
[release._id, setPerspective, setPerspectiveFromRelease],
)

return (
<GlobalPerspectiveMenuItemIndicator $first={first} $last={last} $inRange={inRange}>
<MenuItem onClick={handleOnReleaseClick} padding={1} pressed={active}>
<Flex align="flex-start" gap={1}>
<Box
flex="none"
style={{
position: 'relative',
zIndex: 1,
}}
>
<Text size={1}>
{/* {release.hidden ? (
<DotIcon
style={
{
'--card-icon-color': 'var(--card-border-color)',
} as CSSProperties
}
/>
) : ( */}
<ReleaseAvatar tone={getReleaseTone(release)} />
{/* )} */}
</Text>
</Box>
<Stack
flex={1}
paddingY={2}
paddingRight={2}
space={2}
style={
{
// opacity: release.hidden ? 0.5 : undefined,
}
}
>
<Text size={1} weight="medium">
{release.metadata.title}
</Text>
{release.metadata.releaseType !== 'undecided' &&
(release.publishAt || release.metadata.intendedPublishAt) && (
<Text muted size={1}>
<RelativeTime
time={(release.publishAt || release.metadata.intendedPublishAt)!}
useTemporalPhrase
/>
</Text>
)}
</Stack>
<Box flex="none">
{!toggleable && (
<Box padding={2} style={{opacity: 0}}>
<Text size={1}>
<EyeOpenIcon />
</Text>
</Box>
)}
{/* {toggleable && (
<ToggleLayerButton
$visible={!release.hidden}
forwardedAs="div"
disabled={!active}
icon={release.hidden ? EyeClosedIcon : EyeOpenIcon}
mode="bleed"
onClick={handleToggleReleaseVisibility}
padding={2}
/>
)} */}
</Box>
</Flex>
</MenuItem>
</GlobalPerspectiveMenuItemIndicator>
)
}
Loading
Loading