diff --git a/packages/sanity/src/structure/i18n/resources.ts b/packages/sanity/src/structure/i18n/resources.ts index e2e135822d2..3f6375124dc 100644 --- a/packages/sanity/src/structure/i18n/resources.ts +++ b/packages/sanity/src/structure/i18n/resources.ts @@ -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', }) diff --git a/packages/sanity/src/structure/panes/document/inspectors/changes/HistorySelector.tsx b/packages/sanity/src/structure/panes/document/inspectors/changes/HistorySelector.tsx index 62984f06054..6689070e671 100644 --- a/packages/sanity/src/structure/panes/document/inspectors/changes/HistorySelector.tsx +++ b/packages/sanity/src/structure/panes/document/inspectors/changes/HistorySelector.tsx @@ -74,7 +74,6 @@ export function HistorySelector({showList}: {showList: boolean}) { showList ? ( (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) { @@ -24,6 +155,10 @@ export default function TimelineItemStory() { } } + const handleSelect = useCallback((chunk: Chunk) => { + setSelected((c) => (c === chunk.id ? null : chunk.id)) + }, []) + return ( @@ -51,71 +186,13 @@ export default function TimelineItemStory() { - - {CHUNK_TYPES.map((key, index) => ( - 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 - } - /> - ))} - + ({...chunk, endTimestamp: date.toString()}))} + hasMoreChunks={false} + lastChunk={selected ? CHUNKS.find((chunk) => chunk.id === selected) : undefined} + onSelect={handleSelect} + onLoadMore={() => {}} + /> diff --git a/packages/sanity/src/structure/panes/document/timeline/expandableTimelineItemMenu.tsx b/packages/sanity/src/structure/panes/document/timeline/expandableTimelineItemMenu.tsx new file mode 100644 index 00000000000..45139114fde --- /dev/null +++ b/packages/sanity/src/structure/panes/document/timeline/expandableTimelineItemMenu.tsx @@ -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) => { + e.stopPropagation() + hideScrollbarOnExpand(isExpanded) + onExpand() + }, + [onExpand, isExpanded], + ) + + return ( + + } + 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'], + }} + /> + ) +} diff --git a/packages/sanity/src/structure/panes/document/timeline/timeline.tsx b/packages/sanity/src/structure/panes/document/timeline/timeline.tsx index 23348099ef1..f2479cf4183 100644 --- a/packages/sanity/src/structure/panes/document/timeline/timeline.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/timeline.tsx @@ -8,13 +8,13 @@ import { useTranslation, } from 'sanity' +import {ExpandableTimelineItemMenu} from './expandableTimelineItemMenu' import {ListWrapper, Root, StackWrapper} from './timeline.styled' import {TimelineItem} from './timelineItem' +import {addChunksMetadata, isNonPublishChunk, isPublishChunk} from './utils' interface TimelineProps { chunks: Chunk[] - disabledBeforeFirstChunk?: boolean - firstChunk?: Chunk | null hasMoreChunks: boolean | null lastChunk?: Chunk | null onLoadMore: () => void @@ -25,50 +25,97 @@ interface TimelineProps { listMaxHeight?: string } +export const TIMELINE_LIST_WRAPPER_ID = 'timeline-list-wrapper' + export const Timeline = ({ chunks, - disabledBeforeFirstChunk, hasMoreChunks, - lastChunk, + lastChunk: selectedChunk, onLoadMore, onSelect, - firstChunk, - listMaxHeight = 'calc(100vh - 198px)', + listMaxHeight = 'calc(100vh - 280px)', }: TimelineProps) => { const [mounted, setMounted] = useState(false) const {t} = useTranslation('studio') + const selectedChunkId = selectedChunk?.id + const chunksWithMetadata = useMemo(() => addChunksMetadata(chunks), [chunks]) - const filteredChunks = useMemo(() => { - return chunks.filter((c) => { - if (disabledBeforeFirstChunk && firstChunk) { - return c.index < firstChunk.index + const [expandedParents, setExpandedParents] = useState>(() => { + if (selectedChunkId) { + // If the selected chunk is a draft, we need to expand its parent + const selected = chunksWithMetadata.find((chunk) => chunk.id === selectedChunkId) + if (selected && isNonPublishChunk(selected) && selected.parentId) { + return new Set([selected.parentId]) } - return true + } + return new Set() + }) + + const filteredChunks = useMemo(() => { + return chunksWithMetadata.filter((chunk) => { + if (isPublishChunk(chunk) || !chunk.parentId) return true + // If the chunk has a parent id keep it hidden until the parent is expanded. + return expandedParents.has(chunk.parentId) }) - }, [chunks, disabledBeforeFirstChunk, firstChunk]) + }, [chunksWithMetadata, expandedParents]) + + const handleExpandParent = useCallback( + (parentId: string) => () => + setExpandedParents((prev) => { + const next = new Set(prev) + + if (prev.has(parentId)) next.delete(parentId) + else next.add(parentId) + + return next + }), + [], + ) const selectedIndex = useMemo( - () => (lastChunk?.id ? filteredChunks.findIndex((c) => c.id === lastChunk.id) : -1), - [lastChunk?.id, filteredChunks], + () => + selectedChunkId ? filteredChunks.findIndex((chunk) => chunk.id === selectedChunkId) : -1, + [selectedChunkId, filteredChunks], ) - const renderItem = useCallback>( + const renderItem = useCallback>( (chunk, {activeIndex}) => { const isFirst = activeIndex === 0 + return ( - + 0 ? ( + + ) : null + } /> {activeIndex === filteredChunks.length - 1 && hasMoreChunks && } ) }, - [filteredChunks, hasMoreChunks, onSelect, selectedIndex], + [ + expandedParents, + filteredChunks.length, + handleExpandParent, + hasMoreChunks, + onSelect, + selectedChunkId, + ], ) useEffect(() => setMounted(true), []) @@ -98,14 +145,14 @@ export const Timeline = ({ )} {filteredChunks.length > 0 && ( - + - } - menu={ - - - - } - /> - ) -} export interface TimelineItemProps { chunk: Chunk isSelected: boolean onSelect: (chunk: Chunk) => void - timestamp: string - type: ChunkType - /** - * Chunks that are squashed together on publish. - * e.g. all the draft mutations are squashed into a single `publish` chunk when the document is published. - */ - squashedChunks?: Chunk[] + collaborators?: Set + optionsMenu?: React.ReactNode } const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = { @@ -91,20 +63,14 @@ export function TimelineItem({ chunk, isSelected, onSelect, - timestamp, - type, - squashedChunks, + collaborators, + optionsMenu, }: TimelineItemProps) { const {t} = useTranslation('studio') - + const {type, endTimestamp: timestamp} = chunk const iconComponent = getTimelineEventIconComponent(type) const authorUserIds = Array.from(chunk.authors) - - // TODO: This will be part of future changes where we will show the history squashed when published - const collaborators = Array.from( - new Set(squashedChunks?.flatMap((c) => Array.from(c.authors)) || []), - ).filter((id) => !authorUserIds.includes(id)) - + const collaboratorsUsersIds = collaborators ? Array.from(collaborators) : [] const isSelectable = type !== 'delete' const dateFormat = useDateTimeFormat({dateStyle: 'medium', timeStyle: 'short'}) const date = new Date(timestamp) @@ -159,14 +125,14 @@ export function TimelineItem({ - {collaborators.length > 0 && ( + {collaboratorsUsersIds.length > 0 && ( - + )} - {squashedChunks && squashedChunks?.length > 1 ? : null} + {optionsMenu} ) } diff --git a/packages/sanity/src/structure/panes/document/timeline/timelineMenu.tsx b/packages/sanity/src/structure/panes/document/timeline/timelineMenu.tsx index b3547f898fd..f0268402866 100644 --- a/packages/sanity/src/structure/panes/document/timeline/timelineMenu.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/timelineMenu.tsx @@ -2,12 +2,13 @@ import {ChevronDownIcon} from '@sanity/icons' import { Flex, type Placement, + PortalProvider, Text, useClickOutsideEvent, useGlobalKeyDown, useToast, } from '@sanity/ui' -import {useCallback, useRef, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {type Chunk, useTimelineSelector, useTranslation} from 'sanity' import {styled} from 'styled-components' @@ -28,11 +29,14 @@ const Root = styled(Popover)` overflow: clip; ` +export const TIMELINE_MENU_PORTAL = 'timeline-menu' + export function TimelineMenu({chunk, mode, placement}: TimelineMenuProps) { const {setTimelineRange, setTimelineMode, timelineError, ready, timelineStore} = useDocumentPane() const [open, setOpen] = useState(false) const [button, setButton] = useState(null) - const popoverRef = useRef(null) + const [popoverRef, setPopoverRef] = useState(null) + const toast = useToast() const chunks = useTimelineSelector(timelineStore, (state) => state.chunks) @@ -64,7 +68,7 @@ export function TimelineMenu({chunk, mode, placement}: TimelineMenuProps) { ) useGlobalKeyDown(handleGlobalKeyDown) - useClickOutsideEvent(open && handleClose, () => [button, popoverRef.current]) + useClickOutsideEvent(open && handleClose, () => [button, popoverRef]) const selectRev = useCallback( (revChunk: Chunk) => { @@ -108,33 +112,44 @@ export function TimelineMenu({chunk, mode, placement}: TimelineMenuProps) { } }, [loading, timelineStore]) - const content = timelineError ? ( - - ) : ( - <> - {mode === 'rev' && ( + const content = useMemo(() => { + if (timelineError) return + + if (mode === 'rev') { + return ( - )} - {mode === 'since' && ( - - )} - - ) + ) + } + + const filteredChunks = realRevChunk + ? chunks.filter((c) => c.index < realRevChunk.index) + : chunks + return ( + + ) + }, [ + chunks, + handleLoadMore, + hasMoreChunks, + mode, + realRevChunk, + selectRev, + selectSince, + sinceTime, + timelineError, + ]) const formatParams = { timestamp: {dateStyle: 'medium', timeStyle: 'short'}, @@ -158,35 +173,38 @@ export function TimelineMenu({chunk, mode, placement}: TimelineMenuProps) { const buttonLabel = mode === 'rev' ? revLabel : sinceLabel return ( - - - + + + ) } diff --git a/packages/sanity/src/structure/panes/document/timeline/utils.test.ts b/packages/sanity/src/structure/panes/document/timeline/utils.test.ts new file mode 100644 index 00000000000..6c2c99a8b37 --- /dev/null +++ b/packages/sanity/src/structure/panes/document/timeline/utils.test.ts @@ -0,0 +1,247 @@ +import {describe, expect, it} from '@jest/globals' +import {type Chunk} from 'sanity' + +import {addChunksMetadata} from './utils' + +const chunks: Chunk[] = [ + { + index: 6, + id: 'z2633zRhBXUPVFxuhOgS3I', + type: 'publish', + start: 5, + end: 6, + startTimestamp: '2024-09-02T09:28:49.734Z', + endTimestamp: '2024-09-02T09:28:49.734Z', + authors: new Set(['author1']), + draftState: 'missing', + publishedState: 'present', + }, + { + index: 5, + id: '319b9969-9134-43db-912b-cf3c0082c2bc', + type: 'editDraft', + start: 1, + end: 5, + startTimestamp: '2024-09-02T09:28:34.522Z', + endTimestamp: '2024-09-02T09:28:39.049Z', + authors: new Set(['author1']), + draftState: 'present', + publishedState: 'present', + }, + { + index: 4, + id: '0181e905-db87-4a71-9b8d-dc61c3281686', + type: 'editDraft', + start: -1, + end: 1, + startTimestamp: '2024-08-29T12:28:01.286194Z', + endTimestamp: '2024-08-29T12:28:03.508054Z', + authors: new Set(['author2']), + draftState: 'present', + publishedState: 'present', + }, + { + index: 3, + id: 'oizpdYkKQhBxlL6mF9cm6g', + type: 'publish', + start: -2, + end: -1, + startTimestamp: '2024-08-28T07:42:56.954657Z', + endTimestamp: '2024-08-28T07:42:56.954657Z', + authors: new Set(['author3']), + + draftState: 'missing', + publishedState: 'present', + }, + { + index: 2, + id: '058afb19-b9f2-416a-b6a0-e02600f22d5c', + type: 'editDraft', + start: -5, + end: -2, + startTimestamp: '2024-08-21T18:50:46.872241Z', + endTimestamp: '2024-08-21T18:50:50.921116Z', + authors: new Set(['author1']), + draftState: 'present', + publishedState: 'unknown', + }, + { + index: 1, + id: 'a319e276-8fcb-463c-ad88-cc40d9bed20e', + type: 'editDraft', + start: -7, + end: -5, + startTimestamp: '2024-08-21T01:21:44.156523Z', + endTimestamp: '2024-08-21T01:21:45.599240Z', + authors: new Set(['author2']), + draftState: 'present', + publishedState: 'unknown', + }, + { + index: 0, + id: '1dc76dd9-c852-4e5d-b2a1-a4e0ea6bad9c', + type: 'editDraft', + start: -9, + end: -7, + startTimestamp: '2024-08-20T16:15:45.198871Z', + endTimestamp: '2024-08-20T16:15:47.960919Z', + authors: new Set(['author3']), + draftState: 'present', + publishedState: 'unknown', + }, + { + index: -1, + id: '@initial', + type: 'initial', + start: -9, + end: -9, + startTimestamp: '2024-08-20T16:15:45.198871Z', + endTimestamp: '2024-08-20T16:15:45.198871Z', + authors: new Set(['author0']), + draftState: 'present', + publishedState: 'unknown', + }, +] + +describe('Tests addChunksMetadata', () => { + it('should collapse the editDraft chunks into the single publish chunk', () => { + const collapsedChunks = addChunksMetadata(chunks) + expect(collapsedChunks).toMatchInlineSnapshot(` + Array [ + Object { + "authors": Set { + "author1", + }, + "children": Array [ + "319b9969-9134-43db-912b-cf3c0082c2bc", + "0181e905-db87-4a71-9b8d-dc61c3281686", + ], + "collaborators": Set { + "author2", + }, + "draftState": "missing", + "end": 6, + "endTimestamp": "2024-09-02T09:28:49.734Z", + "id": "z2633zRhBXUPVFxuhOgS3I", + "index": 6, + "publishedState": "present", + "start": 5, + "startTimestamp": "2024-09-02T09:28:49.734Z", + "type": "publish", + }, + Object { + "authors": Set { + "author1", + }, + "draftState": "present", + "end": 5, + "endTimestamp": "2024-09-02T09:28:39.049Z", + "id": "319b9969-9134-43db-912b-cf3c0082c2bc", + "index": 5, + "parentId": "z2633zRhBXUPVFxuhOgS3I", + "publishedState": "present", + "start": 1, + "startTimestamp": "2024-09-02T09:28:34.522Z", + "type": "editDraft", + }, + Object { + "authors": Set { + "author2", + }, + "draftState": "present", + "end": 1, + "endTimestamp": "2024-08-29T12:28:03.508054Z", + "id": "0181e905-db87-4a71-9b8d-dc61c3281686", + "index": 4, + "parentId": "z2633zRhBXUPVFxuhOgS3I", + "publishedState": "present", + "start": -1, + "startTimestamp": "2024-08-29T12:28:01.286194Z", + "type": "editDraft", + }, + Object { + "authors": Set { + "author3", + }, + "children": Array [ + "058afb19-b9f2-416a-b6a0-e02600f22d5c", + "a319e276-8fcb-463c-ad88-cc40d9bed20e", + "1dc76dd9-c852-4e5d-b2a1-a4e0ea6bad9c", + ], + "collaborators": Set { + "author1", + "author2", + }, + "draftState": "missing", + "end": -1, + "endTimestamp": "2024-08-28T07:42:56.954657Z", + "id": "oizpdYkKQhBxlL6mF9cm6g", + "index": 3, + "publishedState": "present", + "start": -2, + "startTimestamp": "2024-08-28T07:42:56.954657Z", + "type": "publish", + }, + Object { + "authors": Set { + "author1", + }, + "draftState": "present", + "end": -2, + "endTimestamp": "2024-08-21T18:50:50.921116Z", + "id": "058afb19-b9f2-416a-b6a0-e02600f22d5c", + "index": 2, + "parentId": "oizpdYkKQhBxlL6mF9cm6g", + "publishedState": "unknown", + "start": -5, + "startTimestamp": "2024-08-21T18:50:46.872241Z", + "type": "editDraft", + }, + Object { + "authors": Set { + "author2", + }, + "draftState": "present", + "end": -5, + "endTimestamp": "2024-08-21T01:21:45.599240Z", + "id": "a319e276-8fcb-463c-ad88-cc40d9bed20e", + "index": 1, + "parentId": "oizpdYkKQhBxlL6mF9cm6g", + "publishedState": "unknown", + "start": -7, + "startTimestamp": "2024-08-21T01:21:44.156523Z", + "type": "editDraft", + }, + Object { + "authors": Set { + "author3", + }, + "draftState": "present", + "end": -7, + "endTimestamp": "2024-08-20T16:15:47.960919Z", + "id": "1dc76dd9-c852-4e5d-b2a1-a4e0ea6bad9c", + "index": 0, + "parentId": "oizpdYkKQhBxlL6mF9cm6g", + "publishedState": "unknown", + "start": -9, + "startTimestamp": "2024-08-20T16:15:45.198871Z", + "type": "editDraft", + }, + Object { + "authors": Set { + "author0", + }, + "draftState": "present", + "end": -9, + "endTimestamp": "2024-08-20T16:15:45.198871Z", + "id": "@initial", + "index": -1, + "publishedState": "unknown", + "start": -9, + "startTimestamp": "2024-08-20T16:15:45.198871Z", + "type": "initial", + }, + ] + `) + }) +}) diff --git a/packages/sanity/src/structure/panes/document/timeline/utils.ts b/packages/sanity/src/structure/panes/document/timeline/utils.ts new file mode 100644 index 00000000000..12d7377a2c2 --- /dev/null +++ b/packages/sanity/src/structure/panes/document/timeline/utils.ts @@ -0,0 +1,83 @@ +import {type Chunk, type ChunkType} from 'sanity' + +export type NonPublishChunk = Omit & { + type: Exclude + parentId?: string +} + +export type PublishChunk = Omit & { + type: 'publish' + children: string[] + collaborators: Set +} + +export const isNonPublishChunk = (chunk: Chunk): chunk is NonPublishChunk => + chunk.type !== 'publish' + +export const isPublishChunk = (chunk: Chunk): chunk is PublishChunk => chunk.type === 'publish' + +/** + * searches for the previous publish action in the list of chunks + * e.g. chunks = [publish, edit, publish, edit, edit] it needs to return the second publish action + * e.g. chunks = [publish, edit, delete, edit, edit] it returns undefined + */ + +function getPreviousPublishAction(chunks: Chunk[]) { + let previousPublish: PublishChunk | null = null + // We need to iterate from the end to the start of the list + for (let index = chunks.length - 1; index >= 0; index--) { + const chunk = chunks[index] + if (isPublishChunk(chunk)) { + previousPublish = chunk + break + } + if (chunk.type === 'editDraft') { + continue + } else break + } + + return previousPublish +} +export type ChunksWithCollapsedDrafts = NonPublishChunk | PublishChunk + +/** + * Takes an array of chunks and adds them metadata necessary for the timeline view. + * for draft chunks, it will add the parentId of the published chunk if this draft action is now published + * for published, it will add the children array and the collaborators array + */ +export function addChunksMetadata(chunks: Chunk[]): ChunksWithCollapsedDrafts[] { + const result: ChunksWithCollapsedDrafts[] = [] + + for (const chunk of chunks) { + if (isPublishChunk(chunk)) { + result.push({ + ...chunk, + type: 'publish', + children: [], + collaborators: new Set(), // Initialize the collaborators array + }) + continue + } + if (isNonPublishChunk(chunk)) { + const previousPublish = getPreviousPublishAction(result) + if (chunk.type === 'editDraft' && previousPublish?.type === 'publish') { + Array.from(chunk.authors).forEach((id) => { + if (!previousPublish.authors.has(id)) { + previousPublish.collaborators.add(id) + } + }) + previousPublish.children.push(chunk.id) + result.push({ + ...chunk, + parentId: previousPublish.id, + }) + continue + } + } + if (isNonPublishChunk(chunk)) { + result.push(chunk) + } + } + + return result +}