Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implementation for events in history #7569

Draft
wants to merge 9 commits into
base: next
Choose a base branch
from
25 changes: 3 additions & 22 deletions packages/sanity/src/core/field/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,21 @@ import {
} from '@sanity/types'
import {type ComponentType} from 'react'

import {type DocumentGroupEvent} from '../store/events/types'
import {type FieldValueError} from './validation'

/**
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is replaced by the event types, that will be part of the event.chunk.type

* History timeline / chunking
*
*
* @hidden
* @beta
*/
export type ChunkType =
| 'initial'
| 'create'
| 'editDraft'
| 'delete'
| 'publish'
| 'unpublish'
| 'discardDraft'
| 'editLive'

/**
* @hidden
* @beta */
export type Chunk = {
index: number

id: string
type: ChunkType
start: number
end: number
startTimestamp: string
endTimestamp: string
authors: Set<string>
draftState: 'present' | 'missing' | 'unknown'
publishedState: 'present' | 'missing' | 'unknown'

event: DocumentGroupEvent
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ export default function HistoryTimelineStory() {
selected={realRevChunk === chunk}
>
<Stack space={2}>
<Text>{chunk.type}</Text>
<Text>{chunk.event.type}</Text>
<Text muted size={1}>
{format(new Date(chunk.endTimestamp), 'MMM d, YYY @ HH:mm')}
{format(new Date(chunk.event.type), 'MMM d, YYY @ HH:mm')}
</Text>
</Stack>
</Card>
Expand All @@ -169,7 +169,7 @@ export default function HistoryTimelineStory() {
selected={sinceTime === chunk}
>
<Stack space={2}>
<Text>{chunk.type}</Text>
<Text>{chunk.event.type}</Text>
<Text muted size={1}>
{format(new Date(chunk.endTimestamp), 'MMM d, YYY @ HH:mm')}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ export class Timeline {
const nextTransactionToChunk = this._chunks.length > 0 ? this._chunks.last.end : firstIdx
for (let idx = nextTransactionToChunk; idx <= lastIdx; idx++) {
const transaction = this._transactions.get(idx)
this._chunks.mergeAtEnd(chunkFromTransaction(transaction), mergeChunk)
const allTransactions = this._transactions.getAllItems
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To generate the event, we need the previous events, because some of them depend on checks like:

  • publishExisted?
  • draftExists?

All of this will be more easy to read going to the getEventFromTransaction function

this._chunks.mergeAtEnd(
chunkFromTransaction(this.publishedId, transaction, allTransactions),
mergeChunk,
)
}

// Add transactions at the beginning:
Expand All @@ -204,7 +208,12 @@ export class Timeline {

for (let idx = firstTransactionChunked - 1; idx >= firstIdx; idx--) {
const transaction = this._transactions.get(idx)
this._chunks.mergeAtBeginning(chunkFromTransaction(transaction), mergeChunk)
const allTransactions = this._transactions.getAllItems

this._chunks.mergeAtBeginning(
chunkFromTransaction(this.publishedId, transaction, allTransactions),
mergeChunk,
)
}
}

Expand All @@ -216,12 +225,14 @@ export class Timeline {

private _createInitialChunk() {
if (this.reachedEarliestEntry) {
if (this._chunks.first?.type === 'initial') return
if (this._chunks.first?.event.type === 'document.createVersion') return

const firstTx = this._transactions.first
if (!firstTx) return
const initialChunk = chunkFromTransaction(firstTx)
initialChunk.type = 'initial'
const allTransactions = this._transactions.getAllItems

const initialChunk = chunkFromTransaction(this.publishedId, firstTx, allTransactions)
initialChunk.event.type = 'document.createVersion'
initialChunk.id = '@initial'
initialChunk.end = initialChunk.start
this._chunks.addToBeginning(initialChunk)
Expand Down Expand Up @@ -275,7 +286,7 @@ export class Timeline {
chunkIdx--
) {
const currentChunk = this._chunks.get(chunkIdx)
if (currentChunk.type === 'publish' || currentChunk.type === 'initial') {
if (currentChunk.event.type === 'document.publishVersion' || currentChunk.id === '@initial') {
return currentChunk
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export class TwoEndedArray<T extends {index: number}> {
}
}

get getAllItems(): T[] {
return [...this._negative.slice().reverse(), ...this._postive.slice()]
}

get lastIdx(): number {
// Note: This also works correctly when _positive is empty (it returns -1)
return this._postive.length - 1
Expand Down
158 changes: 33 additions & 125 deletions packages/sanity/src/core/store/_legacy/history/history/chunker.ts
Original file line number Diff line number Diff line change
@@ -1,160 +1,68 @@
/* eslint-disable no-nested-ternary */
import {type MendozaEffectPair, type MendozaPatch} from '@sanity/types'

import {type Chunk, type ChunkType} from '../../../../field'
import {type Chunk} from '../../../../field'
import {getEventFromTransaction} from '../../../events/getDocumentEvents'
import {type EditDocumentVersionEvent} from '../../../events/types'
import {type Transaction} from './types'

function canMergeEdit(type: ChunkType) {
return type === 'create' || type === 'editDraft'
}

const CHUNK_WINDOW = 5 * 60 * 1000 // 5 minutes

function isWithinMergeWindow(a: string, b: string) {
return Date.parse(b) - Date.parse(a) < CHUNK_WINDOW
}

export function mergeChunk(left: Chunk, right: Chunk): Chunk | [Chunk, Chunk] {
if (left.end !== right.start) throw new Error('chunks are not next to each other')

// TODO: How to detect first squash/create

const draftState = combineState(left.draftState, right.draftState)
const publishedState = combineState(left.publishedState, right.publishedState)

if (left.type === 'delete' && right.type === 'editDraft') {
return [left, {...right, type: 'create', draftState, publishedState}]
}

// Convert deletes into either discardDraft or unpublish depending on what's been deleted.
if (right.type === 'delete') {
if (draftState === 'missing' && publishedState === 'present') {
return [left, {...right, type: 'discardDraft', draftState, publishedState}]
}

if (draftState === 'present' && publishedState === 'missing') {
return [left, {...right, type: 'unpublish', draftState, publishedState}]
}
const mergeEvents = (
leftEvent: EditDocumentVersionEvent,
rightEvent: EditDocumentVersionEvent,
): EditDocumentVersionEvent => {
const mergedEvents = leftEvent.mergedEvents || []
delete leftEvent.mergedEvents
return {
...rightEvent,
mergedEvents: [...mergedEvents, leftEvent],
}
}

/**
* @internal
* Decides whether to merge two chunks or not according to their type and timestamp
*/
export function mergeChunk(left: Chunk, right: Chunk): Chunk | [Chunk, Chunk] {
if (left.end !== right.start) throw new Error('chunks are not next to each other')
if (
canMergeEdit(left.type) &&
right.type === 'editDraft' &&
left.event.type === 'document.editVersion' &&
right.event.type === 'document.editVersion' &&
isWithinMergeWindow(left.endTimestamp, right.startTimestamp)
) {
const authors = new Set<string>()
for (const author of left.authors) authors.add(author)
for (const author of right.authors) authors.add(author)

return {
index: 0,
id: right.id,
type: left.type,
start: left.start,
end: right.end,
event: mergeEvents(left.event, right.event),
startTimestamp: left.startTimestamp,
endTimestamp: right.endTimestamp,
authors,
draftState,
publishedState,
}
}

return [left, {...right, draftState, publishedState}]
}

type ChunkState = 'unedited' | 'deleted' | 'upsert'
function getChunkState(effect?: MendozaEffectPair): ChunkState {
const modified = Boolean(effect)
const deleted = effect && isDeletePatch(effect?.apply)

if (deleted) {
return 'deleted'
}

if (modified) {
return 'upsert'
}

return 'unedited'
return [left, right]
}

/*
* getChunkType tries to determine what effect the given transaction had on the document
* More information about the logic can be found here https://github.com/sanity-io/sanity/pull/2633#issuecomment-886461812
*
* | | draft unedited | draft deleted | draft upsert |
* |--------------------|----------------|---------------|--------------|
* | published unedited | X | delete | editDraft |
* | published deleted | delete | delete | delete |
* | published upsert | liveEdit | publish | liveEdit |
/**
* @internal
* Creates a chunk for the timeline from a transaction.
*/
function getChunkType(transaction: Transaction): ChunkType {
const draftState = getChunkState(transaction.draftEffect)
const publishedState = getChunkState(transaction.publishedEffect)

if (publishedState === 'unedited') {
if (draftState === 'deleted') {
return 'delete'
}

if (draftState === 'upsert') {
return 'editDraft'
}
}

if (publishedState === 'deleted') {
return 'delete'
}

if (publishedState === 'upsert') {
if (draftState === 'unedited') {
return 'editLive'
}

if (draftState === 'deleted') {
return 'publish'
}

if (draftState === 'upsert') {
return 'editLive'
}
}

return 'editLive'
}

export function chunkFromTransaction(transaction: Transaction): Chunk {
const modifiedDraft = Boolean(transaction.draftEffect)
const modifiedPublished = Boolean(transaction.publishedEffect)

const draftDeleted = transaction.draftEffect && isDeletePatch(transaction.draftEffect.apply)
const publishedDeleted =
transaction.publishedEffect && isDeletePatch(transaction.publishedEffect.apply)

const type = getChunkType(transaction)

export function chunkFromTransaction(
publishedId: string,
transaction: Transaction,
transactions: Transaction[],
): Chunk {
const previousTransactions = transactions.filter((tx) => tx.index < transaction.index).reverse()
return {
index: 0,
id: transaction.id,
type,
start: transaction.index,
end: transaction.index + 1,
startTimestamp: transaction.timestamp,
endTimestamp: transaction.timestamp,
authors: new Set([transaction.author]),
draftState: modifiedDraft ? (draftDeleted ? 'missing' : 'present') : 'unknown',
publishedState: modifiedPublished ? (publishedDeleted ? 'missing' : 'present') : 'unknown',
event: getEventFromTransaction(publishedId, transaction, previousTransactions),
}
}

function combineState(
left: 'present' | 'missing' | 'unknown',
right: 'present' | 'missing' | 'unknown',
) {
return right === 'unknown' ? left : right
}

export function isDeletePatch(patch: MendozaPatch): boolean {
return patch[0] === 0 && patch[1] === null
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './chunker'
export * from './Timeline'
export * from './TimelineController'
export * from './types'
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ export function useTimelineStore({
.pipe(
map((innerController) => {
const chunks = innerController.timeline.mapChunks((c) => c)
const lastNonDeletedChunk = chunks.filter(
(chunk) => !['delete', 'initial'].includes(chunk.type),
const lastNonDeletedChunk = chunks.find(
(chunk) =>
![
'document.deleteGroup',
'document.deleteVersion',
'document.createVersion',
].includes(chunk.event.type),
)
const hasMoreChunks = !innerController.timeline.reachedEarliestEntry

Expand All @@ -220,7 +225,7 @@ export function useTimelineStore({
isLoading: false,
isPristine: timelineReady ? chunks.length === 0 && hasMoreChunks === false : null,
hasMoreChunks: !innerController.timeline.reachedEarliestEntry,
lastNonDeletedRevId: lastNonDeletedChunk?.[0]?.id,
lastNonDeletedRevId: lastNonDeletedChunk?.id || null,
onOlderRevision: innerController.onOlderRevision(),
realRevChunk: innerController.realRevChunk,
revTime: innerController.revTime,
Expand All @@ -229,7 +234,7 @@ export function useTimelineStore({
sinceTime: innerController.sinceTime,
timelineDisplayed: innerController.displayed(),
timelineReady,
}
} satisfies TimelineState
}),
// Only emit (and in turn, re-render) when values have changed
distinctUntilChanged(deepEquals),
Expand Down
Loading
Loading