From 21db4b8f8eaac63bd132011690a46c4d15ec74dd Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Fri, 6 Dec 2024 13:36:01 +0100 Subject: [PATCH] feat(core): add release actions list --- .../components/layout/resizer/Resizable.tsx | 28 ++- .../src/core/releases/i18n/resources.ts | 1 + .../sanity/src/core/releases/store/types.ts | 1 + .../releases/tool/components/Table/Table.tsx | 2 +- .../tool/components/Table/TableHeader.tsx | 2 +- .../detail/ReleaseDashboardActivityPanel.tsx | 123 ++++++++----- .../releases/tool/detail/ReleaseDetail.tsx | 15 +- .../detail/activity/getReleaseEditEvents.ts | 165 ++++++++++++++++++ .../releases/tool/detail/activity/types.ts | 23 ++- .../detail/activity/useReleaseActivity.ts | 44 ++++- 10 files changed, 336 insertions(+), 68 deletions(-) create mode 100644 packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts diff --git a/packages/sanity/src/core/form/studio/tree-editing/components/layout/resizer/Resizable.tsx b/packages/sanity/src/core/form/studio/tree-editing/components/layout/resizer/Resizable.tsx index ef695fc4e1dc..8c9849f9b1c8 100644 --- a/packages/sanity/src/core/form/studio/tree-editing/components/layout/resizer/Resizable.tsx +++ b/packages/sanity/src/core/form/studio/tree-editing/components/layout/resizer/Resizable.tsx @@ -8,6 +8,7 @@ export interface ResizableProps { minWidth: number maxWidth: number initialWidth: number + resizerPosition?: 'left' | 'right' } const Root = styled(Box)` @@ -19,7 +20,15 @@ const Root = styled(Box)` export function Resizable( props: ResizableProps & BoxProps & Omit, 'as'>, ) { - const {as: forwardedAs, children, minWidth, maxWidth, initialWidth, ...restProps} = props + const { + as: forwardedAs, + children, + minWidth, + maxWidth, + initialWidth, + resizerPosition = 'right', + ...restProps + } = props const [element, setElement] = useState(null) const elementWidthRef = useRef() const [targetWidth, setTargetWidth] = useState(initialWidth) @@ -31,12 +40,14 @@ export function Resizable( const handleResize = useCallback( (deltaX: number) => { const w = elementWidthRef.current - if (!w) return - - setTargetWidth(Math.min(Math.max(w - deltaX, minWidth), maxWidth)) + if (resizerPosition === 'right') { + setTargetWidth(Math.min(Math.max(w - deltaX, minWidth), maxWidth)) + } else { + setTargetWidth(Math.min(Math.max(w + deltaX, minWidth), maxWidth)) + } }, - [minWidth, maxWidth], + [minWidth, maxWidth, resizerPosition], ) const style = useMemo( @@ -46,8 +57,13 @@ export function Resizable( return ( + {resizerPosition === 'left' && ( + + )} {children} - + {resizerPosition === 'right' && ( + + )} ) } diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts index 61421c23731f..140a750afd66 100644 --- a/packages/sanity/src/core/releases/i18n/resources.ts +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -45,6 +45,7 @@ const releasesLocaleStrings = { 'created the {{releaseTitle}} release targeting ', /* The text for the activity event when a document is removed from a release */ 'activity.event.discard-document': 'discarded a document version', + 'activity.event.edit': 'set release time to ', /* The text for the activity event when the release is published */ 'activity.event.publish': 'published the {{releaseTitle}} release', /* The text for the activity event when the release is scheduled */ diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts index 210bc97c1a3b..f0e620c46670 100644 --- a/packages/sanity/src/core/releases/store/types.ts +++ b/packages/sanity/src/core/releases/store/types.ts @@ -66,6 +66,7 @@ export type EditableReleaseDocument = Omit< PartialExcept, 'metadata' | '_type' > & { + _id: string metadata: Partial } diff --git a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx index 6e8a5ef9bf1f..ed196f6ed569 100644 --- a/packages/sanity/src/core/releases/tool/components/Table/Table.tsx +++ b/packages/sanity/src/core/releases/tool/components/Table/Table.tsx @@ -195,7 +195,7 @@ const TableInner = ({ height: `${datum.virtualRow.size}px`, transform: `translateY(${datum.virtualRow.start - datum.index * datum.virtualRow.size}px)`, paddingInline: `max( - calc((100vw - var(--maxInlineSize)) / 2), + calc((100% - var(--maxInlineSize)) / 2), var(--paddingInline) )`, }} diff --git a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx index 52b81df94205..c2cf8e2925d6 100644 --- a/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx +++ b/packages/sanity/src/core/releases/tool/components/Table/TableHeader.tsx @@ -83,7 +83,7 @@ export const TableHeader = ({headers, searchDisabled}: TableHeaderProps) => { as="tr" style={{ paddingInline: `max( - calc((100vw - var(--maxInlineSize)) / 2), + calc((100% - var(--maxInlineSize)) / 2), var(--paddingInline) )`, }} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx index eadd6b5237d6..9a27864723a6 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx @@ -1,10 +1,12 @@ import {Box, Card, Flex, Stack, Text} from '@sanity/ui' +import {AnimatePresence, motion} from 'framer-motion' import {useMemo} from 'react' import {styled} from 'styled-components' import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' import {RelativeTime} from '../../../components/RelativeTime' import {UserAvatar} from '../../../components/userAvatar/UserAvatar' +import {Resizable} from '../../../form/studio/tree-editing/components/layout/resizer' import {useDateTimeFormat} from '../../../hooks/useDateTimeFormat' import {Translate, useTranslation} from '../../../i18n' import {useDocumentPreviewValues} from '../../../tasks/hooks' @@ -17,6 +19,7 @@ import { isAddDocumentToReleaseEvent, isCreateReleaseEvent, isDiscardDocumentFromReleaseEvent, + isEditReleaseEvent, isScheduleReleaseEvent, type ReleaseEvent, } from './activity/types' @@ -40,27 +43,9 @@ const ACTIVITY_TEXT_118N: Record = { ScheduleRelease: 'activity.event.schedule', UnarchiveRelease: 'activity.event.unarchive', UnscheduleRelease: 'activity.event.unschedule', + releaseEditEvent: 'activity.event.edit', } -// Missing: -// - ReleaseSetTargetDate -// -// /* -// set release time to{' '} -// {event.timing === 'future' ? ( -// {format(event.time || 0, `PPpp`)} -// ) : event.timing === 'immediately' ? ( -// immediately -// ) : ( -// never -// )}{' '} -// ·{' '} -// -// {shortTimeSince(event.timestamp)} -// -// */ -// - const ReleaseEventDocumentPreview = ({ event, release, @@ -90,26 +75,48 @@ const ReleaseEventDocumentPreview = ({ const ScheduleTarget = ({children, event}: {children: React.ReactNode; event: ReleaseEvent}) => { const dateTimeFormat = useDateTimeFormat({dateStyle: 'full', timeStyle: 'medium'}) - const dateString = - isScheduleReleaseEvent(event) || isCreateReleaseEvent(event) ? event.publishAt : null const formattedDate = useMemo(() => { + if (isEditReleaseEvent(event)) { + if (event.change.releaseType === 'asap') return 'immediately' + if (event.change.releaseType === 'undecided') return 'never' + } + + let dateString: string | undefined + if (isScheduleReleaseEvent(event)) { + dateString = event.publishAt + } else if (isCreateReleaseEvent(event)) { + dateString = event.change?.intendedPublishDate + } else if (isEditReleaseEvent(event)) { + dateString = event.change.intendedPublishDate + } + if (!dateString) return null return dateTimeFormat.format(new Date(dateString)) - }, [dateString, dateTimeFormat]) + }, [dateTimeFormat, event]) - if (!formattedDate) return null + if (!formattedDate && isCreateReleaseEvent(event)) return null return ( - {children} {formattedDate} + {children} {formattedDate || '---'} ) } +const FadeInCard = motion(Card) const ActivityItem = ({event, release}: {event: ReleaseEvent; release: ReleaseDocument}) => { const {t} = useTranslation(releasesLocaleNamespace) return ( - + component wrapping the list) + initial={{height: 0, opacity: 0}} + animate={{height: 'auto', opacity: 1}} + transition={{type: 'spring', bounce: 0, duration: 0.4}} + > @@ -133,35 +140,69 @@ const ActivityItem = ({event, release}: {event: ReleaseEvent; release: ReleaseDo ) : null} - + ) } interface ReleaseDashboardActivityPanelProps { activity: ReleaseActivity release: ReleaseDocument + show: boolean } +const MotionFlex = motion(Flex) +const FillHeight = styled.div` + height: 100%; + display: flex; + flex-direction: column; +` export function ReleaseDashboardActivityPanel({ activity, release, + show, }: ReleaseDashboardActivityPanelProps) { const {t} = useTranslation(releasesLocaleNamespace) return ( - - - - {t('activity.panel.title')} - - - {activity.loading && !activity.events.length && ( - + + {show && ( + <> + + + + + + + {t('activity.panel.title')} + + + {activity.loading && !activity.events.length && ( + + )} + {/* TODO: Virtualise this list and add scroll parent */} + + + {activity.events.map((event) => ( + + ))} + + + + + + + )} - {/* TODO: Virtualise this list and add scroll parent */} - - {activity.events.map((event) => ( - - ))} - - + ) } diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx index b69d2fd6a896..1e6b8905cb84 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx @@ -122,7 +122,7 @@ export const ReleaseDetail = () => { if (releaseInDetail) { return ( - + { /> - {inspector === 'activity' && ( - <> - - - - - - )} + ) diff --git a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts new file mode 100644 index 000000000000..b506b874dc92 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts @@ -0,0 +1,165 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' +import { + filter, + from, + map, + type Observable, + of, + scan, + shareReplay, + startWith, + switchMap, + tap, +} from 'rxjs' + +import {getJsonStream} from '../../../../store/_legacy/history/history/getJsonStream' +import {type ReleasesReducerState} from '../../../store/reducer' +import {buildReleaseEditEvents} from './buildReleaseEditEvents' +import {type CreateReleaseEvent, type EditReleaseEvent} from './types' + +const TRANSLOG_ENTRY_LIMIT = 100 + +const documentTransactionsCache: Record = + Object.create(null) + +export async function getReleaseTransactions({ + documentId, + client, + toTransaction, +}: { + documentId: string + client: SanityClient + toTransaction?: string +}): Promise { + const cacheKey = `${documentId}` + let fromTransaction: string | undefined + const cachedTransactions = documentTransactionsCache[cacheKey] || [] + if (cachedTransactions.length > 0) { + if (cachedTransactions[0].id === toTransaction) { + return cachedTransactions + } + // Assign the last cached transaction as the from, we can use that as the entry point in the translog and not fetch unnecessary elements. + fromTransaction = cachedTransactions[0].id + } + const clientConfig = client.config() + const dataset = clientConfig.dataset + + const queryParams = new URLSearchParams({ + tag: 'sanity.studio.release.history', + effectFormat: 'mendoza', + excludeContent: 'true', + includeIdentifiedDocumentsOnly: 'true', + limit: TRANSLOG_ENTRY_LIMIT.toString(), + reverse: 'true', + }) + if (fromTransaction) { + queryParams.append('fromTransaction', fromTransaction) + } + if (toTransaction) { + queryParams.append('toTransaction', toTransaction) + } + + const transactionsUrl = client.getUrl( + `/data/history/${dataset}/transactions/${documentId}?${queryParams.toString()}`, + ) + const transactions: TransactionLogEventWithEffects[] = [] + + const stream = await getJsonStream(transactionsUrl, clientConfig.token) + const reader = stream.getReader() + let count = 0 + for (;;) { + // eslint-disable-next-line no-await-in-loop + const result = await reader.read() + if (result.done) break + + if ('error' in result.value) { + throw new Error(result.value.error.description || result.value.error.type) + } + transactions.push(result.value) + count++ + } + if (count === TRANSLOG_ENTRY_LIMIT) { + // // We have received the max values, we need to fetch the next batch. + // // TODO: Validate this loading more + // const nextTransactions = await getReleaseTransactions({ + // documentId, + // client, + // toTransaction: transactions[transactions.length - 1].id, + // }) + // return transactions.concat(nextTransactions) + // } + } + + documentTransactionsCache[cacheKey] = transactions.concat( + cachedTransactions.filter( + (cached) => !transactions.find((transaction) => transaction.id === cached.id), + ), + ) + return documentTransactionsCache[cacheKey] +} + +interface getReleaseActivityEventsOpts { + client: SanityClient + releaseId?: string + releasesState$: Observable +} +export const EDITS_EVENTS_INITIAL_VALUE: { + editEvents: (EditReleaseEvent | CreateReleaseEvent)[] + loading: boolean +} = { + editEvents: [], + loading: true, +} +export function getReleaseEditEvents({ + client, + releaseId, + releasesState$, +}: getReleaseActivityEventsOpts): { + editEvents$: Observable<{ + editEvents: (EditReleaseEvent | CreateReleaseEvent)[] + loading: boolean + }> +} { + if (!releaseId) { + return { + editEvents$: of({editEvents: [], loading: false}), + } + } + let lastRevProcessed = '' + return { + editEvents$: releasesState$.pipe( + map((releasesState) => releasesState.releases.get(releaseId)), + // Remove the undefined releases + filter(Boolean), + // ReleaseState$ is changing a lot, we only need to update this if the `_rev` changes + filter((release) => lastRevProcessed !== release._rev), + tap((release) => { + lastRevProcessed = release._rev + }), + switchMap((release) => { + return from( + getReleaseTransactions({ + client, + documentId: releaseId, + toTransaction: release?._rev, + }), + ).pipe( + map((transactions) => { + return {editEvents: buildReleaseEditEvents(transactions, release), loading: false} + }), + startWith(EDITS_EVENTS_INITIAL_VALUE), + ) + }), + scan((acc, current) => { + // Accumulate edit events from previous state + const editEvents = current.loading + ? acc.editEvents // Preserve previous events while loading + : current.editEvents // Update with new events when available + + return {...current, editEvents} + }, EDITS_EVENTS_INITIAL_VALUE), + shareReplay(1), + ), + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/activity/types.ts b/packages/sanity/src/core/releases/tool/detail/activity/types.ts index 6f118a4e594f..a78329d8362d 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/types.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/types.ts @@ -1,3 +1,5 @@ +import {type ReleaseType} from '../../../store' + export type ReleaseEvent = | CreateReleaseEvent | ScheduleReleaseEvent @@ -7,11 +9,11 @@ export type ReleaseEvent = | UnarchiveReleaseEvent | AddDocumentToReleaseEvent | DiscardDocumentFromReleaseEvent + | EditReleaseEvent -type EventType = ReleaseEvent['type'] +export type EventType = ReleaseEvent['type'] export interface BaseEvent { - type: EventType timestamp: string author: string releaseName: string @@ -20,13 +22,12 @@ export interface BaseEvent { export interface CreateReleaseEvent extends BaseEvent { type: 'CreateRelease' - // TODO: Can we add this from the API? - publishAt?: string + change?: Change } export interface ScheduleReleaseEvent extends BaseEvent { type: 'ScheduleRelease' - publishAt: Date + publishAt: string } export interface UnscheduleReleaseEvent extends BaseEvent { @@ -60,6 +61,16 @@ export interface DiscardDocumentFromReleaseEvent extends BaseEvent { versionRevisionId: string } +interface Change { + intendedPublishDate?: string + releaseType?: ReleaseType +} +export interface EditReleaseEvent extends BaseEvent { + type: 'releaseEditEvent' + isCreationEvent?: boolean + change: Change +} + // Type guards export const isCreateReleaseEvent = (event: ReleaseEvent): event is CreateReleaseEvent => event.type === 'CreateRelease' @@ -79,3 +90,5 @@ export const isAddDocumentToReleaseEvent = ( export const isDiscardDocumentFromReleaseEvent = ( event: ReleaseEvent, ): event is DiscardDocumentFromReleaseEvent => event.type === 'DiscardDocumentFromRelease' +export const isEditReleaseEvent = (event: ReleaseEvent): event is EditReleaseEvent => + event.type === 'releaseEditEvent' diff --git a/packages/sanity/src/core/releases/tool/detail/activity/useReleaseActivity.ts b/packages/sanity/src/core/releases/tool/detail/activity/useReleaseActivity.ts index cf811051796a..8037b7749c1c 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/useReleaseActivity.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/useReleaseActivity.ts @@ -2,9 +2,11 @@ import {useEffect, useMemo, useRef} from 'react' import {useObservable} from 'react-rx' import {DEFAULT_STUDIO_CLIENT_OPTIONS, type ReleaseDocument, useClient} from 'sanity' +import {useReleasesStore} from '../../../store/useReleasesStore' import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' import {getReleaseActivityEvents, RELEASE_ACTIVITY_INITIAL_VALUE} from './getReleaseActivityEvents' -import {type ReleaseEvent} from './types' +import {EDITS_EVENTS_INITIAL_VALUE, getReleaseEditEvents} from './getReleaseEditEvents' +import {isCreateReleaseEvent, type ReleaseEvent} from './types' export interface ReleaseActivity { events: ReleaseEvent[] @@ -24,15 +26,26 @@ export function useReleaseActivity({ }): ReleaseActivity { const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) const documentsCount = useRef(null) + const {state$: releasesState$} = useReleasesStore() + const releaseId = release?._id + const releaseRev = useRef(release?._rev || null) const {events$, reloadEvents, loadMore} = useMemo( () => getReleaseActivityEvents({ client, - releaseId: release?._id ? getReleaseIdFromReleaseDocumentId(release._id) : undefined, + releaseId: releaseId ? getReleaseIdFromReleaseDocumentId(releaseId) : undefined, }), - [client, release?._id], + [client, releaseId], + ) + + const {events, loading, error} = useObservable(events$, RELEASE_ACTIVITY_INITIAL_VALUE) + const {editEvents$} = useMemo( + () => getReleaseEditEvents({client, releaseId, releasesState$}), + [releaseId, client, releasesState$], ) + const {editEvents} = useObservable(editEvents$, EDITS_EVENTS_INITIAL_VALUE) + useEffect(() => { // Wait until the documents are loaded if (releaseDocumentsLoading) return @@ -51,6 +64,7 @@ export function useReleaseActivity({ } }, [releaseDocumentsCount, releaseDocumentsLoading, reloadEvents]) + // TODO: Move this to the observable by using the releasesState$ useEffect(() => { // Wait until the release exists if (!release?._rev) return @@ -68,7 +82,27 @@ export function useReleaseActivity({ } }, [release?._rev, reloadEvents]) - const {events, loading, error} = useObservable(events$, RELEASE_ACTIVITY_INITIAL_VALUE) + const allEvents = useMemo(() => { + return [...events, ...editEvents] + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + .reduce((acc: ReleaseEvent[], event) => { + if (isCreateReleaseEvent(event)) { + // Check if the creation event exists, we want to show only one, prefer the one that has the "changes" object + const creationEvent = acc.find(isCreateReleaseEvent) + // The creation event with the "change" will come from the edit events, we want that one as it has more info. + if (!creationEvent) acc.push(event) + else if (!creationEvent.change && event.change) { + acc[acc.indexOf(creationEvent)] = event + } + } else acc.push(event) + return acc + }, []) + }, [editEvents, events]) - return {events, loadMore, loading, error} + return { + events: allEvents, + loadMore, + loading, + error, + } }