Skip to content

Commit 7a4fb1b

Browse files
committedMar 4, 2025·
feat(sanity): add document comparison tool (#7907)
1 parent 64a75ef commit 7a4fb1b

26 files changed

+1300
-40
lines changed
 

‎packages/sanity/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261
"rxjs-exhaustmap-with-trailing": "^2.1.1",
262262
"rxjs-mergemap-array": "^0.1.0",
263263
"scroll-into-view-if-needed": "^3.0.3",
264+
"scrollmirror": "^1.2.0",
264265
"semver": "^7.3.5",
265266
"shallow-equals": "^1.0.0",
266267
"speakingurl": "^14.0.1",

‎packages/sanity/src/core/releases/i18n/resources.ts

-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ const releasesLocaleStrings = {
1212
'action.archive.tooltip': 'Unschedule this release to archive it',
1313
/** Action text for showing the archived releases */
1414
'action.archived': 'Archived',
15-
/** Action text for comparing document versions */
16-
'action.compare-versions': 'Compare versions',
1715
/** Action text for reverting a release by creating a new release */
1816
'action.create-revert-release': 'Stage in new release',
1917
/** Action text for deleting a release */

‎packages/sanity/src/core/releases/plugin/documentActions/index.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export default function resolveDocumentActions(
1111
): Action[] {
1212
const duplicateAction = existingActions.filter(({name}) => name === 'DuplicateAction')
1313

14-
return context.versionType === 'version'
15-
? duplicateAction.concat(DiscardVersionAction).concat(UnpublishVersionAction)
16-
: existingActions
14+
if (context.versionType === 'version') {
15+
return duplicateAction.concat(DiscardVersionAction, UnpublishVersionAction)
16+
}
17+
18+
return existingActions
1719
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {styled} from 'styled-components'
2+
3+
export const DialogLayout = styled.div`
4+
--offset-block: 40px;
5+
display: grid;
6+
height: calc(100vh - var(--offset-block));
7+
min-height: 0;
8+
overflow: hidden;
9+
grid-template-areas:
10+
'header header'
11+
'previous-document next-document';
12+
grid-template-columns: 1fr 1fr;
13+
grid-template-rows: min-content minmax(0, 1fr);
14+
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {type ComponentType, useContext, useMemo, useState} from 'react'
2+
import {type DocumentLayoutProps} from 'sanity'
3+
import {ReferenceInputOptionsContext} from 'sanity/_singletons'
4+
5+
import {Dialog} from '../../../ui-components/dialog/Dialog'
6+
import {useCreatePathSyncChannel} from '../hooks/useCreatePathSyncChannel'
7+
import {useDiffViewRouter} from '../hooks/useDiffViewRouter'
8+
import {useDiffViewState} from '../hooks/useDiffViewState'
9+
import {useScrollMirror} from '../hooks/useScrollMirror'
10+
import {VersionModeHeader} from '../versionMode/components/VersionModeHeader'
11+
import {DialogLayout} from './DialogLayout'
12+
import {DiffViewPane} from './DiffViewPane'
13+
import {EditReferenceLinkComponent} from './EditReferenceLinkComponent'
14+
15+
export const DiffView: ComponentType<Pick<DocumentLayoutProps, 'documentId'>> = ({documentId}) => {
16+
const {documents, state, mode} = useDiffViewState()
17+
const {exitDiffView} = useDiffViewRouter()
18+
const syncChannel = useCreatePathSyncChannel()
19+
const [previousPaneElement, setPreviousPaneElement] = useState<HTMLElement | null>(null)
20+
const [nextPaneElement, setNextPaneElement] = useState<HTMLElement | null>(null)
21+
const referenceInputOptionsContext = useContext(ReferenceInputOptionsContext)
22+
23+
const diffViewReferenceInputOptionsContext = useMemo(
24+
() => ({
25+
...referenceInputOptionsContext,
26+
disableNew: true,
27+
EditReferenceLinkComponent,
28+
}),
29+
[referenceInputOptionsContext],
30+
)
31+
32+
useScrollMirror([previousPaneElement, nextPaneElement])
33+
34+
return (
35+
<ReferenceInputOptionsContext.Provider value={diffViewReferenceInputOptionsContext}>
36+
<Dialog
37+
id="diffView"
38+
width="auto"
39+
onClose={exitDiffView}
40+
padding={false}
41+
__unstable_hideCloseButton
42+
>
43+
<DialogLayout>
44+
{mode === 'version' && <VersionModeHeader documentId={documentId} state={state} />}
45+
{state === 'ready' && (
46+
<>
47+
<DiffViewPane
48+
documentType={documents.previous.type}
49+
documentId={documents.previous.id}
50+
role="previous"
51+
ref={setPreviousPaneElement}
52+
scrollElement={previousPaneElement}
53+
syncChannel={syncChannel}
54+
compareDocument={documents.previous}
55+
/>
56+
<DiffViewPane
57+
documentType={documents.next.type}
58+
documentId={documents.next.id}
59+
role="next"
60+
ref={setNextPaneElement}
61+
scrollElement={nextPaneElement}
62+
syncChannel={syncChannel}
63+
// The previous document's edit state is used to calculate the diff inroduced by the next document.
64+
compareDocument={documents.previous}
65+
/>
66+
</>
67+
)}
68+
</DialogLayout>
69+
</Dialog>
70+
</ReferenceInputOptionsContext.Provider>
71+
)
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import {type Path, type SanityDocument} from '@sanity/types'
2+
import {
3+
BoundaryElementProvider,
4+
Card,
5+
Container as UiContainer,
6+
DialogProvider,
7+
PortalProvider,
8+
} from '@sanity/ui'
9+
import {noop} from 'lodash'
10+
import {
11+
type CSSProperties,
12+
forwardRef,
13+
useCallback,
14+
useEffect,
15+
useMemo,
16+
useRef,
17+
useState,
18+
} from 'react'
19+
import {
20+
ChangeIndicatorsTracker,
21+
createPatchChannel,
22+
FormBuilder,
23+
getPublishedId,
24+
getVersionFromId,
25+
isDraftId,
26+
isPublishedId,
27+
isVersionId,
28+
LoadingBlock,
29+
useDocumentForm,
30+
useEditState,
31+
VirtualizerScrollInstanceProvider,
32+
} from 'sanity'
33+
import {ConnectorContext} from 'sanity/_singletons'
34+
import {styled} from 'styled-components'
35+
36+
import {usePathSyncChannel} from '../hooks/usePathSyncChannel'
37+
import {type PathSyncChannel} from '../types/pathSyncChannel'
38+
import {Scroller} from './Scroller'
39+
40+
const DiffViewPaneLayout = styled(Card)`
41+
position: relative;
42+
grid-area: var(--grid-area);
43+
`
44+
45+
const Container = styled(UiContainer)`
46+
width: auto;
47+
`
48+
49+
interface DiffViewPaneProps {
50+
documentType: string
51+
documentId: string
52+
role: 'previous' | 'next'
53+
scrollElement: HTMLElement | null
54+
syncChannel: PathSyncChannel
55+
compareDocument: {
56+
type: string
57+
id: string
58+
}
59+
}
60+
61+
export const DiffViewPane = forwardRef<HTMLDivElement, DiffViewPaneProps>(function DiffViewPane(
62+
{role, documentType, documentId, scrollElement, syncChannel, compareDocument},
63+
ref,
64+
) {
65+
const containerElement = useRef<HTMLDivElement | null>(null)
66+
const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)
67+
const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)
68+
const compareValue = useCompareValue({compareDocument})
69+
const [patchChannel] = useState(() => createPatchChannel())
70+
71+
const {
72+
formState,
73+
onChange,
74+
onFocus,
75+
onBlur,
76+
onSetActiveFieldGroup,
77+
onSetCollapsedFieldSet,
78+
onSetCollapsedPath,
79+
collapsedFieldSets,
80+
ready,
81+
collapsedPaths,
82+
schemaType,
83+
value,
84+
onProgrammaticFocus,
85+
...documentForm
86+
} = useDocumentForm({
87+
documentId: getPublishedId(documentId),
88+
documentType,
89+
selectedPerspectiveName: perspectiveName(documentId),
90+
releaseId: getVersionFromId(documentId),
91+
comparisonValue: compareValue,
92+
})
93+
94+
const isLoading = formState === null || !ready
95+
96+
const pathSyncChannel = usePathSyncChannel({
97+
id: role,
98+
syncChannel,
99+
})
100+
101+
const onPathOpen = useCallback(
102+
(path: Path) => {
103+
documentForm.onPathOpen(path)
104+
pathSyncChannel.push({source: role, path})
105+
},
106+
[documentForm, pathSyncChannel, role],
107+
)
108+
109+
useEffect(() => {
110+
const subscription = pathSyncChannel.path.subscribe((path) => onProgrammaticFocus(path))
111+
return () => subscription.unsubscribe()
112+
}, [onProgrammaticFocus, pathSyncChannel.path])
113+
114+
return (
115+
<ConnectorContext.Provider
116+
value={{
117+
// Render the change indicators inertly, because the diff view modal does not currently
118+
// provide a way to display document inspectors.
119+
isInteractive: false,
120+
onOpenReviewChanges: noop,
121+
onSetFocus: noop,
122+
isReviewChangesOpen: false,
123+
}}
124+
>
125+
<ChangeIndicatorsTracker>
126+
<VirtualizerScrollInstanceProvider
127+
scrollElement={scrollElement}
128+
containerElement={containerElement}
129+
>
130+
<BoundaryElementProvider element={boundaryElement}>
131+
<DiffViewPaneLayout
132+
ref={setBoundaryElement}
133+
style={
134+
{
135+
'--grid-area': `${role}-document`,
136+
} as CSSProperties
137+
}
138+
borderLeft={role === 'next'}
139+
>
140+
<Scroller
141+
ref={ref}
142+
style={
143+
{
144+
// The scroll position is synchronised between panes. This style hides the
145+
// scrollbar for all panes except the one displaying the next document.
146+
'--scrollbar-width': role !== 'next' && 'none',
147+
} as CSSProperties
148+
}
149+
>
150+
<PortalProvider element={portalElement}>
151+
<DialogProvider position="absolute">
152+
{isLoading ? (
153+
<LoadingBlock showText />
154+
) : (
155+
<Container ref={containerElement} padding={4} width={1}>
156+
<FormBuilder
157+
// eslint-disable-next-line camelcase
158+
__internal_patchChannel={patchChannel}
159+
id={`diffView-pane-${role}`}
160+
onChange={onChange}
161+
onPathFocus={onFocus}
162+
onPathOpen={onPathOpen}
163+
onPathBlur={onBlur}
164+
onFieldGroupSelect={onSetActiveFieldGroup}
165+
onSetFieldSetCollapsed={onSetCollapsedFieldSet}
166+
onSetPathCollapsed={onSetCollapsedPath}
167+
collapsedPaths={collapsedPaths}
168+
collapsedFieldSets={collapsedFieldSets}
169+
focusPath={formState.focusPath}
170+
changed={formState.changed}
171+
focused={formState.focused}
172+
groups={formState.groups}
173+
validation={formState.validation}
174+
members={formState.members}
175+
presence={formState.presence}
176+
schemaType={schemaType}
177+
value={value}
178+
/>
179+
</Container>
180+
)}
181+
</DialogProvider>
182+
</PortalProvider>
183+
</Scroller>
184+
<div data-testid="diffView-document-panel-portal" ref={setPortalElement} />
185+
</DiffViewPaneLayout>
186+
</BoundaryElementProvider>
187+
</VirtualizerScrollInstanceProvider>
188+
</ChangeIndicatorsTracker>
189+
</ConnectorContext.Provider>
190+
)
191+
})
192+
193+
function perspectiveName(documentId: string): string | undefined {
194+
if (isVersionId(documentId)) {
195+
return getVersionFromId(documentId)
196+
}
197+
198+
if (isPublishedId(documentId)) {
199+
return 'published'
200+
}
201+
202+
return undefined
203+
}
204+
205+
type UseCompareValueOptions = Pick<DiffViewPaneProps, 'compareDocument'>
206+
207+
/**
208+
* Fetch the contents of `compareDocument` for comparison with another version of the document.
209+
*/
210+
function useCompareValue({compareDocument}: UseCompareValueOptions): SanityDocument | undefined {
211+
const compareDocumentEditState = useEditState(
212+
getPublishedId(compareDocument.id),
213+
compareDocument.type,
214+
'low',
215+
getVersionFromId(compareDocument.id),
216+
)
217+
218+
return useMemo(() => {
219+
if (isVersionId(compareDocument.id)) {
220+
return compareDocumentEditState.version ?? undefined
221+
}
222+
223+
if (isDraftId(compareDocument.id)) {
224+
return compareDocumentEditState.draft ?? undefined
225+
}
226+
227+
if (isPublishedId(compareDocument.id)) {
228+
return compareDocumentEditState.published ?? undefined
229+
}
230+
231+
return undefined
232+
}, [
233+
compareDocument.id,
234+
compareDocumentEditState.draft,
235+
compareDocumentEditState.published,
236+
compareDocumentEditState.version,
237+
])
238+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {type ReferenceInputOptions} from 'sanity'
2+
import {useIntentLink} from 'sanity/router'
3+
import {styled} from 'styled-components'
4+
5+
const Link = styled.a`
6+
flex: 1;
7+
text-decoration: none;
8+
color: inherit;
9+
`
10+
11+
export const EditReferenceLinkComponent: ReferenceInputOptions['EditReferenceLinkComponent'] = ({
12+
children,
13+
documentId: _documentId,
14+
documentType,
15+
}) => {
16+
const {href} = useIntentLink({
17+
intent: 'edit',
18+
params: {
19+
id: _documentId,
20+
type: documentType,
21+
},
22+
})
23+
24+
return (
25+
<Link href={href} target="_blank" rel="noopener noreferrer">
26+
{children}
27+
</Link>
28+
)
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {type ComponentType} from 'react'
2+
3+
import {usePathSyncChannel} from '../hooks/usePathSyncChannel'
4+
import {type PathSyncChannelProps} from '../types/pathSyncChannel'
5+
6+
export const PathSyncChannelSubscriber: ComponentType<PathSyncChannelProps> = (props) => {
7+
usePathSyncChannel(props)
8+
return undefined
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {styled} from 'styled-components'
2+
3+
export const Scroller = styled.div`
4+
position: relative;
5+
height: 100%;
6+
overflow: auto;
7+
scroll-behavior: smooth;
8+
scrollbar-width: var(--scrollbar-width);
9+
overscroll-behavior: contain;
10+
will-change: scroll-position;
11+
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @internal
3+
*/
4+
export const DIFF_VIEW_SEARCH_PARAMETER = 'diffView'
5+
6+
/**
7+
* @internal
8+
*/
9+
export const DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER = 'previousDocument'
10+
11+
/**
12+
* @internal
13+
*/
14+
export const DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER = 'nextDocument'
15+
16+
/**
17+
* @internal
18+
*/
19+
export const DIFF_SEARCH_PARAM_DELIMITER = ','
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {useMemo} from 'react'
2+
import {Subject} from 'rxjs'
3+
4+
import {type PathSyncChannel, type PathSyncState} from '../types/pathSyncChannel'
5+
6+
/**
7+
* @internal
8+
*/
9+
export function useCreatePathSyncChannel(): PathSyncChannel {
10+
return useMemo(() => new Subject<PathSyncState>(), [])
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {fromPairs, toPairs} from 'lodash'
2+
import {useCallback} from 'react'
3+
import {useRouter} from 'sanity/router'
4+
5+
import {
6+
DIFF_SEARCH_PARAM_DELIMITER,
7+
DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER,
8+
DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER,
9+
DIFF_VIEW_SEARCH_PARAMETER,
10+
} from '../constants'
11+
import {type DiffViewMode} from '../types/diffViewMode'
12+
13+
type NavigateDiffView = (
14+
options: {
15+
mode?: DiffViewMode
16+
} & Partial<
17+
Record<
18+
'previousDocument' | 'nextDocument',
19+
{
20+
type: string
21+
id: string
22+
}
23+
>
24+
>,
25+
) => void
26+
27+
export interface DiffViewRouter {
28+
navigateDiffView: NavigateDiffView
29+
exitDiffView: () => void
30+
}
31+
32+
/**
33+
* @internal
34+
*/
35+
export function useDiffViewRouter(): DiffViewRouter {
36+
const {navigate, state: routerState} = useRouter()
37+
38+
const navigateDiffView = useCallback<NavigateDiffView>(
39+
({mode, previousDocument, nextDocument}) => {
40+
const next = {
41+
...fromPairs(routerState._searchParams),
42+
...(mode
43+
? {
44+
[DIFF_VIEW_SEARCH_PARAMETER]: mode,
45+
}
46+
: {}),
47+
...(previousDocument
48+
? {
49+
[DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER]: [
50+
previousDocument.type,
51+
previousDocument.id,
52+
].join(DIFF_SEARCH_PARAM_DELIMITER),
53+
}
54+
: {}),
55+
...(nextDocument
56+
? {
57+
[DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER]: [nextDocument.type, nextDocument.id].join(
58+
DIFF_SEARCH_PARAM_DELIMITER,
59+
),
60+
}
61+
: {}),
62+
}
63+
64+
navigate({
65+
...routerState,
66+
_searchParams: toPairs(next),
67+
})
68+
},
69+
[navigate, routerState],
70+
)
71+
72+
const exitDiffView = useCallback(() => {
73+
navigate({
74+
...routerState,
75+
_searchParams: (routerState._searchParams ?? []).filter(
76+
([key]) =>
77+
![
78+
DIFF_VIEW_SEARCH_PARAMETER,
79+
DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER,
80+
DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER,
81+
].includes(key),
82+
),
83+
})
84+
}, [navigate, routerState])
85+
86+
return {
87+
navigateDiffView,
88+
exitDiffView,
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {useEffect, useMemo} from 'react'
2+
import {useRouter} from 'sanity/router'
3+
4+
import {
5+
DIFF_SEARCH_PARAM_DELIMITER,
6+
DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER,
7+
DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER,
8+
DIFF_VIEW_SEARCH_PARAMETER,
9+
} from '../constants'
10+
import {type DiffViewMode, diffViewModes} from '../types/diffViewMode'
11+
12+
function isDiffViewMode(maybeDiffViewMode: unknown): maybeDiffViewMode is DiffViewMode {
13+
return diffViewModes.includes(maybeDiffViewMode as DiffViewMode)
14+
}
15+
16+
type DiffViewState =
17+
| {
18+
isActive: true
19+
state: 'ready'
20+
mode: DiffViewMode
21+
documents: Record<
22+
'previous' | 'next',
23+
{
24+
type: string
25+
id: string
26+
}
27+
>
28+
}
29+
| {
30+
isActive: true
31+
state: 'error'
32+
mode: DiffViewMode
33+
documents?: never
34+
}
35+
| {
36+
isActive: false
37+
state?: never
38+
mode?: never
39+
documents?: never
40+
}
41+
42+
export function useDiffViewState({
43+
onParamsError,
44+
}: {
45+
onParamsError?: (errors: DiffViewStateErrorWithInput[]) => void
46+
} = {}): DiffViewState {
47+
const {state: routerState} = useRouter()
48+
const searchParams = new URLSearchParams(routerState._searchParams)
49+
const previousDocument = searchParams.get(DIFF_VIEW_PREVIOUS_DOCUMENT_SEARCH_PARAMETER)
50+
const nextDocument = searchParams.get(DIFF_VIEW_NEXT_DOCUMENT_SEARCH_PARAMETER)
51+
const mode = searchParams.get(DIFF_VIEW_SEARCH_PARAMETER)
52+
const anyParamSet = [previousDocument, nextDocument, mode].some((param) => param !== null)
53+
54+
const params = useMemo(
55+
() =>
56+
parseParams({
57+
previousDocument: previousDocument ?? '',
58+
nextDocument: nextDocument ?? '',
59+
mode: mode ?? '',
60+
}),
61+
[mode, nextDocument, previousDocument],
62+
)
63+
64+
useEffect(() => {
65+
if (params.result === 'error' && anyParamSet) {
66+
onParamsError?.(params.errors)
67+
}
68+
}, [anyParamSet, onParamsError, params])
69+
70+
if (params.result === 'error') {
71+
return {
72+
isActive: false,
73+
}
74+
}
75+
76+
return {
77+
state: 'ready',
78+
isActive: true,
79+
...params.params,
80+
}
81+
}
82+
83+
type DiffViewStateError =
84+
| 'invalidModeParam'
85+
| 'invalidPreviousDocumentParam'
86+
| 'invalidNextDocumentParam'
87+
88+
type DiffViewStateErrorWithInput = [error: DiffViewStateError, input: unknown]
89+
90+
interface ParamsSuccess {
91+
result: 'success'
92+
params: Pick<DiffViewState & {state: 'ready'}, 'mode' | 'documents'>
93+
}
94+
95+
interface ParamsError {
96+
result: 'error'
97+
errors: DiffViewStateErrorWithInput[]
98+
}
99+
100+
function parseParams({
101+
previousDocument,
102+
nextDocument,
103+
mode,
104+
}: {
105+
previousDocument: string
106+
nextDocument: string
107+
mode: string
108+
}): ParamsSuccess | ParamsError {
109+
const errors: DiffViewStateErrorWithInput[] = []
110+
111+
const [previousDocumentType, previousDocumentId] = previousDocument
112+
.split(DIFF_SEARCH_PARAM_DELIMITER)
113+
.filter(Boolean)
114+
115+
const [nextDocumentType, nextDocumentId] = nextDocument
116+
.split(DIFF_SEARCH_PARAM_DELIMITER)
117+
.filter(Boolean)
118+
119+
if (!isDiffViewMode(mode)) {
120+
errors.push(['invalidModeParam', mode])
121+
}
122+
123+
if (typeof previousDocumentType === 'undefined' || typeof previousDocumentId === 'undefined') {
124+
errors.push(['invalidPreviousDocumentParam', previousDocument])
125+
}
126+
127+
if (typeof nextDocumentType === 'undefined' || typeof nextDocumentId === 'undefined') {
128+
errors.push(['invalidNextDocumentParam', nextDocument])
129+
}
130+
131+
if (errors.length !== 0) {
132+
return {
133+
result: 'error',
134+
errors,
135+
}
136+
}
137+
138+
return {
139+
result: 'success',
140+
params: {
141+
mode,
142+
documents: {
143+
previous: {
144+
type: previousDocumentType,
145+
id: previousDocumentId,
146+
},
147+
next: {
148+
type: nextDocumentType,
149+
id: nextDocumentId,
150+
},
151+
},
152+
},
153+
} as ParamsSuccess
154+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {type Path} from '@sanity/types'
2+
import {useCallback, useMemo} from 'react'
3+
import deepEquals from 'react-fast-compare'
4+
import {distinctUntilChanged, filter, map, type Observable} from 'rxjs'
5+
6+
import {type PathSyncChannelProps, type PathSyncState} from '../types/pathSyncChannel'
7+
8+
type Push = (state: PathSyncState) => void
9+
10+
/**
11+
* Synchronise the open path between multiple document panes.
12+
*
13+
* @internal
14+
*/
15+
export function usePathSyncChannel({syncChannel, id}: PathSyncChannelProps): {
16+
push: Push
17+
path: Observable<Path>
18+
} {
19+
const push = useCallback<Push>(
20+
(state) => syncChannel.next({...state, source: id}),
21+
[id, syncChannel],
22+
)
23+
24+
const path = useMemo(
25+
() =>
26+
syncChannel.pipe(
27+
distinctUntilChanged<PathSyncState>((previous, next) =>
28+
deepEquals(previous.path, next.path),
29+
),
30+
filter(({source}) => source !== id),
31+
map((state) => state.path),
32+
),
33+
[id, syncChannel],
34+
)
35+
36+
return {
37+
path,
38+
push,
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {useEffect} from 'react'
2+
import ScrollMirror from 'scrollmirror'
3+
4+
/**
5+
* Use the ScrollMirror library to synchronise the scroll position for an array of elements.
6+
*
7+
* @internal
8+
*/
9+
export function useScrollMirror(elements: (HTMLElement | null)[]): void {
10+
useEffect(() => {
11+
const existentElements = elements.filter((element) => element !== null)
12+
13+
if (existentElements.length === 0) {
14+
return undefined
15+
}
16+
17+
const scrollMirror = new ScrollMirror(existentElements)
18+
return () => scrollMirror.destroy()
19+
}, [elements])
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './hooks/useDiffViewRouter'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {useToast} from '@sanity/ui'
2+
import {type ComponentType, type PropsWithChildren} from 'react'
3+
import {type DocumentLayoutProps, useTranslation} from 'sanity'
4+
5+
import {structureLocaleNamespace} from '../../i18n'
6+
import {DiffView} from '../components/DiffView'
7+
import {useDiffViewState} from '../hooks/useDiffViewState'
8+
9+
export const DiffViewDocumentLayout: ComponentType<
10+
PropsWithChildren<Pick<DocumentLayoutProps, 'documentId' | 'documentType'>>
11+
> = ({children, documentId}) => {
12+
const toast = useToast()
13+
const {t} = useTranslation(structureLocaleNamespace)
14+
const {isActive} = useDiffViewState({
15+
onParamsError: (errors) => {
16+
toast.push({
17+
id: 'diffViewParamsParsingError',
18+
status: 'error',
19+
title: t('compare-version.error.invalidParams.title'),
20+
description: (
21+
<ul>
22+
{errors.map(([error, input]) => (
23+
<li key={error}>
24+
{t(`compare-version.error.${error}`, {
25+
input,
26+
})}
27+
</li>
28+
))}
29+
</ul>
30+
),
31+
})
32+
},
33+
})
34+
35+
return (
36+
<>
37+
{children}
38+
{isActive && <DiffView documentId={documentId} />}
39+
</>
40+
)
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @internal
3+
*/
4+
export const diffViewModes = ['version'] as const
5+
6+
/**
7+
* @internal
8+
*/
9+
export type DiffViewMode = (typeof diffViewModes)[number]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {type Path} from '@sanity/types'
2+
import {type Subject} from 'rxjs'
3+
4+
/**
5+
* @internal
6+
*/
7+
export interface PathSyncState {
8+
path: Path
9+
source?: string
10+
}
11+
12+
/**
13+
* @internal
14+
*/
15+
export type PathSyncChannel = Subject<PathSyncState>
16+
17+
/**
18+
* @internal
19+
*/
20+
export interface PathSyncChannelProps {
21+
syncChannel: PathSyncChannel
22+
id: string
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import {CloseIcon, LockIcon, TransferIcon} from '@sanity/icons'
2+
import {
3+
Box,
4+
// eslint-disable-next-line no-restricted-imports -- we need more control over how the `Button` component is rendered
5+
Button,
6+
type ButtonTone,
7+
Flex,
8+
Menu,
9+
// eslint-disable-next-line no-restricted-imports -- the `VersionModeHeader` component needs more control over how the `MenuItem` component is rendered
10+
MenuItem,
11+
Stack,
12+
Text,
13+
} from '@sanity/ui'
14+
import {type TFunction} from 'i18next'
15+
import {type ComponentProps, type ComponentType, useCallback, useMemo} from 'react'
16+
import {
17+
type DocumentLayoutProps,
18+
formatRelativeLocalePublishDate,
19+
getDraftId,
20+
getPublishedId,
21+
getReleaseIdFromReleaseDocumentId,
22+
getReleaseTone,
23+
getVersionFromId,
24+
getVersionId,
25+
isDraftId,
26+
isPublishedId,
27+
isReleaseDocument,
28+
isReleaseScheduledOrScheduling,
29+
ReleaseAvatar,
30+
type ReleaseDocument,
31+
useActiveReleases,
32+
useDocumentVersions,
33+
useEditState,
34+
useTranslation,
35+
} from 'sanity'
36+
import {styled} from 'styled-components'
37+
38+
import {MenuButton} from '../../../../ui-components/menuButton/MenuButton'
39+
import {structureLocaleNamespace} from '../../../i18n'
40+
import {useDiffViewRouter} from '../../hooks/useDiffViewRouter'
41+
import {useDiffViewState} from '../../hooks/useDiffViewState'
42+
43+
const VersionModeHeaderLayout = styled.header`
44+
display: grid;
45+
grid-area: header;
46+
grid-template-columns: 1fr min-content 1fr;
47+
border-block-end: 1px solid var(--card-border-color);
48+
`
49+
50+
const VersionModeHeaderLayoutSection = styled.div`
51+
display: flex;
52+
justify-content: space-between;
53+
align-items: center;
54+
`
55+
56+
/**
57+
* The header component that is rendered when diff view is being used to compare versions of a
58+
* document.
59+
*
60+
* @internal
61+
*/
62+
export const VersionModeHeader: ComponentType<
63+
{state: 'pending' | 'ready' | 'error'} & Pick<DocumentLayoutProps, 'documentId'>
64+
> = ({documentId, state}) => {
65+
const {t} = useTranslation(structureLocaleNamespace)
66+
const {data: documentVersions} = useDocumentVersions({documentId})
67+
const {exitDiffView, navigateDiffView} = useDiffViewRouter()
68+
const {documents} = useDiffViewState()
69+
const activeReleases = useActiveReleases()
70+
const releasesIds = documentVersions.flatMap((id) => getVersionFromId(id) ?? [])
71+
72+
const releases = useMemo(() => {
73+
return activeReleases.data.filter((release) => {
74+
const releaseId = getReleaseIdFromReleaseDocumentId(release._id)
75+
return typeof releaseId !== 'undefined' && releasesIds.includes(releaseId)
76+
})
77+
}, [activeReleases.data, releasesIds])
78+
79+
const onSelectPreviousRelease = useCallback(
80+
(selectedDocumentId: string): void => {
81+
if (typeof documents?.previous !== 'undefined') {
82+
navigateDiffView({
83+
previousDocument: {
84+
...documents.previous,
85+
id: selectedDocumentId,
86+
},
87+
})
88+
}
89+
},
90+
[documents?.previous, navigateDiffView],
91+
)
92+
93+
const onSelectNextRelease = useCallback(
94+
(selectedDocumentId: string): void => {
95+
if (typeof documents?.next !== 'undefined') {
96+
navigateDiffView({
97+
nextDocument: {
98+
...documents.next,
99+
id: selectedDocumentId,
100+
},
101+
})
102+
}
103+
},
104+
[documents?.next, navigateDiffView],
105+
)
106+
107+
return (
108+
<VersionModeHeaderLayout>
109+
<VersionModeHeaderLayoutSection>
110+
<Box padding={4}>
111+
<Text as="h1" size={1} muted>
112+
{t('compare-versions.title')}
113+
</Text>
114+
</Box>
115+
{typeof documents?.previous !== 'undefined' && (
116+
<VersionMenu
117+
releases={releases}
118+
onSelectRelease={onSelectPreviousRelease}
119+
role="previous"
120+
documentId={documentId}
121+
state={state}
122+
document={documents.previous}
123+
/>
124+
)}
125+
</VersionModeHeaderLayoutSection>
126+
<Flex align="center" paddingX={3}>
127+
<Text size={1}>
128+
<TransferIcon />
129+
</Text>
130+
</Flex>
131+
<VersionModeHeaderLayoutSection>
132+
{typeof documents?.next !== 'undefined' && (
133+
<VersionMenu
134+
releases={releases}
135+
onSelectRelease={onSelectNextRelease}
136+
role="next"
137+
documentId={documentId}
138+
state={state}
139+
document={documents.next}
140+
/>
141+
)}
142+
<Box
143+
padding={3}
144+
style={{
145+
justifySelf: 'end',
146+
}}
147+
>
148+
<Button icon={CloseIcon} mode="bleed" onClick={exitDiffView} padding={2} />
149+
</Box>
150+
</VersionModeHeaderLayoutSection>
151+
</VersionModeHeaderLayout>
152+
)
153+
}
154+
155+
interface VersionMenuProps {
156+
state: 'pending' | 'ready' | 'error'
157+
releases: ReleaseDocument[]
158+
role: 'previous' | 'next'
159+
onSelectRelease: (releaseId: string) => void
160+
documentId: string
161+
document: {
162+
type: string
163+
id: string
164+
}
165+
}
166+
167+
const VersionMenu: ComponentType<VersionMenuProps> = ({
168+
releases = [],
169+
onSelectRelease,
170+
role,
171+
documentId,
172+
document,
173+
}) => {
174+
const {published, draft} = useEditState(getPublishedId(document.id), document.type)
175+
const selected = useMemo(() => findRelease(document.id, releases), [document.id, releases])
176+
const {t: tStructure} = useTranslation(structureLocaleNamespace)
177+
const {t: tCore} = useTranslation()
178+
179+
return (
180+
<MenuButton
181+
id={role}
182+
button={
183+
<Button
184+
type="button"
185+
mode="bleed"
186+
padding={2}
187+
paddingRight={3}
188+
radius="full"
189+
selected
190+
{...getMenuButtonProps({selected, tCore, tStructure})}
191+
/>
192+
}
193+
menu={
194+
<Menu>
195+
{published && (
196+
<VersionMenuItem
197+
type="published"
198+
onSelect={onSelectRelease}
199+
isSelected={selected === 'published'}
200+
documentId={documentId}
201+
/>
202+
)}
203+
{draft && (
204+
<VersionMenuItem
205+
type="draft"
206+
onSelect={onSelectRelease}
207+
isSelected={selected === 'draft'}
208+
documentId={documentId}
209+
/>
210+
)}
211+
{releases.map((release) => (
212+
<VersionMenuItem
213+
key={release._id}
214+
release={release}
215+
onSelect={onSelectRelease}
216+
isSelected={typeof selected !== 'string' && selected?._id === release._id}
217+
documentId={documentId}
218+
/>
219+
))}
220+
</Menu>
221+
}
222+
/>
223+
)
224+
}
225+
226+
type VersionMenuItemProps = {
227+
onSelect: (releaseId: string) => void
228+
isSelected?: boolean
229+
documentId: string
230+
} & (
231+
| {
232+
release: ReleaseDocument
233+
type?: never
234+
}
235+
| {
236+
type: 'published' | 'draft'
237+
release?: never
238+
}
239+
)
240+
241+
const VersionMenuItem: ComponentType<VersionMenuItemProps> = ({
242+
type,
243+
release,
244+
onSelect,
245+
isSelected,
246+
documentId,
247+
}) => {
248+
const {t: tCore} = useTranslation()
249+
const {t: tStructure} = useTranslation(structureLocaleNamespace)
250+
251+
const onClick = useCallback(() => {
252+
if (type === 'draft') {
253+
onSelect(getDraftId(documentId))
254+
return
255+
}
256+
257+
if (type === 'published') {
258+
onSelect(getPublishedId(documentId))
259+
return
260+
}
261+
262+
if (typeof release?._id !== 'undefined') {
263+
onSelect(getVersionId(documentId, getReleaseIdFromReleaseDocumentId(release._id)))
264+
}
265+
}, [type, onSelect, documentId, release?._id])
266+
267+
if (type) {
268+
const tone: ButtonTone = type === 'published' ? 'positive' : 'caution'
269+
270+
return (
271+
<MenuItem padding={1} paddingRight={3} onClick={onClick} pressed={isSelected}>
272+
<Flex gap={1}>
273+
<ReleaseAvatar padding={2} tone={tone} />
274+
<Box paddingY={2}>
275+
<Text size={1} weight="medium">
276+
{tStructure(['compare-versions.status', type].join('.'))}
277+
</Text>
278+
</Box>
279+
</Flex>
280+
</MenuItem>
281+
)
282+
}
283+
284+
const tone: ButtonTone = release ? getReleaseTone(release) : 'neutral'
285+
286+
return (
287+
<MenuItem padding={1} paddingRight={3} onClick={onClick} pressed={isSelected}>
288+
<Flex gap={1}>
289+
<ReleaseAvatar padding={2} tone={tone} />
290+
<Stack flex={1} paddingY={2} paddingRight={2} space={2}>
291+
<Text size={1} weight="medium">
292+
{release.metadata.title}
293+
</Text>
294+
{['asap', 'undecided'].includes(release.metadata.releaseType) && (
295+
<Text muted size={1}>
296+
{tCore(`release.type.${release.metadata.releaseType}`)}
297+
</Text>
298+
)}
299+
{release.metadata.releaseType === 'scheduled' && (
300+
<Text muted size={1}>
301+
{formatRelativeLocalePublishDate(release)}
302+
</Text>
303+
)}
304+
</Stack>
305+
<Flex flex="none">
306+
{isReleaseScheduledOrScheduling(release) && (
307+
<Box padding={2}>
308+
<Text size={1}>
309+
<LockIcon />
310+
</Text>
311+
</Box>
312+
)}
313+
</Flex>
314+
</Flex>
315+
</MenuItem>
316+
)
317+
}
318+
319+
function getMenuButtonProps({
320+
selected,
321+
tCore,
322+
tStructure,
323+
}: {
324+
selected?: ReleaseDocument | 'published' | 'draft'
325+
tCore: TFunction
326+
tStructure: TFunction
327+
}): Pick<ComponentProps<typeof Button>, 'text' | 'tone' | 'icon' | 'iconRight' | 'disabled'> {
328+
if (typeof selected === 'undefined') {
329+
return {
330+
text: tCore('common.loading'),
331+
tone: 'neutral',
332+
disabled: true,
333+
}
334+
}
335+
336+
if (isReleaseDocument(selected)) {
337+
const tone: ButtonTone = selected ? getReleaseTone(selected) : 'neutral'
338+
339+
return {
340+
text: selected?.metadata.title,
341+
icon: <ReleaseAvatar padding={1} tone={tone} />,
342+
iconRight: selected && isReleaseScheduledOrScheduling(selected) ? <LockIcon /> : undefined,
343+
tone,
344+
}
345+
}
346+
347+
const tone: ButtonTone = selected === 'published' ? 'positive' : 'caution'
348+
349+
return {
350+
text: tStructure(['compare-versions.status', selected].join('.')),
351+
icon: <ReleaseAvatar padding={1} tone={tone} />,
352+
tone,
353+
}
354+
}
355+
356+
/**
357+
* If the provided document id represents a version, find and return the corresponding release
358+
* document. Otherwise, return a string literal signifying whether the document id represents a
359+
* published or draft document.
360+
*/
361+
function findRelease(
362+
documentId: string,
363+
releases: ReleaseDocument[],
364+
): ReleaseDocument | 'published' | 'draft' | undefined {
365+
if (isPublishedId(documentId)) {
366+
return 'published'
367+
}
368+
369+
if (isDraftId(documentId)) {
370+
return 'draft'
371+
}
372+
373+
return releases.find(
374+
({_id}) => getReleaseIdFromReleaseDocumentId(_id) === getVersionFromId(documentId),
375+
)
376+
}

‎packages/sanity/src/structure/i18n/resources.ts

+21
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ const structureLocaleStrings = defineLocalesResources('structure', {
161161
'buttons.split-pane-close-button.title': 'Close split pane',
162162
/** The title for the close group button on the split pane on the document panel header */
163163
'buttons.split-pane-close-group-button.title': 'Close pane group',
164+
164165
/** The label used in the changes inspector for the from selector */
165166
'changes.from.label': 'From',
166167
/* The label for the history tab in the changes inspector*/
@@ -169,6 +170,26 @@ const structureLocaleStrings = defineLocalesResources('structure', {
169170
'changes.tab.review-changes': 'Review changes',
170171
/** The label used in the changes inspector for the to selector */
171172
'changes.to.label': 'To',
173+
174+
/** The error message shown when the specified document comparison mode is not supported */
175+
'compare-version.error.invalidModeParam':
176+
'"{{input}}" is not a supported document comparison mode.',
177+
/** The error message shown when the next document for comparison could not be extracted from the URL */
178+
'compare-version.error.invalidNextDocumentParam': 'The next document parameter is invalid.',
179+
/** The error message shown when the document comparison URL could not be parsed */
180+
'compare-version.error.invalidParams.title': 'Unable to compare documents',
181+
/** The error message shown when the previous document for comparison could not be extracted from the URL */
182+
'compare-version.error.invalidPreviousDocumentParam':
183+
'The previous document parameter is invalid.',
184+
/** The text for the "Compare versions" action for a document */
185+
'compare-versions.menu-item.title': 'Compare versions',
186+
/** The string used to label draft documents */
187+
'compare-versions.status.draft': 'Draft',
188+
/** The string used to label published documents */
189+
'compare-versions.status.published': 'Published',
190+
/** The title used when comparing versions of a document */
191+
'compare-versions.title': 'Compare versions',
192+
172193
/** The text in the "Cancel" button in the confirm delete dialog that cancels the action and closes the dialog */
173194
'confirm-delete-dialog.cancel-button.text': 'Cancel',
174195
/** Used in `confirm-delete-dialog.cdr-summary.title` */

‎packages/sanity/src/structure/panes/document/DocumentPane.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from 'sanity'
1717

1818
import {usePaneRouter} from '../../components'
19+
import {DiffViewDocumentLayout} from '../../diffView/plugin/DiffViewDocumentLayout'
1920
import {structureLocaleNamespace} from '../../i18n'
2021
import {type DocumentPaneNode} from '../../types'
2122
import {ErrorPane} from '../error'
@@ -145,9 +146,11 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) {
145146
initialValueTemplateItems={templatePermissions}
146147
activePath={activePath}
147148
>
148-
<CommentsWrapper documentId={options.id} documentType={options.type}>
149-
<DocumentLayout documentId={options.id} documentType={options.type} />
150-
</CommentsWrapper>
149+
<DiffViewDocumentLayout documentId={options.id} documentType={options.type}>
150+
<CommentsWrapper documentId={options.id} documentType={options.type}>
151+
<DocumentLayout documentId={options.id} documentType={options.type} />
152+
</CommentsWrapper>
153+
</DiffViewDocumentLayout>
151154
</ReferenceInputOptionsProvider>
152155
</DocumentPaneProviderWrapper>
153156
)

‎packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx

+34-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
import {DocumentPaneContext} from 'sanity/_singletons'
2525

2626
import {usePaneRouter} from '../../components'
27+
import {useDiffViewRouter} from '../../diffView/hooks/useDiffViewRouter'
28+
import {useDocumentIdStack} from '../../hooks/useDocumentIdStack'
2729
import {structureLocaleNamespace} from '../../i18n'
2830
import {type PaneMenuItem} from '../../types'
2931
import {DocumentURLCopied} from './__telemetry__'
@@ -98,6 +100,8 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
98100
}
99101
}, [forcedVersion, perspective.selectedPerspectiveName, perspective.selectedReleaseId])
100102

103+
const diffViewRouter = useDiffViewRouter()
104+
101105
const initialValue = useDocumentPaneInitialValue({
102106
paneOptions,
103107
documentId,
@@ -290,6 +294,8 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
290294
[getDisplayed, value],
291295
)
292296

297+
const {previousId} = useDocumentIdStack({displayed, documentId, editState})
298+
293299
const setTimelineRange = useCallback(
294300
(newSince: string, newRev: string | null) => {
295301
setPaneParams({
@@ -338,9 +344,36 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
338344
) {
339345
handleInspectorAction(item)
340346
}
347+
348+
if (item.action === 'compareVersions' && typeof previousId !== 'undefined') {
349+
diffViewRouter.navigateDiffView({
350+
mode: 'version',
351+
previousDocument: {
352+
type: documentType,
353+
id: previousId,
354+
},
355+
nextDocument: {
356+
type: documentType,
357+
id: value._id,
358+
},
359+
})
360+
return true
361+
}
362+
341363
return false
342364
},
343-
[previewUrl, telemetry, pushToast, t, handleHistoryOpen, handleInspectorAction],
365+
[
366+
previewUrl,
367+
previousId,
368+
telemetry,
369+
pushToast,
370+
t,
371+
handleHistoryOpen,
372+
handleInspectorAction,
373+
diffViewRouter,
374+
documentType,
375+
value._id,
376+
],
344377
)
345378

346379
useEffect(() => {

‎packages/sanity/src/structure/panes/document/document-layout/DocumentLayout.tsx

+16-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {styled} from 'styled-components'
2727
import {TooltipDelayGroupProvider} from '../../../../ui-components'
2828
import {Pane, PaneFooter, usePane, usePaneLayout, usePaneRouter} from '../../../components'
2929
import {DOCUMENT_PANEL_PORTAL_ELEMENT} from '../../../constants'
30+
import {useDocumentIdStack} from '../../../hooks/useDocumentIdStack'
3031
import {structureLocaleNamespace} from '../../../i18n'
3132
import {useStructureTool} from '../../../useStructureTool'
3233
import {
@@ -66,8 +67,10 @@ const StyledChangeConnectorRoot = styled(ChangeConnectorRoot)`
6667
export function DocumentLayout() {
6768
const {
6869
changesOpen,
70+
displayed,
6971
documentId,
7072
documentType,
73+
editState,
7174
fieldActions,
7275
focusPath,
7376
inspectOpen,
@@ -128,6 +131,8 @@ export function DocumentLayout() {
128131
[inspectors, inspector?.name],
129132
)
130133

134+
const documentIdStack = useDocumentIdStack({displayed, documentId, editState})
135+
131136
const hasValue = Boolean(value)
132137

133138
const menuItems = useMemo(
@@ -139,9 +144,19 @@ export function DocumentLayout() {
139144
inspectorMenuItems,
140145
inspectors,
141146
previewUrl,
147+
documentIdStack,
142148
t,
143149
}),
144-
[currentInspector, features, hasValue, inspectorMenuItems, inspectors, previewUrl, t],
150+
[
151+
currentInspector,
152+
documentIdStack,
153+
features,
154+
hasValue,
155+
inspectorMenuItems,
156+
inspectors,
157+
previewUrl,
158+
t,
159+
],
145160
)
146161

147162
const handleKeyUp = useCallback(

‎packages/sanity/src/structure/panes/document/menuItems.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {EarthAmericasIcon, JsonIcon, LinkIcon} from '@sanity/icons'
1+
import {EarthAmericasIcon, JsonIcon, LinkIcon, TransferIcon} from '@sanity/icons'
22
import {type DocumentInspector, type DocumentInspectorMenuItem, type TFunction} from 'sanity'
33

4+
import {type DocumentIdStack} from '../../hooks/useDocumentIdStack'
45
import {type PaneMenuItem, type StructureToolFeatures} from '../../types'
56
import {INSPECT_ACTION_PREFIX} from './constants'
67

@@ -10,6 +11,7 @@ interface GetMenuItemsParams {
1011
hasValue: boolean
1112
inspectors: DocumentInspector[]
1213
previewUrl?: string | null
14+
documentIdStack?: DocumentIdStack
1315
inspectorMenuItems: DocumentInspectorMenuItem[]
1416
t: TFunction
1517
}
@@ -52,6 +54,25 @@ function getInspectItem({hasValue, t}: GetMenuItemsParams): PaneMenuItem {
5254
}
5355
}
5456

57+
function getCompareVersionsItem({documentIdStack, t}: GetMenuItemsParams): PaneMenuItem | null {
58+
const isDisabled = typeof documentIdStack?.previousId === 'undefined'
59+
60+
// TODO: It would be preferable to display the option in an inert state, but the `isDisabled`
61+
// property does not appear to have any impact. Instead, we simply exclude the option when
62+
// there is no version to compare.
63+
if (isDisabled) {
64+
return null
65+
}
66+
67+
return {
68+
action: 'compareVersions',
69+
group: 'inspectors',
70+
title: t('compare-versions.menu-item.title'),
71+
icon: TransferIcon,
72+
isDisabled,
73+
}
74+
}
75+
5576
export function getProductionPreviewItem({previewUrl, t}: GetMenuItemsParams): PaneMenuItem | null {
5677
if (!previewUrl) return null
5778

@@ -69,6 +90,7 @@ export function getMenuItems(params: GetMenuItemsParams): PaneMenuItem[] {
6990
const items = [
7091
// Get production preview item
7192
getProductionPreviewItem(params),
93+
getCompareVersionsItem(params),
7294
].filter(Boolean) as PaneMenuItem[]
7395

7496
return [

‎pnpm-lock.yaml

+37-29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.