Skip to content

Commit

Permalink
feat(structure): squash chunks in timeline. (#7458)
Browse files Browse the repository at this point in the history
* feat(structure): add squashed chunks to timeline

* feat(structure): refactor expandable item, all items need to be rendered in the same virtual list

* feat(structure): expand elements on menu click

* chore(structure): clean timeline item
  • Loading branch information
pedrobonamin committed Nov 11, 2024
1 parent 53d19d9 commit 04f12cd
Show file tree
Hide file tree
Showing 9 changed files with 718 additions and 191 deletions.
2 changes: 2 additions & 0 deletions packages/sanity/src/structure/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ const structureLocaleStrings = defineLocalesResources('structure', {
'timeline-item.menu-button.aria-label': 'Open action menu',
/** The text for the tooltip in menu button the timeline item */
'timeline-item.menu-button.tooltip': 'Actions',
/** The text for the collapse action in the timeline item menu */
'timeline-item.menu.action-collapse': 'Collapse',
/** The text for the expand action in the timeline item menu */
'timeline-item.menu.action-expand': 'Expand',
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export function HistorySelector({showList}: {showList: boolean}) {
showList ? (
<Timeline
chunks={chunks}
firstChunk={realRevChunk}
hasMoreChunks={hasMoreChunks}
lastChunk={realRevChunk}
onLoadMore={handleLoadMore}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,151 @@
import {Box, Card, Container, Stack, Text} from '@sanity/ui'
import {useMemo, useState} from 'react'
import {type ChunkType, getCalendarLabels, useDateTimeFormat, useTranslation} from 'sanity'
import {useCallback, useMemo, useState} from 'react'
import {type Chunk, getCalendarLabels, useDateTimeFormat, useTranslation} from 'sanity'

import {DateTimeInput} from '../../../../../ui-components/inputs/DateInputs/DateTimeInput'
import {TIMELINE_ITEM_I18N_KEY_MAPPING} from '../timelineI18n'
import {TimelineItem} from '../timelineItem'
import {Timeline} from '../timeline'

const CHUNK_TYPES = Object.keys(TIMELINE_ITEM_I18N_KEY_MAPPING).reverse() as ChunkType[]
const CHUNKS: Chunk[] = [
{
index: 10,
id: 'delete-10',
type: 'delete',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 9,
id: 'unpublish-9',
type: 'unpublish',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'unknown',
publishedState: 'present',
},
{
index: 8,
id: 'editLive-8',
type: 'editLive',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'unknown',
publishedState: 'present',
},
{
index: 7,
id: 'discardDraft-7',
type: 'discardDraft',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'present',
publishedState: 'present',
},
{
index: 6,
id: 'editDraft-6',
type: 'editDraft',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['pP5s3g90N']),
draftState: 'present',
publishedState: 'present',
},
{
index: 5,
id: 'publish-5',
type: 'publish',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 4,
id: 'editDraft-4',
type: 'editDraft',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['pP5s3g90N']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 3,
id: 'editDraft-3',
type: 'editDraft',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['pJ61yWhkD']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 2,
id: 'editDraft-2',
type: 'editDraft',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['pJ61yWhkD']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 1,
id: 'create-1',
type: 'create',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'present',
publishedState: 'unknown',
},
{
index: 0,
id: 'initial-0',
type: 'initial',
start: 0,
end: 10,
startTimestamp: '2024-09-02T09:28:49.734503Z',
endTimestamp: '2024-09-02T09:28:49.734503Z',
authors: new Set(['p8xDvUMxC']),
draftState: 'unknown',
publishedState: 'unknown',
},
]

export default function TimelineItemStory() {
const {t: coreT} = useTranslation()
const [date, setDate] = useState<Date>(() => new Date())
const [selected, setSelected] = useState<string | null>(null)
const dateFormatter = useDateTimeFormat({dateStyle: 'medium', timeStyle: 'short'})
const calendarLabels = useMemo(() => getCalendarLabels(coreT), [coreT])

const inputValue = date ? dateFormatter.format(new Date(date)) : ''
const handleDatechange = (newDate: Date | null) => {
if (newDate) {
Expand All @@ -24,6 +155,10 @@ export default function TimelineItemStory() {
}
}

const handleSelect = useCallback((chunk: Chunk) => {
setSelected((c) => (c === chunk.id ? null : chunk.id))
}, [])

return (
<Box margin={3}>
<Container width={0} margin={4}>
Expand Down Expand Up @@ -51,71 +186,13 @@ export default function TimelineItemStory() {
</Stack>

<Card border padding={2} marginTop={3} radius={2}>
<Stack space={1}>
{CHUNK_TYPES.map((key, index) => (
<TimelineItem
key={key}
onSelect={() => setSelected((p) => (p === key ? null : key))}
isSelected={selected === key}
type={key}
timestamp={date.toString()}
chunk={{
index,
id: key,
type: key,
start: -13,
end: -13,
startTimestamp: date.toString(),
endTimestamp: date.toString(),
authors: new Set(['p8xDvUMxC']),
draftState: 'unknown',
publishedState: 'present',
}}
squashedChunks={
key === 'publish'
? [
{
index: 0,
id: '123',
type: 'editDraft',
start: 0,
end: 0,
startTimestamp: date.toString(),
endTimestamp: date.toString(),
authors: new Set(['pP5s3g90N']),
draftState: 'present',
publishedState: 'present',
},
{
index: 1,
id: '345',
type: 'editDraft',
start: 1,
end: 1,
startTimestamp: date.toString(),
endTimestamp: date.toString(),
authors: new Set(['pJ61yWhkD']),
draftState: 'present',
publishedState: 'present',
},
{
index: 2,
id: '345',
type: 'editDraft',
start: 2,
end: 2,
startTimestamp: date.toString(),
endTimestamp: date.toString(),
authors: new Set(['pJ61yWhkD']),
draftState: 'present',
publishedState: 'present',
},
]
: undefined
}
/>
))}
</Stack>
<Timeline
chunks={CHUNKS.map((chunk) => ({...chunk, endTimestamp: date.toString()}))}
hasMoreChunks={false}
lastChunk={selected ? CHUNKS.find((chunk) => chunk.id === selected) : undefined}
onSelect={handleSelect}
onLoadMore={() => {}}
/>
</Card>
</Container>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {Menu, usePortal} from '@sanity/ui'
import {type MouseEvent, useCallback} from 'react'
import {ContextMenuButton, useTranslation} from 'sanity'

import {MenuButton, MenuItem} from '../../../../ui-components'
import {structureLocaleNamespace} from '../../../i18n'
import {TIMELINE_LIST_WRAPPER_ID} from './timeline'
import {TIMELINE_MENU_PORTAL} from './timelineMenu'

/**
* This is a hack to force the scrollbar to not appear when the list is expanding,
* if we don't do this the scrollbar will appear for a brief moment when the list is expanding and then disappear
* when the list is fully expanded.
*/
function hideScrollbarOnExpand(isExpanded: boolean) {
// Do nothing if the list is already expanded
if (isExpanded) return

const listWrapper = document.getElementById(TIMELINE_LIST_WRAPPER_ID)

if (listWrapper) {
const firstChildren = listWrapper.children[0] as HTMLElement
const hasScrollbar = firstChildren.scrollHeight > firstChildren.clientHeight
if (!hasScrollbar) {
//
const currentStyle = getComputedStyle(firstChildren).overflowY
// Add overflow hidden to the listWrapper to avoid the scrollbar to appear when expanding
firstChildren.style.overflowY = 'hidden'
setTimeout(() => {
// Reset the overflow style after the list is expanded
firstChildren.style.overflowY = currentStyle
}, 0)
}
}
}

export function ExpandableTimelineItemMenu({
chunkId,
isExpanded,
onExpand,
}: {
chunkId: string
isExpanded: boolean
onExpand: () => void
}) {
const {t} = useTranslation(structureLocaleNamespace)
const portalContext = usePortal()

const handleExpandClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
hideScrollbarOnExpand(isExpanded)
onExpand()
},
[onExpand, isExpanded],
)

return (
<MenuButton
id={`timeline-item-menu-button-${chunkId}`}
button={
<ContextMenuButton
aria-label={t('timeline-item.menu-button.aria-label')}
size="large"
tooltipProps={{content: t('timeline-item.menu-button.tooltip')}}
/>
}
menu={
<Menu padding={1}>
<MenuItem
text={t(
isExpanded
? 'timeline-item.menu.action-collapse'
: 'timeline-item.menu.action-expand',
)}
onClick={handleExpandClick}
/>
</Menu>
}
popover={{
// when used inside the timeline menu we want to keep the element inside the popover, to avoid closing the popover when clicking expand.
portal: portalContext.elements?.[TIMELINE_MENU_PORTAL] ? TIMELINE_MENU_PORTAL : true,
placement: 'bottom-end',
fallbackPlacements: ['left', 'left-end', 'left-start'],
}}
/>
)
}
Loading

0 comments on commit 04f12cd

Please sign in to comment.