diff --git a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx index 3af229b59ee..b4fc881b8c0 100644 --- a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx +++ b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx @@ -5,14 +5,36 @@ import { type AvatarProps, type AvatarSize, type AvatarStatus, + Skeleton, } from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' import {type ForwardedRef, forwardRef, useState} from 'react' +import {css, styled} from 'styled-components' import {Tooltip} from '../../../ui-components' import {useUser} from '../../store' import {useUserColor} from '../../user-color' import {isRecord} from '../../util' +interface AvatarSkeletonProps { + size?: AvatarSize +} + +/** + * A loading skeleton element representing a user avatar + * @beta + */ +export const AvatarSkeleton = styled(Skeleton)((props) => { + const theme = getTheme_v2(props.theme) + const size = props.size ?? 1 + return css` + border-radius: 50%; + width: ${theme.avatar.sizes[size].size}px; + height: ${theme.avatar.sizes[size].size}px; + ` +}) + /** * @hidden * @beta */ 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 ef695fc4e1d..8c9849f9b1c 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/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts index 0c1be69450c..8cb5f5774dc 100644 --- a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -9,10 +9,10 @@ function omitRev(document: SanityDocument | undefined) { return doc } -export function applyMendozaPatch( - document: SanityDocument | undefined, +export function applyMendozaPatch( + document: T, patch: RawPatch, -): SanityDocument | undefined { +): T { const next = applyPatch(omitRev(document), patch) return next === null ? undefined : next } diff --git a/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts index e87f02dcdf9..5e6cad2bb77 100644 --- a/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts +++ b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts @@ -1,6 +1,7 @@ import {type ReleaseDocument} from '../store/types' export const activeScheduledRelease: ReleaseDocument = { + _rev: 'activeRev', _id: '_.releases.activeRelease', _type: 'system.release', createdBy: '', @@ -17,6 +18,7 @@ export const activeScheduledRelease: ReleaseDocument = { } export const scheduledRelease: ReleaseDocument = { + _rev: 'scheduledRev', _id: '_.releases.scheduledRelease', _type: 'system.release', createdBy: '', @@ -34,6 +36,7 @@ export const scheduledRelease: ReleaseDocument = { } export const activeASAPRelease: ReleaseDocument = { + _rev: 'activeASAPRev', _id: '_.releases.activeASAPRelease', _type: 'system.release', createdBy: '', @@ -49,6 +52,7 @@ export const activeASAPRelease: ReleaseDocument = { } export const archivedScheduledRelease: ReleaseDocument = { + _rev: 'archivedRev', _id: '_.releases.archivedRelease', _type: 'system.release', createdBy: '', @@ -65,6 +69,7 @@ export const archivedScheduledRelease: ReleaseDocument = { } export const publishedASAPRelease: ReleaseDocument = { + _rev: 'publishedRev', _id: '_.releases.publishedRelease', _type: 'system.release', createdBy: '', @@ -82,6 +87,7 @@ export const publishedASAPRelease: ReleaseDocument = { } export const activeUndecidedRelease: ReleaseDocument = { + _rev: 'undecidedRev', _id: '_.releases.undecidedRelease', _type: 'system.release', createdBy: '', diff --git a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts index b7653e7f7ff..0ab29c4c81c 100644 --- a/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts +++ b/packages/sanity/src/core/releases/hooks/__tests__/utils.test.ts @@ -17,6 +17,7 @@ function createReleaseMock( const name = getReleaseIdFromReleaseDocumentId(id) return { _id: id, + _rev: 'rev', _type: RELEASE_DOCUMENT_TYPE, _createdAt: new Date().toISOString(), _updatedAt: new Date().toISOString(), diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts index 07f2a1c9220..2ab3c88a6c5 100644 --- a/packages/sanity/src/core/releases/i18n/resources.ts +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -39,6 +39,37 @@ const releasesLocaleStrings = { /** Header for the dialog confirming the archive of a release */ 'archive-dialog.confirm-archive-header': "Are you sure you want to archive the '{{title}}' release?", + + /* The text for the activity event when a document is added to a release */ + 'activity.event.add-document': 'added a document version', + /* The text for the activity event when the release is archived */ + 'activity.event.archive': 'archived the {{releaseTitle}} release', + /* The text for the activity event when the release is created */ + 'activity.event.create': + '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 to display in the changes when the release type changes to asap */ + 'activity.event.edit-time-asap': 'immediately', + /**The text to display in the changes when the release type changes to undecided */ + 'activity.event.edit-time-undecided': 'never', + /* 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 */ + 'activity.event.schedule': 'marked as scheduled', + /** The text for the activity event when the release is unarchived */ + 'activity.event.unarchive': 'unarchived the {{releaseTitle}} release', + /** The text for the activity event when the release is unscheduled */ + 'activity.event.unschedule': 'marked as unscheduled', + /** The loading text for when releases are loading */ + 'activity.panel.loading': 'Loading release activity', + /** The title for the activity panel shown in the releases detail screen */ + 'activity.panel.title': 'Activity', + + /** Title for the dialog confirming the archive of a release */ + 'archive-dialog.confirm-archive-title': + "Are you sure you want to archive the '{{title}}' release?", /** Description for the dialog confirming the archive of a release with one document */ 'archive-dialog.confirm-archive-description_one': 'This will archive 1 document version.', /** Description for the dialog confirming the archive of a release with more than one document */ @@ -111,7 +142,8 @@ const releasesLocaleStrings = { 'footer.status.edited': 'Edited', /**The text that will be shown in the footer to indicate the time the release was published */ 'footer.status.published': 'Published', - + /**The text that will be shown in the footer to indicate the time the release was unarchived */ + 'footer.status.unarchived': 'Unarchived', /** Label text for the loading state whilst release is being loaded */ 'loading-release': 'Loading release', diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts index d8410200742..9e2bc391557 100644 --- a/packages/sanity/src/core/releases/store/types.ts +++ b/packages/sanity/src/core/releases/store/types.ts @@ -1,3 +1,4 @@ +import {type SanityDocument} from '@sanity/types' import {type Dispatch} from 'react' import {type Observable} from 'rxjs' @@ -26,7 +27,7 @@ export type ReleaseFinalDocumentState = { * TODO: When made `beta`, update the PublishDocumentVersionEvent to use this type * @internal */ -export interface ReleaseDocument { +export interface ReleaseDocument extends SanityDocument { /** * typically * _.releases. @@ -35,10 +36,13 @@ export interface ReleaseDocument { _type: typeof RELEASE_DOCUMENT_TYPE _createdAt: string _updatedAt: string + _rev: string /** * The same as the last path segment of the _id, added by the backend. */ + // TODO: Remove this, we want to force the use of `getReleaseIdFromReleaseDocumentId` name: string + // TODO: Remove this is not part of the API response createdBy: string state: ReleaseState finalDocumentStates?: ReleaseFinalDocumentState[] @@ -63,6 +67,7 @@ export type EditableReleaseDocument = Omit< PartialExcept, 'metadata' | '_type' > & { + _id: string metadata: Partial } diff --git a/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx index 09c10d7b7b4..f87a4157deb 100644 --- a/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx +++ b/packages/sanity/src/core/releases/tool/components/ReleaseDocumentPreview.tsx @@ -3,6 +3,7 @@ import {Card} from '@sanity/ui' import {type ForwardedRef, forwardRef, useMemo} from 'react' import {IntentLink} from 'sanity/router' +import {type PreviewLayoutKey} from '../../../components/previews/types' import {DocumentPreviewPresence} from '../../../presence' import {SanityDefaultPreview} from '../../../preview/components/SanityDefaultPreview' import {getPublishedId} from '../../../util/draftUtils' @@ -17,6 +18,8 @@ interface ReleaseDocumentPreviewProps { isLoading: boolean releaseState?: ReleaseState documentRevision?: string + hasValidationError?: boolean + layout?: PreviewLayoutKey } export function ReleaseDocumentPreview({ @@ -27,6 +30,8 @@ export function ReleaseDocumentPreview({ isLoading, releaseState, documentRevision, + layout, + hasValidationError, }: ReleaseDocumentPreviewProps) { const documentPresence = useDocumentPresence(documentId) @@ -78,7 +83,12 @@ export function ReleaseDocumentPreview({ return ( - + ) } diff --git a/packages/sanity/src/core/releases/tool/components/StatusItem.tsx b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx index d715a4a1e53..ebacb13ca36 100644 --- a/packages/sanity/src/core/releases/tool/components/StatusItem.tsx +++ b/packages/sanity/src/core/releases/tool/components/StatusItem.tsx @@ -1,11 +1,11 @@ import {Box, Card, Flex, Text} from '@sanity/ui' import {type ReactNode} from 'react' -export function StatusItem(props: {avatar?: ReactNode; text: ReactNode}) { - const {avatar, text} = props +export function StatusItem(props: {avatar?: ReactNode; text: ReactNode; testId?: string}) { + const {avatar, text, testId} = props return ( - + {avatar && ( 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 6e8a5ef9bf1..ed196f6ed56 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 52b81df9420..c2cf8e2925d 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/ReleaseActivityList.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityList.tsx new file mode 100644 index 00000000000..8f5adafee84 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityList.tsx @@ -0,0 +1,147 @@ +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 + +import {Box} from '@sanity/ui' +import {useVirtualizer} from '@tanstack/react-virtual' +import {AnimatePresence} from 'framer-motion' +import {useEffect, useMemo, useRef} from 'react' +import {styled} from 'styled-components' + +import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' +import { + isAddDocumentToReleaseEvent, + isDiscardDocumentFromReleaseEvent, + isEventsAPIEvent, + isTranslogEvent, + type ReleaseEvent, +} from './events/types' +import {ReleaseActivityListItem} from './ReleaseActivityListItem' + +const estimateSize = (event: ReleaseEvent | undefined) => { + if (!event) { + return 40 // Is the loader row + } + if (isAddDocumentToReleaseEvent(event) || isDiscardDocumentFromReleaseEvent(event)) { + return 70 + } + return 56 +} +const VirtualContainer = styled(Box)` + height: 100%; + overflow: scroll; +` + +interface ReleaseActivityListProps { + events: ReleaseEvent[] + releaseTitle: string + releaseId: string + hasMore: boolean + loadMore: () => void + isLoading: boolean +} +export const ReleaseActivityList = ({ + events, + releaseTitle, + releaseId, + hasMore, + loadMore, + isLoading, +}: ReleaseActivityListProps) => { + const virtualizerContainerRef = useRef(null) + + const listEvents: ReleaseEvent[] = useMemo(() => { + /** + * This list combines: + * - API events, which are loaded incrementally (paginated) + * - Translog events, which are fully available (non-paginated) + * + * We want to display all events up to the oldest API event and include any translog events + * that occurred before that API event. By doing so, as we load older batches of API events, + * they will show at the bottom of the list + */ + + // If all events are loaded (no more pages) and we’re not loading, just return all events. + if (!hasMore && !isLoading) return events + + const lastEventFromEventsAPI = [...events].reverse().find(isEventsAPIEvent) + // If no API events are found (e.g., events api is not enabled) and we're not loading, return all translog events. + if (!lastEventFromEventsAPI && !isLoading) return events + + // If we haven’t found any API events yet and are still loading, show nothing for now. + if (!lastEventFromEventsAPI) return [] + + // Include only those translog events that occur before the newest API event. + const lastEventDate = new Date(lastEventFromEventsAPI.timestamp) + return events.filter((event) => { + if (isTranslogEvent(event)) { + return new Date(event.timestamp) > lastEventDate + } + return true + }) + }, [events, hasMore, isLoading]) + + const virtualizer = useVirtualizer({ + // If we have more events, or the events are loading, we add a loader row at the end + count: hasMore || isLoading ? listEvents.length + 1 : listEvents.length, + getScrollElement: () => virtualizerContainerRef.current, + estimateSize: (i) => estimateSize(events[i]), + overscan: 10, + paddingEnd: 24, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems.at(-1) + if (!lastItem) return + if (lastItem.index >= listEvents.length - 1 && hasMore) { + loadMore() + } + }, [listEvents.length, hasMore, loadMore, virtualItems]) + + return ( + +
+ + {virtualizer.getVirtualItems().map((virtualRow) => { + const event = listEvents[virtualRow.index] + const isLoaderRow = !event + + return ( +
+ {isLoaderRow ? ( + + + + ) : ( + + )} +
+ ) + })} +
+
+
+ ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx new file mode 100644 index 00000000000..fdd4defdc3b --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx @@ -0,0 +1,157 @@ +import {Card, Flex, Stack, Text} from '@sanity/ui' +import {motion} from 'framer-motion' +import {memo, type ReactNode, useMemo} from 'react' +import {styled} from 'styled-components' + +import {RelativeTime} from '../../../components/RelativeTime' +import {UserAvatar} from '../../../components/userAvatar/UserAvatar' +import {useDateTimeFormat} from '../../../hooks/useDateTimeFormat' +import {Translate, useTranslation} from '../../../i18n' +import {useDocumentPreviewValues} from '../../../tasks/hooks' +import {releasesLocaleNamespace} from '../../i18n' +import {ReleaseDocumentPreview} from '../components/ReleaseDocumentPreview' +import { + type AddDocumentToReleaseEvent, + type DiscardDocumentFromReleaseEvent, + isAddDocumentToReleaseEvent, + isCreateReleaseEvent, + isDiscardDocumentFromReleaseEvent, + isEditReleaseEvent, + isScheduleReleaseEvent, + type ReleaseEvent, +} from './events/types' + +const StatusText = styled(Text)` + strong { + font-weight: 500; + color: var(--card-fg-color); + } + time { + white-space: nowrap; + } +` +const ACTIVITY_TEXT_118N: Record = { + addDocumentToRelease: 'activity.event.add-document', + archiveRelease: 'activity.event.archive', + createRelease: 'activity.event.create', + discardDocumentFromRelease: 'activity.event.discard-document', + publishRelease: 'activity.event.publish', + scheduleRelease: 'activity.event.schedule', + unarchiveRelease: 'activity.event.unarchive', + unscheduleRelease: 'activity.event.unschedule', + editRelease: 'activity.event.edit', +} + +const ReleaseEventDocumentPreview = ({ + event, + releaseId, +}: { + releaseId: string + event: AddDocumentToReleaseEvent | DiscardDocumentFromReleaseEvent +}) => { + const {value, isLoading} = useDocumentPreviewValues({ + documentId: event.documentId, + documentType: event.documentType, + }) + return ( + + + + ) +} + +const ScheduleTarget = ({children, event}: {children: ReactNode; event: ReleaseEvent}) => { + const dateTimeFormat = useDateTimeFormat({dateStyle: 'full', timeStyle: 'medium'}) + const {t} = useTranslation(releasesLocaleNamespace) + + const formattedDate = useMemo(() => { + if (isEditReleaseEvent(event)) { + if (event.change.releaseType === 'asap') return t('activity.event.edit-time-asap') + if (event.change.releaseType === 'undecided') return t('activity.event.edit-time-undecided') + } + + 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)) + }, [dateTimeFormat, event, t]) + + if (!formattedDate && isCreateReleaseEvent(event)) return null + return ( + + {children} {formattedDate || '---'} + + ) +} + +const FadeInCard = motion(Card) +export const ReleaseActivityListItem = memo( + ({ + event, + releaseId, + releaseTitle, + }: { + event: ReleaseEvent + releaseId: string + releaseTitle: string + }) => { + const {t} = useTranslation(releasesLocaleNamespace) + + return ( + component wrapping the list) + initial={{opacity: 0}} + animate={{opacity: 1}} + transition={{type: 'spring', bounce: 0, duration: 0.4}} + > + + + + + + ( + {children} + ), + }} + values={{releaseTitle}} + i18nKey={ACTIVITY_TEXT_118N[event.type]} + />{' '} + · + + + {isAddDocumentToReleaseEvent(event) || isDiscardDocumentFromReleaseEvent(event) ? ( + + ) : null} + + + + ) + }, + (prevProps, nextProps) => { + return prevProps.event.id === nextProps.event.id && prevProps.releaseId === nextProps.releaseId + }, +) + +ReleaseActivityListItem.displayName = 'ReleaseActivityListItem' diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx index 846f23bf1d4..2e3a0e38854 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardActivityPanel.tsx @@ -1,14 +1,76 @@ -import {Stack, Text} from '@sanity/ui' +'use no memo' +// The `use no memo` directive is due to a known issue with react-virtual and react compiler: https://github.com/TanStack/virtual/issues/736 -export function ReleaseDashboardActivityPanel() { +import {Box, Card, Flex, Text} from '@sanity/ui' +import {AnimatePresence, motion} from 'framer-motion' +import {styled} from 'styled-components' + +import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' +import {Resizable} from '../../../form/studio/tree-editing/components/layout/resizer' +import {useTranslation} from '../../../i18n' +import {releasesLocaleNamespace} from '../../i18n' +import {type ReleaseDocument} from '../../store/types' +import {type ReleaseEvents} from './events/useReleaseEvents' +import {ReleaseActivityList} from './ReleaseActivityList' + +interface ReleaseDashboardActivityPanelProps { + events: ReleaseEvents + release: ReleaseDocument + show: boolean +} +const MotionFlex = motion(Flex) +const FillHeight = styled.div` + height: 100%; + display: flex; + flex-direction: column; +` +export function ReleaseDashboardActivityPanel({ + events, + release, + show, +}: ReleaseDashboardActivityPanelProps) { + const {t} = useTranslation(releasesLocaleNamespace) return ( - - - {'Activity'} - - - {'🚧 Under construction 🚧'} - - + + {show && ( + <> + + + + + + + {t('activity.panel.title')} + + + {events.loading && !events.events.length && ( + + )} + + + + + + )} + ) } diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx index b60bae2db5f..9d39db130e6 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardFooter.tsx @@ -7,14 +7,16 @@ import {ReleasePublishAllButton} from '../components/releaseCTAButtons/ReleasePu import {ReleaseScheduleButton} from '../components/releaseCTAButtons/ReleaseScheduleButton' import {ReleaseUnscheduleButton} from '../components/releaseCTAButtons/ReleaseUnscheduleButton' import {ReleaseMenuButton} from '../components/ReleaseMenuButton/ReleaseMenuButton' +import {type ReleaseEvent} from './events/types' import {ReleaseStatusItems} from './ReleaseStatusItems' import {type DocumentInRelease} from './useBundleDocuments' export function ReleaseDashboardFooter(props: { documents: DocumentInRelease[] release: ReleaseDocument + events: ReleaseEvent[] }) { - const {documents, release} = props + const {documents, release, events} = props const releaseActionButton = useMemo(() => { if (release.metadata.releaseType === 'scheduled') { @@ -52,7 +54,7 @@ export function ReleaseDashboardFooter(props: { - + diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx index 7fc52cc17eb..840319b3ea3 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDetail.tsx @@ -9,6 +9,7 @@ import {useReleases} from '../../store/useReleases' import {type ReleasesRouterState} from '../../types/router' import {getReleaseIdFromReleaseDocumentId} from '../../util/getReleaseIdFromReleaseDocumentId' import {useReleaseHistory} from './documentTable/useReleaseHistory' +import {useReleaseEvents} from './events/useReleaseEvents' import {ReleaseDashboardActivityPanel} from './ReleaseDashboardActivityPanel' import {ReleaseDashboardDetails} from './ReleaseDashboardDetails' import {ReleaseDashboardFooter} from './ReleaseDashboardFooter' @@ -41,6 +42,7 @@ export const ReleaseDetail = () => { const {data, archivedReleases, loading} = useReleases() const {loading: documentsLoading, results} = useBundleDocuments(releaseId) + const releaseEvents = useReleaseEvents(releaseId) const documentIds = results.map((result) => result.document?._id) const history = useReleaseHistory(documentIds, releaseId) @@ -115,7 +117,7 @@ export const ReleaseDetail = () => { if (releaseInDetail) { return ( - + { {detailContent} - + - {inspector === 'activity' && ( - <> - - - - - - )} + ) diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx index c657d0aa176..3a8cfd2ea0e 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseStatusItems.tsx @@ -1,52 +1,75 @@ import {Flex} from '@sanity/ui' +import {useMemo} from 'react' -import {RelativeTime, UserAvatar} from '../../../components' +import {AvatarSkeleton, RelativeTime, UserAvatar} from '../../../components' import {useTranslation} from '../../../i18n' +import {isNonNullable} from '../../../util/isNonNullable' import {releasesLocaleNamespace} from '../../i18n' -import {type ReleaseDocument} from '../../index' +import {type ReleaseDocument} from '../../store/types' import {StatusItem} from '../components/StatusItem' +import { + isArchiveReleaseEvent, + isCreateReleaseEvent, + isPublishReleaseEvent, + isUnarchiveReleaseEvent, + type ReleaseEvent, +} from './events/types' -interface LastEdit { - author: string - date: string +const STATUS_TITLE_I18N = { + createRelease: 'footer.status.created', + publishRelease: 'footer.status.published', + archiveRelease: 'footer.status.archived', + unarchiveRelease: 'footer.status.unarchived', } - -function getLastEdit(): LastEdit | null { - /* TODO: Hold until release activity is ready, we will need to use that data to show the last edit done to the release. */ - return null -} - -export function ReleaseStatusItems({release}: {release: ReleaseDocument}) { +export function ReleaseStatusItems({ + events, + release, +}: { + events: ReleaseEvent[] + release: ReleaseDocument +}) { const {t} = useTranslation(releasesLocaleNamespace) + const footerEvents = useMemo(() => { + const createEvent = events.find(isCreateReleaseEvent) + const extraEvent = events.find( + (event) => + isPublishReleaseEvent(event) || + isArchiveReleaseEvent(event) || + isUnarchiveReleaseEvent(event), + ) + return [createEvent, extraEvent].filter(isNonNullable) + }, [events]) - const lastEdit = getLastEdit() - return ( - - {/* Created */} - {release.state !== 'archived' && !release.publishAt && !lastEdit && ( + if (!footerEvents.length) { + return ( + } + avatar={} text={ <> - {t('footer.status.created')}{' '} + {t(STATUS_TITLE_I18N.createRelease)}{' '} } /> - )} - - {/* Edited */} - {lastEdit && !release.publishAt && release.state === 'archived' && ( + + ) + } + return ( + + {footerEvents.map((event) => ( : null} + key={event.id} + testId={`status-${event.type}`} + avatar={event.author && } text={ <> - {t('footer.status.edited')}{' '} - + {t(STATUS_TITLE_I18N[event.type])}{' '} + } /> - )} + ))} ) } diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx index da09f34075a..10a32c2fdc6 100644 --- a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseDetail.test.tsx @@ -18,6 +18,7 @@ import { mockUseBundleDocuments, useBundleDocumentsMockReturn, } from './__mocks__/useBundleDocuments.mock' +import {useReleaseEventsMockReturn} from './__mocks__/useReleaseEvents.mock' vi.mock('sanity/router', async (importOriginal) => { return { @@ -47,6 +48,10 @@ vi.mock('../useBundleDocuments', () => ({ useBundleDocuments: vi.fn(() => useBundleDocumentsMockReturn), })) +vi.mock('../events/useReleaseEvents', () => ({ + useReleaseEvents: vi.fn(() => useReleaseEventsMockReturn), +})) + vi.mock('../../components/ReleasePublishAllButton/useObserveDocumentRevisions', () => ({ useObserveDocumentRevisions: vi.fn().mockReturnValue({ '123': 'mock revision id', diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx new file mode 100644 index 00000000000..3ab0aebc9e0 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/ReleaseStatusItems.test.tsx @@ -0,0 +1,103 @@ +import {render, within} from '@testing-library/react' +import {describe, expect, it} from 'vitest' + +import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import {activeASAPRelease} from '../../../__fixtures__/release.fixture' +import {releasesUsEnglishLocaleBundle} from '../../../i18n' +import { + archivedReleaseEvents, + publishedReleaseEvents, + unarchivedReleaseEvents, +} from '../events/__fixtures__/release-events' +import {ReleaseStatusItems} from '../ReleaseStatusItems' + +describe('ReleaseStatusItems', () => { + it('renders fallback status item when no footer event is found', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render(, { + wrapper, + }) + const text = await component.findByText('Created') + expect(text).toBeInTheDocument() + }) + it('renders the creation event, when no any other relevant event is present', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const timeElement = await component.findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-03T00:00:00.000Z') + const text = await component.findByText('Created') + expect(text).toBeInTheDocument() + }) + it('renders a status item for a PublishRelease event and the create event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const publishEvent = await component.findByTestId('status-publishRelease') + const timeElement = await within(publishEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-05T00:00:00.000Z') + const text = await within(publishEvent).findByText('Published') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) + it('renders a status item for an ArchiveRelease event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + + const component = render( + , + { + wrapper, + }, + ) + const archivedEvent = await component.findByTestId('status-archiveRelease') + + const timeElement = await within(archivedEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-05T00:00:00.000Z') + const text = await within(archivedEvent).findByText('Archived') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) + it('renders a status item for an UnarchiveRelease event', async () => { + const wrapper = await createTestProvider({ + resources: [releasesUsEnglishLocaleBundle], + }) + const component = render( + , + { + wrapper, + }, + ) + const unarchiveEvent = await component.findByTestId('status-unarchiveRelease') + + const timeElement = await within(unarchiveEvent).findByRole('time') + expect(timeElement).toHaveAttribute('datetime', '2024-12-06T00:00:00.000Z') + const text = await within(unarchiveEvent).findByText('Unarchived') + expect(text).toBeInTheDocument() + + const createEvent = await component.findByTestId('status-createRelease') + expect(createEvent).toBeInTheDocument() + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts new file mode 100644 index 00000000000..5034767401e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/__tests__/__mocks__/useReleaseEvents.mock.ts @@ -0,0 +1,12 @@ +import {type Mocked, vitest} from 'vitest' + +import {publishedReleaseEvents} from '../../events/__fixtures__/release-events' +import {type useReleaseEvents} from '../../events/useReleaseEvents' + +export const useReleaseEventsMockReturn: Mocked> = { + loading: false, + events: publishedReleaseEvents, + hasMore: false, + error: null, + loadMore: vitest.fn(), +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts b/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts new file mode 100644 index 00000000000..4852458a481 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/__fixtures__/release-events.ts @@ -0,0 +1,60 @@ +import {type ReleaseEvent} from '../types' + +const author = 'author1' +const releaseName = 'release1' + +export const publishedReleaseEvents: ReleaseEvent[] = [ + { + id: '3', + type: 'publishRelease', + author, + timestamp: '2024-12-05T00:00:00Z', + releaseName, + origin: 'events', + }, + { + id: '2', + type: 'addDocumentToRelease', + author, + timestamp: '2024-12-04T00:00:00Z', + releaseName, + documentId: 'foo', + documentType: 'author', + versionId: 'versions.release1.foo', + revisionId: 'rev1', + versionRevisionId: 'versions.release1.foo.rev1', + origin: 'events', + }, + { + id: '1', + type: 'createRelease', + author, + timestamp: '2024-12-03T00:00:00Z', + origin: 'events', + releaseName, + }, +] + +export const archivedReleaseEvents: ReleaseEvent[] = [ + { + id: '3', + type: 'archiveRelease', + author, + timestamp: '2024-12-05T00:00:00Z', + releaseName, + origin: 'events', + }, + ...publishedReleaseEvents.slice(1), +] + +export const unarchivedReleaseEvents: ReleaseEvent[] = [ + { + id: '4', + type: 'unarchiveRelease', + origin: 'events', + author, + timestamp: '2024-12-06T00:00:00Z', + releaseName, + }, + ...archivedReleaseEvents, +] diff --git a/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts new file mode 100644 index 00000000000..afdd10edb22 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.test.ts @@ -0,0 +1,577 @@ +import {type ReleaseDocument} from 'sanity' +import {describe, expect, it} from 'vitest' + +import {buildReleaseEditEvents} from './buildReleaseEditEvents' + +describe('buildReleaseEditEvents()', () => { + it('should identify a metadata.releaseType change', () => { + const release = { + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + _rev: '27IdYXOVe1PEc0ZOADFAhQ', + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:09:28Z', + metadata: { + releaseType: 'undecided', + description: '', + title: 'winter drop', + }, + publishAt: null, + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, + ], + release, + ) + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should identify a intededPublishDate change', () => { + const release = { + publishAt: null, + _rev: 'zGoOhrVQZLzwh7QVfgQryJ', + _id: '_.releases.rWBfpXZVj', + _createdAt: '2024-12-05T16:34:59Z', + userId: '', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-13T17:12:00.000Z', + }, + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:12:56Z', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '56', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '3', + 23, + 10, + 24, + 15, + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 17, + 22, + '48', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '2', + 23, + 10, + 24, + 15, + 15, + ], + }, + }, + }, + ], + release, + ) + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {intendedPublishDate: '2024-12-13T17:12:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should identify a metadata.releaseType and intendedPublishDate change', () => { + const releaseDocument = { + publishAt: null, + finalDocumentStates: null, + _id: '_.releases.rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T16:35:11Z', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-20T16:35:00.000Z', + }, + _rev: 'zGoOhrVQZLzwh7QVfgIGWK', + _type: 'system.release', + name: 'rWBfpXZVj', + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + } as unknown as ReleaseDocument + + const releaseEditEvents = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 15, + 22, + '5:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [10, 0, 14, '_updatedAt', 10, 5, 19, 1, 17, 'asap', 'releaseType', 15], + }, + }, + }, + ], + releaseDocument, + ) + expect(releaseEditEvents).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: { + releaseType: 'scheduled', + intendedPublishDate: '2024-12-20T16:35:00.000Z', + }, + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) + it('should handle multiple changes correctly', () => { + const release = { + publishAt: null, + _rev: 'zGoOhrVQZLzwh7QVfgQryJ', + _id: '_.releases.rWBfpXZVj', + _createdAt: '2024-12-05T16:34:59Z', + userId: '', + metadata: { + releaseType: 'scheduled', + description: '', + title: 'winter drop', + intendedPublishAt: '2024-12-13T17:12:00.000Z', + }, + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:12:56Z', + _type: 'system.release', + finalDocumentStates: null, + } as unknown as ReleaseDocument + const changes = buildReleaseEditEvents( + [ + { + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '56', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '3', + 23, + 10, + 24, + 15, + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 17, + 22, + '48', + 23, + 19, + 20, + 15, + 10, + 5, + 11, + 1, + 23, + 0, + 9, + 22, + '2', + 23, + 10, + 24, + 15, + 15, + ], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOADFjtK', + timestamp: '2024-12-05T17:12:48.742846Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 14, + 22, + '12:4', + 23, + 18, + 20, + 15, + 10, + 5, + 17, + '2024-12-12T17:12:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 14, + 22, + '09:2', + 23, + 18, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, + { + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + author: 'p8xDvUMxC', + + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 15, + 22, + '5:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + revert: [10, 0, 14, '_updatedAt', 10, 5, 19, 1, 17, 'asap', 'releaseType', 15], + }, + }, + }, + { + id: '27IdYXOVe1PEc0ZOAD1jvY', + timestamp: '2024-12-05T16:34:59.512774Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 0, + { + _createdAt: '2024-12-05T16:34:59Z', + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + _updatedAt: '2024-12-05T16:34:59Z', + finalDocumentStates: null, + metadata: { + description: '', + releaseType: 'asap', + title: 'winter drop', + }, + name: 'rWBfpXZVj', + publishAt: null, + state: 'active', + userId: '', + }, + ], + revert: [0, null], + }, + }, + }, + ], + release, + ) + + expect(changes).toEqual([ + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {intendedPublishDate: '2024-12-13T17:12:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgQryJ', + timestamp: '2024-12-05T17:12:56.253502Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'scheduled', intendedPublishDate: '2024-12-12T17:12:00.000Z'}, + id: '27IdYXOVe1PEc0ZOADFjtK', + timestamp: '2024-12-05T17:12:48.742846Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: '27IdYXOVe1PEc0ZOADFAhQ', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'scheduled', intendedPublishDate: '2024-12-20T16:35:00.000Z'}, + id: 'zGoOhrVQZLzwh7QVfgIGWK', + timestamp: '2024-12-05T16:35:11.995089Z', + releaseName: 'rWBfpXZVj', + }, + { + type: 'createRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'asap'}, + id: '27IdYXOVe1PEc0ZOAD1jvY', + timestamp: '2024-12-05T16:34:59.512774Z', + releaseName: 'rWBfpXZVj', + }, + ]) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts new file mode 100644 index 00000000000..abcc428b826 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/buildReleaseEditEvents.ts @@ -0,0 +1,53 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' + +import {applyMendozaPatch} from '../../../../preview/utils/applyMendozaPatch' +import {type ReleaseDocument, type ReleaseType} from '../../../store/types' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {type CreateReleaseEvent, type EditReleaseEvent} from './types' + +export function buildReleaseEditEvents( + transactions: TransactionLogEventWithEffects[], + release: ReleaseDocument, +): (EditReleaseEvent | CreateReleaseEvent)[] { + // Confirm we have all the events by checking the first transaction id and the release._rev, the should match. + if (release._rev !== transactions[0]?.id) { + console.error('Some transactions are missing, cannot calculate the edit events') + return [] + } + + const releaseEditEvents: (EditReleaseEvent | CreateReleaseEvent)[] = [] + // We start from the last release document and apply changes in reverse order + // Compare for each transaction what changed, if metadata.releaseType or metadata.intendedPublishAt changed build an event. + let currentDocument = release + for (const transaction of transactions) { + const effect = transaction.effects[release._id] + if (!effect) continue + // This will apply the revert effect to the document, so we will get the document from before this change. + const before = applyMendozaPatch(currentDocument, effect.revert) + const changed: { + releaseType?: ReleaseType + intendedPublishDate?: string + } = {} + + if (before?.metadata.releaseType !== currentDocument.metadata.releaseType) { + changed.releaseType = currentDocument.metadata.releaseType + } + if (before?.metadata.intendedPublishAt !== currentDocument.metadata.intendedPublishAt) { + changed.intendedPublishDate = currentDocument.metadata.intendedPublishAt + } + // If the "changed" object has more than one key identify it as a change event + if (Object.values(changed).length >= 1) { + releaseEditEvents.push({ + type: before ? 'editRelease' : 'createRelease', + origin: 'translog', + author: transaction.author, + change: changed, + id: transaction.id, + timestamp: transaction.timestamp, + releaseName: getReleaseIdFromReleaseDocumentId(release._id), + }) + currentDocument = before + } + } + return releaseEditEvents +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts new file mode 100644 index 00000000000..bdce055b700 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.test.ts @@ -0,0 +1,176 @@ +import {type SanityClient} from '@sanity/client' +import {of} from 'rxjs' +import {TestScheduler} from 'rxjs/testing' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {addEventData, getReleaseActivityEvents, INITIAL_VALUE} from './getReleaseActivityEvents' +import {type ReleaseEvent} from './types' + +const mockObservableRequest = vi.fn() + +const mockClient = { + observable: { + request: mockObservableRequest, + }, + config: vi.fn().mockReturnValue({dataset: 'testDataset'}), +} as unknown as SanityClient + +const creationEvent: Omit = { + timestamp: '2024-12-03T00:00:00Z', + type: 'createRelease', + releaseName: 'r123', + author: 'user-1', +} +const addFirstDocumentEvent: Omit = { + timestamp: '2024-12-03T01:00:00Z', + type: 'addDocumentToRelease', + releaseName: 'r123', + author: 'user-1', +} +const addSecondDocumentEvent: Omit = { + timestamp: '2024-12-03T02:00:00Z', + type: 'addDocumentToRelease', + releaseName: 'r123', + author: 'user-2', +} + +const releaseId = '_.releases.r123' +describe('getReleaseActivityEvents', () => { + let testScheduler: TestScheduler + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + it('should fetch initial events from the API', () => { + mockObservableRequest.mockReturnValueOnce( + of({ + events: [creationEvent, addFirstDocumentEvent], + nextCursor: 'cursor1', + }), + ) + + const {events$} = getReleaseActivityEvents({client: mockClient, releaseId}) + testScheduler.run(({expectObservable}) => { + expectObservable(events$).toBe('(ab)', { + a: INITIAL_VALUE, + b: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + loading: false, + error: null, + }, + }) + }) + }) + + it('should reload events when reloadEvents is called', () => { + mockObservableRequest + .mockReturnValueOnce( + of({ + events: [creationEvent, addFirstDocumentEvent], + nextCursor: 'cursor1', + }), + ) + .mockReturnValueOnce( + of({ + events: [addFirstDocumentEvent, addSecondDocumentEvent], + // This cursor won't be added, is a reload action we need to keep the previous. Reloads usually load less elements + nextCursor: 'cursor2', + }), + ) + + const {events$, reloadEvents} = getReleaseActivityEvents({client: mockClient, releaseId}) + + testScheduler.run(({expectObservable, cold}) => { + const actions = cold('5ms a', { + a: reloadEvents, + }) + + actions.subscribe((action) => action()) + + expectObservable(events$).toBe('(ab)-(cd)', { + a: INITIAL_VALUE, + b: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + loading: false, + error: null, + }, + c: { + events: [addEventData(addFirstDocumentEvent), addEventData(creationEvent)], + nextCursor: 'cursor1', + // Emits a loading state + loading: true, + error: null, + }, + d: { + events: [ + addEventData(addSecondDocumentEvent), + addEventData(addFirstDocumentEvent), + addEventData(creationEvent), + ], + // Preserves previous cursor + nextCursor: 'cursor1', + loading: false, + error: null, + }, + }) + }) + }) + + it('should fetch additional events when loadMore is called', () => { + // It returns the first two events and then it loads an older one + mockObservableRequest + .mockReturnValueOnce( + of({ + events: [addFirstDocumentEvent, addSecondDocumentEvent], + nextCursor: 'cursor2', + }), + ) + .mockReturnValueOnce( + of({ + events: [creationEvent], + nextCursor: '', + }), + ) + + const {events$, loadMore} = getReleaseActivityEvents({client: mockClient, releaseId}) + + testScheduler.run(({expectObservable, cold}) => { + const actions = cold('5ms a', { + a: loadMore, + }) + + actions.subscribe((action) => action()) + expectObservable(events$).toBe('(ab)-(cd)', { + a: INITIAL_VALUE, + b: { + loading: false, + nextCursor: 'cursor2', + error: null, + events: [addEventData(addSecondDocumentEvent), addEventData(addFirstDocumentEvent)], + }, + c: { + loading: true, + // Given it's a loadMore action, we don't need to keep the previous cursor + nextCursor: '', + error: null, + events: [addEventData(addSecondDocumentEvent), addEventData(addFirstDocumentEvent)], + }, + d: { + loading: false, + nextCursor: '', + error: null, + events: [ + addEventData(addSecondDocumentEvent), + addEventData(addFirstDocumentEvent), + addEventData(creationEvent), + ], + }, + }) + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts new file mode 100644 index 00000000000..88d9bdc3d82 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseActivityEvents.ts @@ -0,0 +1,122 @@ +import {type SanityClient} from '@sanity/client' +import {BehaviorSubject, type Observable} from 'rxjs' +import {map, scan, shareReplay, startWith, switchMap, tap} from 'rxjs/operators' + +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {type ReleaseEvent} from './types' + +export interface ReleaseEventsObservableValue { + events: ReleaseEvent[] + nextCursor: string + loading: boolean + error: null | Error +} +export const INITIAL_VALUE: ReleaseEventsObservableValue = { + events: [], + nextCursor: '', + loading: true, + error: null, +} + +function removeDupes(prev: ReleaseEvent[], next: ReleaseEvent[]): ReleaseEvent[] { + const noDupes = [...prev, ...next].reduce((acc, event) => { + if (acc.has(event.id)) { + return acc + } + return acc.set(event.id, event) + }, new Map()) + return Array.from(noDupes.values()) +} + +export function addEventData(event: Omit): ReleaseEvent { + return {...event, id: `${event.timestamp}-${event.type}`, origin: 'events'} as ReleaseEvent +} + +interface InitialFetchEventsOptions { + client: SanityClient + releaseId: string +} +export function getReleaseActivityEvents({client, releaseId}: InitialFetchEventsOptions): { + events$: Observable + reloadEvents: () => void + loadMore: () => void +} { + const refetchEventsTrigger$ = new BehaviorSubject<{ + cursor: string | null + origin: 'loadMore' | 'reload' | 'initial' + }>({ + cursor: null, + origin: 'initial', + }) + + const fetchEvents = ({limit, nextCursor}: {limit: number; nextCursor: string | null}) => { + const params = new URLSearchParams({limit: limit.toString()}) + if (nextCursor) { + params.append('nextCursor', nextCursor) + } + return client.observable + .request<{ + events: Omit[] + nextCursor: string + }>({ + url: `/data/events/${client.config().dataset}/releases/${getReleaseIdFromReleaseDocumentId(releaseId)}?${params.toString()}`, + tag: 'get-release-events', + }) + .pipe( + map((response) => { + return { + events: response.events.map(addEventData), + nextCursor: response.nextCursor, + loading: false, + error: null, + } + }), + ) + } + + let nextCursor: string = '' + return { + events$: refetchEventsTrigger$.pipe( + switchMap(({cursor, origin}) => { + return fetchEvents({ + nextCursor: cursor, + limit: origin === 'reload' ? 10 : 100, + }).pipe( + map((response) => { + return {...response, origin} + }), + startWith({events: [], nextCursor: '', loading: true, error: null, origin}), + ) + }), + scan((prev, next) => { + const events = removeDupes(prev.events, next.events).sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ) + return { + events: events, + // If we are reloading, we should keep the cursor as it was before. + nextCursor: next.origin === 'reload' ? prev.nextCursor : next.nextCursor, + loading: next.loading, + error: next.error, + } + }, INITIAL_VALUE), + tap((response) => { + nextCursor = response.nextCursor + }), + shareReplay(1), + ), + /** + * Loads new events for the release, fetching the latest events from the API. + */ + reloadEvents: () => refetchEventsTrigger$.next({cursor: null, origin: 'reload'}), + /** + * Loads more events for the release, fetching the next batch of events from the API. + */ + loadMore: () => { + const lastCursorUsed = refetchEventsTrigger$.getValue().cursor + if (nextCursor && lastCursorUsed !== nextCursor) { + refetchEventsTrigger$.next({origin: 'loadMore', cursor: nextCursor}) + } + }, + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts new file mode 100644 index 00000000000..c9be2f484c2 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.test.ts @@ -0,0 +1,313 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {TestScheduler} from 'rxjs/testing' +import {type ReleaseDocument, type SanityClient} from 'sanity' +import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {getTransactionsLogs} from '../../../../store/translog/getTransactionLogs' +import { + type getReleaseEditEvents as getReleaseEditEventsFunction, + INITIAL_VALUE, +} from './getReleaseEditEvents' + +const mockClient = { + config: vi.fn().mockReturnValue({dataset: 'testDataset'}), +} as unknown as SanityClient + +vi.mock('../../../../store/translog/getTransactionLogs', () => { + return { + getTransactionsLogs: vi.fn(), + } +}) +const MOCKED_RELEASE = { + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + _rev: 'mocked-rev', + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:09:28Z', + metadata: { + releaseType: 'undecided', + description: '', + title: 'winter drop', + }, + publishAt: null, + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + finalDocumentStates: null, +} as unknown as ReleaseDocument + +const MOCKED_TRANSACTION_LOGS: TransactionLogEventWithEffects[] = [ + { + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, +] + +const MOCKED_EVENT = { + type: 'editRelease', + author: 'p8xDvUMxC', + origin: 'translog', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', +} + +const mockGetTransactionsLogs = getTransactionsLogs as Mock +const BASE_GET_TRANSACTION_LOGS_PARAMS = { + effectFormat: 'mendoza', + fromTransaction: undefined, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + toTransaction: MOCKED_RELEASE._rev, +} as const + +const MOCKED_RELEASES_STATE = { + state: 'loaded' as const, + releaseStack: [], + releases: new Map([[MOCKED_RELEASE._id, MOCKED_RELEASE]]), +} + +describe('getReleaseEditEvents()', () => { + let testScheduler: TestScheduler + let getReleaseEditEvents: typeof getReleaseEditEventsFunction + beforeEach(async () => { + // We need to reset the module and reassign it because it has an internal cache that we need to evict + vi.resetModules() + const testModule = await import('./getReleaseEditEvents') + getReleaseEditEvents = testModule.getReleaseEditEvents + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + afterEach(() => { + vi.resetAllMocks() + }) + it('should not get the events if release is undefined', () => { + testScheduler.run(({expectObservable, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: 'not-existing-release', + releasesState$, + }) + + expectObservable(editEvents$).toBe('(a)', {a: INITIAL_VALUE}) + }) + }) + it('should get and build the release edit events', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + expectObservable(editEvents$).toBe('a-b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + }) + it('should expand the release edit events transactions if received max', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$, + }) + const mockFirstResponse$ = cold('-a|', { + a: Array.from({length: 100}).map((_, index) => { + return { + ...MOCKED_TRANSACTION_LOGS[0], + id: + index === 0 + ? MOCKED_TRANSACTION_LOGS[0].id + : `${MOCKED_TRANSACTION_LOGS[0].id}-${index + 1}`, + } + }), + }) + const mockSecondResponse$ = cold('-a|', { + a: Array.from({length: 100}).map((_, index) => { + return { + ...MOCKED_TRANSACTION_LOGS[0], + id: `${MOCKED_TRANSACTION_LOGS[0].id}-${index + 101}`, + } + }), + }) + const mockFinalResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs + .mockReturnValueOnce(mockFirstResponse$) + .mockReturnValueOnce(mockSecondResponse$) + .mockReturnValueOnce(mockFinalResponse$) + expectObservable(editEvents$).toBe('a---b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledTimes(3) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: MOCKED_RELEASE._rev, + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: `${MOCKED_TRANSACTION_LOGS[0].id}-100`, + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + toTransaction: `${MOCKED_TRANSACTION_LOGS[0].id}-200`, + }) + }) + it('should not refetch the edit events if rev has not changed', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Simulate the release states changing over time, but the _rev is the same + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: MOCKED_RELEASES_STATE, + }) + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + // Even though the state changes, the editEvents$ should not emit again + expectObservable(editEvents$).toBe('a-b', { + a: {editEvents: [], loading: true, error: null}, + b: {editEvents: [MOCKED_EVENT], loading: false, error: null}, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + }) + it('should refetch the edit events if release._rev changes', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Define the initial and updated release state + const updatedReleaseState = { + ...MOCKED_RELEASES_STATE, + releases: new Map([[MOCKED_RELEASE._id, {...MOCKED_RELEASE, _rev: 'changed-rev'}]]), + } + // Simulate the release states changing over time + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: updatedReleaseState, + }) + + const editEvents$ = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + const newTransaction = { + id: 'changed-rev', + timestamp: '2024-12-05T17:10:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: {}, + } + // It only returns the new transactions, the rest are from the cache, so they will be persisted. + const mockResponse2$ = cold('-a|', {a: [newTransaction]}) + + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$).mockReturnValueOnce(mockResponse2$) + + expectObservable(editEvents$).toBe('a-b---c', { + a: {editEvents: [], loading: true, error: null}, + b: { + editEvents: [MOCKED_EVENT], + loading: false, + error: null, + }, + c: { + editEvents: [MOCKED_EVENT], + loading: false, + error: null, + }, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith( + mockClient, + MOCKED_RELEASE._id, + BASE_GET_TRANSACTION_LOGS_PARAMS, + ) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + ...BASE_GET_TRANSACTION_LOGS_PARAMS, + // Uses the previous release._rev as the fromTransaction + fromTransaction: MOCKED_RELEASE._rev, + // Uses the new release._rev as the toTransaction + toTransaction: 'changed-rev', + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts new file mode 100644 index 00000000000..139b58e6796 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEditEvents.ts @@ -0,0 +1,161 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' +import { + distinctUntilChanged, + expand, + filter, + from, + map, + type Observable, + of, + reduce, + scan, + shareReplay, + startWith, + switchMap, + tap, +} from 'rxjs' + +import {getTransactionsLogs} from '../../../../store/translog/getTransactionLogs' +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) + +function removeDupes( + newTransactions: TransactionLogEventWithEffects[], + oldTransactions: TransactionLogEventWithEffects[], +) { + const seen = new Set() + return newTransactions.concat(oldTransactions).filter((transaction) => { + if (seen.has(transaction.id)) { + return false + } + seen.add(transaction.id) + return true + }) +} + +/** + * This will fetch all the transactions for a given release. + * I anticipate this would be a rather small number of transactions, given the release document is "small" and shouldn't change much. + * + * We need to fetch all of them to create the correct pagination of events in the activity feed, given we need to combine this with the + * releaseActivityEvents that will be fetched from the events api. + */ +function getReleaseTransactions({ + documentId, + client, + toTransaction, +}: { + documentId: string + client: SanityClient + toTransaction: string +}): Observable { + const cacheKey = `${documentId}` + const cachedTransactions = documentTransactionsCache[cacheKey] || [] + if (cachedTransactions.length > 0 && cachedTransactions[0].id === toTransaction) { + return of(cachedTransactions) + } + + function fetchLogs(options: { + fromTransaction?: string + toTransaction: string + }): Observable { + return from( + getTransactionsLogs(client, documentId, { + tag: 'sanity.studio.release.history', + effectFormat: 'mendoza', + limit: TRANSLOG_ENTRY_LIMIT, + reverse: true, + fromTransaction: options.fromTransaction, + toTransaction: options.toTransaction, + }), + ) + } + + return fetchLogs({fromTransaction: cachedTransactions[0]?.id, toTransaction: toTransaction}) + .pipe( + expand((response) => { + // Fetch more if the transactions length is equal to the limit + if (response.length === TRANSLOG_ENTRY_LIMIT) { + // Continue fetching if nextCursor exists, we use the last transaction received as the cursor. + return fetchLogs({ + fromTransaction: undefined, + toTransaction: response[response.length - 1].id, + }) + } + // End recursion by emitting an empty observable + return of() + }), + // Combine all batches of transactions into a single array + reduce( + (allTransactions, batch) => allTransactions.concat(batch), + [] as TransactionLogEventWithEffects[], + ), + ) + .pipe( + map((transactions) => removeDupes(transactions, cachedTransactions)), + tap((transactions) => { + documentTransactionsCache[cacheKey] = transactions + }), + ) +} + +interface EditEventsObservableValue { + editEvents: (EditReleaseEvent | CreateReleaseEvent)[] + loading: boolean + error: null | Error +} +export const INITIAL_VALUE: EditEventsObservableValue = { + editEvents: [], + loading: true, + error: null, +} + +interface getReleaseActivityEventsOpts { + client: SanityClient + releaseId: string + releasesState$: Observable +} +export function getReleaseEditEvents({ + client, + releaseId, + releasesState$, +}: getReleaseActivityEventsOpts): Observable { + return releasesState$.pipe( + map((releasesState) => releasesState.releases.get(releaseId)), + // Don't emit if the release is not found + filter(Boolean), + distinctUntilChanged((prev, next) => prev._rev === next._rev), + switchMap((release) => { + return getReleaseTransactions({ + client, + documentId: releaseId, + toTransaction: release._rev, + }).pipe( + map((transactions) => { + return { + editEvents: buildReleaseEditEvents(transactions, release), + loading: false, + error: null, + } + }), + ) + }), + startWith(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} + }, INITIAL_VALUE), + shareReplay(1), + ) +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts new file mode 100644 index 00000000000..03ea64a5a7e --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/getReleaseEvents.ts @@ -0,0 +1,117 @@ +import {type SanityClient} from '@sanity/client' +import { + combineLatest, + distinctUntilChanged, + filter, + map, + merge, + type Observable, + of, + skip, + startWith, + tap, +} from 'rxjs' + +import {type DocumentPreviewStore} from '../../../../preview/documentPreviewStore' +import {type ReleasesReducerState} from '../../../store/reducer' +import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId' +import {getReleaseActivityEvents} from './getReleaseActivityEvents' +import {getReleaseEditEvents} from './getReleaseEditEvents' +import {isCreateReleaseEvent, isEventsAPIEvent, isTranslogEvent, type ReleaseEvent} from './types' + +interface getReleaseEventsOpts { + client: SanityClient + releaseId: string + releasesState$: Observable + documentPreviewStore: DocumentPreviewStore + eventsAPIEnabled: boolean +} + +export const EVENTS_INITIAL_VALUE = { + events: [], + hasMore: false, + error: null, + loading: true, +} + +const notEnabledActivityEvents: ReturnType = { + events$: of({ + events: [], + nextCursor: '', + loading: false, + error: null, + }), + reloadEvents: () => {}, + loadMore: () => {}, +} + +/** + * Combines activity and edit events for a release, and adds side effects for reloading events when the release or the document changes. + */ +export function getReleaseEvents({ + client, + releaseId, + releasesState$, + documentPreviewStore, + eventsAPIEnabled, +}: getReleaseEventsOpts) { + const activityEvents = eventsAPIEnabled + ? getReleaseActivityEvents({client, releaseId}) + : notEnabledActivityEvents + + const editEvents$ = getReleaseEditEvents({client, releaseId, releasesState$}) + + const releaseRev$ = releasesState$.pipe( + map((state) => state.releases.get(releaseId)?._rev), + filter(Boolean), + distinctUntilChanged(), + // Emit only when rev changes, after first non null value. + skip(1), + ) + + const groqFilter = `_id in path("versions.${getReleaseIdFromReleaseDocumentId(releaseId)}.*")` + const documentsCount$ = documentPreviewStore.unstable_observeDocumentIdSet(groqFilter).pipe( + filter(({status}) => status === 'connected'), + map(({documentIds}) => documentIds.length), + distinctUntilChanged(), + // Emit only when count changes, after first non null value. + skip(1), + ) + + const sideEffects$ = merge(releaseRev$, documentsCount$).pipe( + tap(() => { + activityEvents.reloadEvents() + }), + startWith(null), + ) + + const events$ = combineLatest([activityEvents.events$, editEvents$, sideEffects$]).pipe( + map(([activity, edit]) => { + const events = [...activity.events, ...edit.editEvents] + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + .reduce((acc: ReleaseEvent[], event) => { + if (isCreateReleaseEvent(event)) { + const creationEvent = acc.find(isCreateReleaseEvent) + if (!creationEvent) acc.push(event) + // Prefer the translog event for the creation given it has extra information. + else if (isEventsAPIEvent(creationEvent) && isTranslogEvent(event)) { + acc[acc.indexOf(creationEvent)] = event + } + } else acc.push(event) + return acc + }, []) + + return { + events, + hasMore: Boolean(activity.nextCursor), + error: activity.error || edit.error, + loading: activity.loading || edit.loading, + } + }), + ) + + return { + events$, + loadMore: activityEvents.loadMore, + } +} diff --git a/packages/sanity/src/core/releases/tool/detail/events/types.ts b/packages/sanity/src/core/releases/tool/detail/events/types.ts new file mode 100644 index 00000000000..8656be6d9dd --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/types.ts @@ -0,0 +1,105 @@ +import {type ReleaseType} from '../../../store' + +export type ReleaseEvent = + | CreateReleaseEvent + | ScheduleReleaseEvent + | UnscheduleReleaseEvent + | PublishReleaseEvent + | ArchiveReleaseEvent + | UnarchiveReleaseEvent + | AddDocumentToReleaseEvent + | DiscardDocumentFromReleaseEvent + | EditReleaseEvent + +export type EventType = ReleaseEvent['type'] + +export interface BaseEvent { + timestamp: string + author: string + releaseName: string + id: string // Added client side ${event.timestamp}-${event.type} + origin: 'translog' | 'events' // Added client side to identify from where the event was received +} + +export interface CreateReleaseEvent extends BaseEvent { + type: 'createRelease' + change?: Change +} + +export interface ScheduleReleaseEvent extends BaseEvent { + type: 'scheduleRelease' + publishAt: string +} + +export interface UnscheduleReleaseEvent extends BaseEvent { + type: 'unscheduleRelease' +} + +export interface PublishReleaseEvent extends BaseEvent { + type: 'publishRelease' +} + +export interface ArchiveReleaseEvent extends BaseEvent { + type: 'archiveRelease' +} + +export interface UnarchiveReleaseEvent extends BaseEvent { + type: 'unarchiveRelease' +} + +export interface AddDocumentToReleaseEvent extends BaseEvent { + type: 'addDocumentToRelease' + documentId: string + documentType: string + versionId: string + revisionId: string + versionRevisionId: string +} + +export interface DiscardDocumentFromReleaseEvent extends BaseEvent { + type: 'discardDocumentFromRelease' + documentId: string + documentType: string + versionId: string + versionRevisionId: string +} + +interface Change { + intendedPublishDate?: string + releaseType?: ReleaseType +} +export interface EditReleaseEvent extends BaseEvent { + type: 'editRelease' + isCreationEvent?: boolean + change: Change +} + +// Type guards +export const isCreateReleaseEvent = (event: ReleaseEvent): event is CreateReleaseEvent => + event.type === 'createRelease' +export const isScheduleReleaseEvent = (event: ReleaseEvent): event is ScheduleReleaseEvent => + event.type === 'scheduleRelease' +export const isUnscheduleReleaseEvent = (event: ReleaseEvent): event is UnscheduleReleaseEvent => + event.type === 'unscheduleRelease' +export const isPublishReleaseEvent = (event: ReleaseEvent): event is PublishReleaseEvent => + event.type === 'publishRelease' +export const isArchiveReleaseEvent = (event: ReleaseEvent): event is ArchiveReleaseEvent => + event.type === 'archiveRelease' +export const isUnarchiveReleaseEvent = (event: ReleaseEvent): event is UnarchiveReleaseEvent => + event.type === 'unarchiveRelease' +export const isAddDocumentToReleaseEvent = ( + event: ReleaseEvent, +): event is AddDocumentToReleaseEvent => event.type === 'addDocumentToRelease' +export const isDiscardDocumentFromReleaseEvent = ( + event: ReleaseEvent, +): event is DiscardDocumentFromReleaseEvent => event.type === 'discardDocumentFromRelease' +export const isEditReleaseEvent = (event: ReleaseEvent): event is EditReleaseEvent => + event.type === 'editRelease' + +export const isTranslogEvent = ( + event: ReleaseEvent, +): event is EditReleaseEvent | CreateReleaseEvent => event.origin === 'translog' + +export const isEventsAPIEvent = ( + event: ReleaseEvent, +): event is Exclude => event.origin === 'events' diff --git a/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts b/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts new file mode 100644 index 00000000000..597b82f2b04 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/events/useReleaseEvents.ts @@ -0,0 +1,48 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {useClient} from '../../../../hooks/useClient' +import {useDocumentPreviewStore} from '../../../../store/_legacy/datastores' +import {useSource} from '../../../../studio/source' +import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../../studioClient' +import {useReleasesStore} from '../../../store/useReleasesStore' +import {getReleaseDocumentIdFromReleaseId} from '../../../util/getReleaseDocumentIdFromReleaseId' +import {EVENTS_INITIAL_VALUE, getReleaseEvents} from './getReleaseEvents' +import {type ReleaseEvent} from './types' + +export interface ReleaseEvents { + events: ReleaseEvent[] + loading: boolean + error: null | Error + loadMore: () => void + hasMore: boolean +} + +export function useReleaseEvents(releaseId: string): ReleaseEvents { + const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const documentPreviewStore = useDocumentPreviewStore() + const {state$: releasesState$} = useReleasesStore() + const source = useSource() + const eventsAPIEnabled = Boolean(source.beta?.eventsAPI?.enabled) + + const releaseEvents = useMemo( + () => + getReleaseEvents({ + client, + releaseId: getReleaseDocumentIdFromReleaseId(releaseId), + releasesState$, + documentPreviewStore, + eventsAPIEnabled, + }), + [releaseId, client, releasesState$, documentPreviewStore, eventsAPIEnabled], + ) + const events = useObservable(releaseEvents.events$, EVENTS_INITIAL_VALUE) + + return { + events: events.events, + hasMore: events.hasMore, + loading: events.loading, + error: events.error, + loadMore: releaseEvents.loadMore, + } +} diff --git a/packages/sanity/src/core/store/events/getDocumentTransactions.ts b/packages/sanity/src/core/store/events/getDocumentTransactions.ts index 9c431a37776..4fd8c1df421 100644 --- a/packages/sanity/src/core/store/events/getDocumentTransactions.ts +++ b/packages/sanity/src/core/store/events/getDocumentTransactions.ts @@ -1,7 +1,7 @@ import {type TransactionLogEventWithEffects} from '@sanity/types' import {type SanityClient} from 'sanity' -import {getJsonStream} from '../_legacy/history/history/getJsonStream' +import {getTransactionsLogs} from '../translog/getTransactionLogs' const TRANSLOG_ENTRY_LIMIT = 50 @@ -26,40 +26,19 @@ export async function getDocumentTransactions({ if (documentTransactionsCache[cacheKey] && typeof toTransaction !== 'undefined') { return documentTransactionsCache[cacheKey] } - const clientConfig = client.config() - const dataset = clientConfig.dataset + const skipFromTransaction = fromTransaction !== toTransaction - const queryParams = new URLSearchParams({ + let transactions = await getTransactionsLogs(client, documentId, { tag: 'sanity.studio.documents.history', effectFormat: 'mendoza', - excludeContent: 'true', - includeIdentifiedDocumentsOnly: 'true', - limit: TRANSLOG_ENTRY_LIMIT.toString(), + excludeContent: true, + includeIdentifiedDocumentsOnly: true, + limit: TRANSLOG_ENTRY_LIMIT, fromTransaction: fromTransaction, + toTransaction: toTransaction, }) - - if (toTransaction) { - queryParams.append('toTransaction', toTransaction) - } - - const transactionsUrl = client.getUrl( - `/data/history/${dataset}/transactions/${documentId}?${queryParams.toString()}`, - ) - const transactions: TransactionLogEventWithEffects[] = [] - - const skipFromTransaction = fromTransaction !== toTransaction - const stream = await getJsonStream(transactionsUrl, clientConfig.token) - const reader = stream.getReader() - 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) - } - if (result.value.id === fromTransaction && skipFromTransaction) continue - else transactions.push(result.value) + if (skipFromTransaction) { + transactions = transactions.filter((transaction) => transaction.id !== fromTransaction) } if ( diff --git a/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx b/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx index 692a787cdcd..ec61577cce7 100644 --- a/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx +++ b/packages/sanity/src/structure/panes/document/timeline/timelineItem.tsx @@ -3,6 +3,7 @@ import {Box, Card, Flex, Skeleton, Stack, Text} from '@sanity/ui' import {getTheme_v2, type ThemeColorAvatarColorKey} from '@sanity/ui/theme' import {createElement, type MouseEvent, useCallback, useMemo} from 'react' import { + AvatarSkeleton, type ChunkType, type RelativeTimeOptions, useDateTimeFormat, @@ -62,15 +63,6 @@ const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = { useTemporalPhrase: true, } -const AvatarSkeleton = styled(Skeleton)((props) => { - const theme = getTheme_v2(props.theme) - return css` - border-radius: 50%; - width: ${theme.avatar.sizes[1].size}px; - height: ${theme.avatar.sizes[1].size}px; - ` -}) - const NameSkeleton = styled(Skeleton)((props) => { const theme = getTheme_v2(props.theme) return css`