Skip to content

Commit

Permalink
feat(releases): global perspective release type grouped menu (#7677)
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanl17 authored and RitaDias committed Oct 28, 2024
1 parent 60830b4 commit 63b1233
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 134 deletions.
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

0 comments on commit 63b1233

Please sign in to comment.