Skip to content

Commit

Permalink
[base] Remove reference to nonexistent document upon restore to previ…
Browse files Browse the repository at this point in the history
…ous revision (#1423)

When restoring a document to an earlier revision, it may have references to documents that no longer exists. This adds a check that removes any references to documents that no longer exists right before the restore is performed.
  • Loading branch information
bjoerge authored Aug 2, 2019
1 parent 9a1879c commit 921e954
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {from, merge} from 'rxjs'
import {transactionsToEvents} from '@sanity/transaction-collator'
import {map, mergeMap, reduce, scan} from 'rxjs/operators'
import {getDraftId, getPublishedId} from '../../util/draftUtils'
import {omit} from 'lodash'
import jsonReduce from 'json-reduce'

const documentRevisionCache = Object.create(null)

Expand Down Expand Up @@ -101,11 +103,76 @@ function historyEventsFor(documentId) {
)
}

const getAllRefIds = doc =>
jsonReduce(
doc,
(acc, node) =>
node && typeof node === 'object' && '_ref' in node && !acc.includes(node._ref)
? [...acc, node._ref]
: acc,
[]
)

function jsonMap(value, mapFn) {
if (Array.isArray(value)) {
return mapFn(value.map(item => map(item, mapFn)))
}
if (value && typeof value === 'object') {
return mapFn(
Object.keys(value).reduce((res, key) => {
res[key] = jsonMap(value[key], mapFn)
return res
}, {})
)
}
return mapFn(value)
}

const mapRefNodes = (doc, mapFn) =>
jsonMap(doc, node => {
return typeof node && typeof node === 'object' && typeof node._ref === 'string'
? mapFn(node)
: node
})

function restore(id, rev) {
return from(getDocumentAtRevision(id, rev)).pipe(
mergeMap(documentAtRevision => {
const existingIdsQuery = getAllRefIds(documentAtRevision)
.map(refId => `"${refId}": defined(*[_id=="${refId}"]._id)`)
.join(',')

return client.observable.fetch(`{${existingIdsQuery}}`).pipe(
map(existingIds =>
mapRefNodes(documentAtRevision, refNode => {
const documentExists = existingIds[refNode._ref]
return documentExists ? refNode : undefined
})
)
)
}),
map(documentAtRevision =>
// Remove _updatedAt and create a new draft from the document at given revision
({
...omit(documentAtRevision, '_updatedAt'),
_id: getDraftId(id)
})
),
mergeMap(restoredDraft =>
client.observable
.transaction()
.createOrReplace(restoredDraft)
.commit()
)
)
}

export default function createHistoryStore() {
return {
getDocumentAtRevision,
getHistory,
getTransactions,
historyEventsFor
historyEventsFor,
restore
}
}
45 changes: 20 additions & 25 deletions packages/@sanity/desk-tool/src/pane/Editor/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ const isValidationError = marker => marker.type === 'validation' && marker.level
const INITIAL_HISTORY_STATE = {
isOpen: false,
isLoading: true,
error: false,
error: null,
events: [],
selectedRev: null
}
Expand Down Expand Up @@ -313,6 +313,10 @@ export default withRouterHOC(
UNSAFE_componentWillReceiveProps(nextProps) {
this.setState({didPublish: this.props.isPublishing && !nextProps.isPublishing})

if (this.props.isRestoring && !nextProps.isRestoring) {
this.setHistoryState(INITIAL_HISTORY_STATE)
}

if (this.props.isSaving && !nextProps.isSaving) {
this.setState({
showSavingStatus: true
Expand Down Expand Up @@ -419,22 +423,6 @@ export default withRouterHOC(
this.setState({showConfirmUnpublish: false})
}

handleConfirmHistoryRestore = () => {
const {onRestore} = this.props
const selectedEvent = this.findSelectedEvent()
if (selectedEvent) {
historyStore
.getDocumentAtRevision(selectedEvent.displayDocumentId, selectedEvent.rev)
.then(document => {
onRestore(document)
this.setHistoryState({
selected: null,
isOpen: false
})
})
}
}

handleConfirmDelete = () => {
const {onDelete} = this.props
onDelete()
Expand Down Expand Up @@ -557,7 +545,6 @@ export default withRouterHOC(
isCreatingDraft,
isPublishing,
isReconnecting,
isRestoring,
isUnpublishing,
markers,
published
Expand All @@ -566,12 +553,11 @@ export default withRouterHOC(
const errors = validation.filter(marker => marker.level === 'error')
return (
<>
{(isCreatingDraft || isPublishing || isUnpublishing || isRestoring) && (
{(isCreatingDraft || isPublishing || isUnpublishing) && (
<div className={styles.spinnerContainer}>
{isCreatingDraft && <Spinner center message="Making changes…" />}
{isPublishing && <Spinner center message="Publishing…" />}
{isUnpublishing && <Spinner center message="Unpublishing…" />}
{isRestoring && <Spinner center message="Restoring changes…" />}
</div>
)}
<Tooltip
Expand Down Expand Up @@ -608,16 +594,25 @@ export default withRouterHOC(
}

renderHistoryInfo = () => {
const {isReconnecting} = this.props
const {isReconnecting, isRestoring, onRestore} = this.props
const {historyState} = this.state
const selectedEvent = this.findSelectedEvent()

const isLatestEvent = historyState.events[0] === selectedEvent
return (
<RestoreHistoryButton
disabled={isReconnecting || !historyState.isOpen || isLatestEvent}
onRestore={this.handleConfirmHistoryRestore}
/>
<>
{isRestoring && (
<div className={styles.spinnerContainer}>
<Spinner center message="Restoring revision…" />
</div>
)}
<RestoreHistoryButton
disabled={isRestoring || isReconnecting || isLatestEvent}
onRestore={() =>
onRestore({id: selectedEvent.displayDocumentId, rev: selectedEvent.rev})
}
/>
</>
)
}

Expand Down
58 changes: 25 additions & 33 deletions packages/@sanity/desk-tool/src/pane/EditorPane.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import PropTypes from 'prop-types'
import React from 'react'
import promiseLatest from 'promise-latest'
import {merge, timer, of as observableOf} from 'rxjs'
import {catchError, switchMap, map, mapTo, tap} from 'rxjs/operators'
import {merge, concat, timer, of as observableOf} from 'rxjs'
import {catchError, take, mergeMap, switchMap, map, mapTo, tap} from 'rxjs/operators'
import {validateDocument} from '@sanity/validation'
import {omit, throttle, debounce} from 'lodash'
import {FormBuilder, checkoutPair} from 'part:@sanity/form-builder'
Expand All @@ -14,6 +14,7 @@ import withDocumentType from '../utils/withDocumentType'
import styles from './styles/EditorWrapper.css'
import Editor from './Editor'
import UseState from '../utils/UseState'
import historyStore from 'part:@sanity/base/datastore/history'

const INITIAL_DOCUMENT_STATE = {
isLoading: true,
Expand Down Expand Up @@ -362,38 +363,29 @@ export default withDocumentType(
})
}

handleRestoreRevision = restoredDocument => {
const documentId = this.props.options.id
this.setState({isRestoring: true})
const tx = client.observable.transaction()
tx.createOrReplace({
...omit(restoredDocument, '_updatedAt'),
_id: getDraftId(documentId)
handleRestoreRevision = ({id, rev}) => {
const transactionResult$ = historyStore.restore(id, rev).pipe(
map(result => ({
type: 'success',
result: result
})),
catchError(error =>
observableOf({
type: 'error',
message: 'An error occurred while attempting to restore the document',
error
})
),
map(transactionResult => ({transactionResult}))
)

concat(
observableOf({isRestoring: true}),
transactionResult$,
observableOf({isRestoring: false})
).subscribe(nextState => {
this.setStateIfMounted(nextState)
})
tx.commit()
.pipe(
map(result => ({
type: 'success',
result: result
})),
catchError(error =>
observableOf({
type: 'error',
message: 'An error occurred while attempting to restore the document',
error
})
)
)
.subscribe({
next: result => {
this.setStateIfMounted({
transactionResult: result
})
},
complete: () => {
this.setStateIfMounted({isRestoring: false})
}
})
}

handleChange = event => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export default class ReferenceInput extends React.Component<Props, State> {
placeholder={readOnly ? '' : placeholder}
title={
isMissing && hasRef
? `Document id: ${value._ref || 'unknown'}`
? `Referencing nonexistent document (id: ${value._ref || 'unknown'})`
: previewSnapshot && previewSnapshot.description
}
customValidity={errors.length > 0 ? errors[0].item.message : ''}
Expand All @@ -241,7 +241,7 @@ export default class ReferenceInput extends React.Component<Props, State> {
onClear={this.handleClear}
openItemElement={this.renderOpenItemElement}
value={valueFromHit || value}
inputValue={isMissing ? '<Unpublished or missing document>' : inputValue}
inputValue={isMissing ? '<nonexistent reference>' : inputValue}
renderItem={this.renderHit}
isLoading={isFetching || isLoadingSnapshot}
items={hits}
Expand Down

0 comments on commit 921e954

Please sign in to comment.