From 53d19d95a07193d7afea9ab9609928c628c27ebc Mon Sep 17 00:00:00 2001 From: Pedro Bonamin <46196328+pedrobonamin@users.noreply.github.com> Date: Mon, 2 Sep 2024 18:19:11 +0200 Subject: [PATCH] feat(structure): update TimelineItem to match new UI, prepare for chunks (#7449) * feat(structure): update TimelineItem to match new UI, prepare for chunks * chore(core): export getCalendarLabels to support DateTimeInput reusability --- .../src/core/form/inputs/DateInputs/index.ts | 1 + .../src/core/form/inputs/DateInputs/utils.ts | 3 + .../sanity/src/structure/i18n/resources.ts | 7 + .../__workshop__/TimelineItemStory.tsx | 123 +++++++++++ .../document/timeline/__workshop__/index.ts | 5 + .../panes/document/timeline/constants.ts | 6 +- .../panes/document/timeline/timeline.tsx | 8 +- .../document/timeline/timelineItem.styled.tsx | 76 ------- .../panes/document/timeline/timelineItem.tsx | 193 +++++++++++------- .../document/timeline/userAvatarStack.tsx | 7 +- 10 files changed, 268 insertions(+), 161 deletions(-) create mode 100644 packages/sanity/src/structure/panes/document/timeline/__workshop__/TimelineItemStory.tsx delete mode 100644 packages/sanity/src/structure/panes/document/timeline/timelineItem.styled.tsx diff --git a/packages/sanity/src/core/form/inputs/DateInputs/index.ts b/packages/sanity/src/core/form/inputs/DateInputs/index.ts index f34d7d154b8..43635831208 100644 --- a/packages/sanity/src/core/form/inputs/DateInputs/index.ts +++ b/packages/sanity/src/core/form/inputs/DateInputs/index.ts @@ -1,2 +1,3 @@ export {DateInput, type DateInputProps} from './DateInput' export {DateTimeInput, type DateTimeInputProps} from './DateTimeInput' +export {getCalendarLabels} from './utils' diff --git a/packages/sanity/src/core/form/inputs/DateInputs/utils.ts b/packages/sanity/src/core/form/inputs/DateInputs/utils.ts index 82f7fca7309..e55c7987b41 100644 --- a/packages/sanity/src/core/form/inputs/DateInputs/utils.ts +++ b/packages/sanity/src/core/form/inputs/DateInputs/utils.ts @@ -4,6 +4,9 @@ export function isValidDate(date: Date): boolean { return date instanceof Date && !isNaN(date.valueOf()) } +/** + * @internal + */ export function getCalendarLabels( t: (key: string, values?: Record) => string, ): CalendarLabels { diff --git a/packages/sanity/src/structure/i18n/resources.ts b/packages/sanity/src/structure/i18n/resources.ts index bdb7affd8b8..e2e135822d2 100644 --- a/packages/sanity/src/structure/i18n/resources.ts +++ b/packages/sanity/src/structure/i18n/resources.ts @@ -481,6 +481,13 @@ const structureLocaleStrings = defineLocalesResources('structure', { 'structure-error.reload-button.text': 'Reload', /** Labels the structure path of the structure error screen */ 'structure-error.structure-path.label': 'Structure path', + + /** The aria label for the menu button in the timeline item */ + '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 expand action in the timeline item menu */ + 'timeline-item.menu.action-expand': 'Expand', }) /** diff --git a/packages/sanity/src/structure/panes/document/timeline/__workshop__/TimelineItemStory.tsx b/packages/sanity/src/structure/panes/document/timeline/__workshop__/TimelineItemStory.tsx new file mode 100644 index 00000000000..890deda5acb --- /dev/null +++ b/packages/sanity/src/structure/panes/document/timeline/__workshop__/TimelineItemStory.tsx @@ -0,0 +1,123 @@ +import {Box, Card, Container, Stack, Text} from '@sanity/ui' +import {useMemo, useState} from 'react' +import {type ChunkType, 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' + +const CHUNK_TYPES = Object.keys(TIMELINE_ITEM_I18N_KEY_MAPPING).reverse() as ChunkType[] + +export default function TimelineItemStory() { + const {t: coreT} = useTranslation() + const [date, setDate] = useState(() => new Date()) + const [selected, setSelected] = useState(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) { + setDate(newDate) + } else { + console.error('No date selected') + } + } + + return ( + + + + + Timeline Item + + + + + Select date: + + + + Update the selected date to see how the component behaves with relative dates. + + + + + + {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 + } + /> + ))} + + + + + ) +} diff --git a/packages/sanity/src/structure/panes/document/timeline/__workshop__/index.ts b/packages/sanity/src/structure/panes/document/timeline/__workshop__/index.ts index b6a69f398b4..1c0fae728ec 100644 --- a/packages/sanity/src/structure/panes/document/timeline/__workshop__/index.ts +++ b/packages/sanity/src/structure/panes/document/timeline/__workshop__/index.ts @@ -10,5 +10,10 @@ export default defineScope({ title: 'Default', component: lazy(() => import('./DefaultStory')), }, + { + name: 'timelineItem', + title: 'Timeline Item', + component: lazy(() => import('./TimelineItemStory')), + }, ], }) diff --git a/packages/sanity/src/structure/panes/document/timeline/constants.ts b/packages/sanity/src/structure/panes/document/timeline/constants.ts index 2758d159841..bd4bd93ea23 100644 --- a/packages/sanity/src/structure/panes/document/timeline/constants.ts +++ b/packages/sanity/src/structure/panes/document/timeline/constants.ts @@ -1,5 +1,5 @@ import { - AddCircleIcon, + AddIcon, CloseIcon, EditIcon, type IconComponent, @@ -9,10 +9,10 @@ import { } from '@sanity/icons' export const TIMELINE_ICON_COMPONENTS: {[key: string]: IconComponent | undefined} = { - create: AddCircleIcon, + create: AddIcon, delete: TrashIcon, discardDraft: CloseIcon, - initial: AddCircleIcon, + initial: AddIcon, editDraft: EditIcon, editLive: EditIcon, publish: PublishIcon, diff --git a/packages/sanity/src/structure/panes/document/timeline/timeline.tsx b/packages/sanity/src/structure/panes/document/timeline/timeline.tsx index 5e3296f318f..23348099ef1 100644 --- a/packages/sanity/src/structure/panes/document/timeline/timeline.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/timeline.tsx @@ -55,14 +55,10 @@ export const Timeline = ({ const renderItem = useCallback>( (chunk, {activeIndex}) => { const isFirst = activeIndex === 0 - const isLast = (filteredChunks && activeIndex === filteredChunks.length - 1) || false return ( - + ) }, - [disabledBeforeFirstChunk, filteredChunks, hasMoreChunks, onSelect, selectedIndex], + [filteredChunks, hasMoreChunks, onSelect, selectedIndex], ) useEffect(() => setMounted(true), []) diff --git a/packages/sanity/src/structure/panes/document/timeline/timelineItem.styled.tsx b/packages/sanity/src/structure/panes/document/timeline/timelineItem.styled.tsx deleted file mode 100644 index 5cb24ead58e..00000000000 --- a/packages/sanity/src/structure/panes/document/timeline/timelineItem.styled.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - Box, - // eslint-disable-next-line no-restricted-imports - Button, // Button with specific styling and children behavior. - Flex, - rem, -} from '@sanity/ui' -import {css, styled} from 'styled-components' - -export const IconWrapper = styled(Flex)(({theme}) => { - const borderColor = theme.sanity.color.base.skeleton?.from - - return css` - --timeline-hairline-width: 1px; - position: relative; - z-index: 2; - margin: 0; - padding: 0; - - &::before { - position: absolute; - content: ''; - height: 100%; - width: var(--timeline-hairline-width); - background: ${borderColor}; - top: 0; - left: calc((100% - var(--timeline-hairline-width)) / 2); - z-index: 1; - } - ` -}) - -export const Root = styled(Button)<{ - $selected: boolean - $disabled: boolean -}>(({$selected, $disabled}) => { - return css` - position: relative; - width: 100%; - - /* Line styling */ - &[data-first] ${IconWrapper}::before { - height: 50%; - top: unset; - bottom: 0; - } - - &[data-last] ${IconWrapper}::before { - height: 50%; - } - - ${$selected && - css` - ${IconWrapper}::before { - background: transparent; - } - `} - - ${$disabled && - css` - cursor: not-allowed; - `} - ` -}) - -export const IconBox = styled(Box)` - background: var(--card-bg-color); - border-radius: 50px; - position: relative; - z-index: 2; -` - -export const TimestampBox = styled(Box)` - min-width: 1rem; - margin-left: ${({theme}) => `-${rem(theme.sanity.space[1])}`}; -` diff --git a/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx b/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx index bd87834a504..79c3652bfa9 100644 --- a/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx @@ -1,52 +1,116 @@ -import {Box, Card, Flex, Stack, Text} from '@sanity/ui' +/* eslint-disable camelcase */ +import {Card, Flex, Menu, Stack, Text} from '@sanity/ui' +import {getTheme_v2, type ThemeColorAvatarColorKey} from '@sanity/ui/theme' import {createElement, type MouseEvent, useCallback, useMemo} from 'react' -import {type Chunk, type ChunkType, useDateTimeFormat, useTranslation} from 'sanity' +import { + type Chunk, + type ChunkType, + ContextMenuButton, + type RelativeTimeOptions, + useDateTimeFormat, + useRelativeTime, + useTranslation, +} from 'sanity' +import {css, styled} from 'styled-components' -import {type ButtonProps} from '../../../../ui-components' +import {MenuButton, MenuItem} from '../../../../ui-components' +import {structureLocaleNamespace} from '../../../i18n' import {getTimelineEventIconComponent} from './helpers' import {TIMELINE_ITEM_I18N_KEY_MAPPING} from './timelineI18n' -import {IconBox, IconWrapper, Root, TimestampBox} from './timelineItem.styled' import {UserAvatarStack} from './userAvatarStack' -const TIMELINE_ITEM_EVENT_TONE: Record = { - initial: 'primary', - create: 'primary', - publish: 'positive', - editLive: 'caution', - editDraft: 'caution', - unpublish: 'critical', - discardDraft: 'critical', - delete: 'critical', - withinSelection: 'primary', +export const IconBox = styled(Flex)<{$color: ThemeColorAvatarColorKey}>((props) => { + const theme = getTheme_v2(props.theme) + const color = props.$color + + return css` + --card-icon-color: ${theme.color.avatar[color].fg}; + background-color: ${theme.color.avatar[color].bg}; + box-shadow: 0 0 0 1px var(--card-bg-color); + + position: absolute; + width: ${theme.avatar.sizes[0].size}px; + height: ${theme.avatar.sizes[0].size}px; + right: -3px; + bottom: -3px; + border-radius: 50%; + ` +}) + +const TIMELINE_ITEM_EVENT_TONE: Record = { + initial: 'blue', + create: 'blue', + publish: 'green', + editLive: 'green', + editDraft: 'yellow', + unpublish: 'orange', + discardDraft: 'orange', + delete: 'red', + withinSelection: 'magenta', } -interface TimelineItemProps { +function TimelineItemMenu({chunk}: {chunk: Chunk}) { + const {t} = useTranslation(structureLocaleNamespace) + return ( + + } + menu={ + + + + } + /> + ) +} +export interface TimelineItemProps { chunk: Chunk - isFirst: boolean - isLast: boolean - isLatest: boolean 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[] +} + +const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = { + minimal: true, + useTemporalPhrase: true, } export function TimelineItem({ chunk, - isFirst, - isLast, - isLatest, isSelected, onSelect, timestamp, type, + squashedChunks, }: TimelineItemProps) { const {t} = useTranslation('studio') 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 isSelectable = type !== 'delete' const dateFormat = useDateTimeFormat({dateStyle: 'medium', timeStyle: 'short'}) + const date = new Date(timestamp) + + const updatedTimeAgo = useRelativeTime(date || '', RELATIVE_TIME_OPTIONS) + const formattedTimestamp = useMemo(() => { const parsedDate = new Date(timestamp) const formattedDate = dateFormat.format(parsedDate) @@ -55,7 +119,7 @@ export function TimelineItem({ }, [timestamp, dateFormat]) const handleClick = useCallback( - (evt: MouseEvent) => { + (evt: MouseEvent) => { evt.preventDefault() evt.stopPropagation() @@ -67,59 +131,42 @@ export function TimelineItem({ ) return ( - - - - - - {iconComponent && createElement(iconComponent)} + + + +
+ + + {iconComponent && createElement(iconComponent)} - - - - {isLatest && ( - - - - {t('timeline.latest')} - - - - )} - - - {t(TIMELINE_ITEM_I18N_KEY_MAPPING[type]) || {type}} - - - - - {formattedTimestamp} - - +
+ + + {t(TIMELINE_ITEM_I18N_KEY_MAPPING[type]) || {type}} + + + + {updatedTimeAgo} + - - - + + {collaborators.length > 0 && ( + + + + )}
-
-
+ + {squashedChunks && squashedChunks?.length > 1 ? : null} + ) } diff --git a/packages/sanity/src/structure/panes/document/timeline/userAvatarStack.tsx b/packages/sanity/src/structure/panes/document/timeline/userAvatarStack.tsx index d64f97582ed..cda0adf8091 100644 --- a/packages/sanity/src/structure/panes/document/timeline/userAvatarStack.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/userAvatarStack.tsx @@ -1,14 +1,15 @@ -import {AvatarStack} from '@sanity/ui' +import {type AvatarSize, AvatarStack} from '@sanity/ui' import {UserAvatar} from 'sanity' interface UserAvatarStackProps { maxLength?: number userIds: string[] + size?: AvatarSize } -export function UserAvatarStack({maxLength, userIds}: UserAvatarStackProps) { +export function UserAvatarStack({maxLength, userIds, size}: UserAvatarStackProps) { return ( - + {userIds.map((userId) => ( ))}