feat(core): recover entry after unexpected quit (#2129)

This commit is contained in:
Shawn Erquhart 2019-02-28 13:30:23 -05:00 committed by GitHub
parent 7577443849
commit 686504adee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 267 additions and 14 deletions

View File

@ -21,6 +21,7 @@
"dependencies": {
"ajv": "^6.4.0",
"ajv-errors": "^1.0.0",
"copy-text-to-clipboard": "^1.0.4",
"diacritics": "^1.3.0",
"emotion": "^9.2.6",
"fuzzy": "^0.1.1",

View File

@ -30,6 +30,8 @@ export const DRAFT_CHANGE = 'DRAFT_CHANGE';
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS';
export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED';
export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
@ -220,6 +222,46 @@ export function clearFieldErrors() {
return { type: DRAFT_CLEAR_ERRORS };
}
export function localBackupRetrieved(entry) {
return {
type: DRAFT_LOCAL_BACKUP_RETRIEVED,
payload: { entry },
};
}
export function loadLocalBackup() {
return {
type: DRAFT_CREATE_FROM_LOCAL_BACKUP,
};
}
export function persistLocalBackup(entry, collection) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
return backend.persistLocalDraftBackup(entry, collection);
};
}
export function retrieveLocalBackup(collection, slug) {
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const entry = await backend.getLocalDraftBackup(collection, slug);
if (entry) {
return dispatch(localBackupRetrieved(entry));
}
};
}
export function deleteLocalBackup(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
return backend.deleteLocalDraftBackup(collection, slug);
};
}
/*
* Exported Thunk Action Creators
*/

View File

@ -3,6 +3,7 @@ import { Map } from 'immutable';
import { stripIndent } from 'common-tags';
import moment from 'moment';
import fuzzy from 'fuzzy';
import { localForage } from 'netlify-cms-lib-util';
import { resolveFormat } from 'Formats/formats';
import { selectIntegration } from 'Reducers/integrations';
import {
@ -75,6 +76,20 @@ function getExplicitFieldReplacement(key, data) {
return data.get(fieldName, '');
}
function getEntryBackupKey(collectionName, slug) {
const baseKey = 'backup';
if (!collectionName) {
return baseKey;
}
const suffix = slug ? `.${slug}` : '';
return `backup.${collectionName}${suffix}`;
}
function getLabelForFileCollectionEntry(collection, path) {
const files = collection.get('files');
return files && files.find(f => f.get('file') === path).get('label');
}
function compileSlug(template, date, identifier = '', data = Map(), processor) {
let missingRequiredDate;
@ -240,6 +255,8 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
class Backend {
constructor(implementation, { backendName, authStore = null, config } = {}) {
// We can't reliably run this on exit, so we do cleanup on load.
this.deleteAnonymousBackup();
this.config = config;
this.implementation = implementation.init(config, {
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
@ -417,10 +434,44 @@ class Backend {
}));
}
async getLocalDraftBackup(collection, slug) {
const key = getEntryBackupKey(collection.get('name'), slug);
const backup = await localForage.getItem(key);
if (!backup || !backup.raw.trim()) {
return;
}
const { raw, path } = backup;
const label = getLabelForFileCollectionEntry(collection, path);
return this.entryWithFormat(collection, slug)(
createEntry(collection.get('name'), slug, path, { raw, label }),
);
}
async persistLocalDraftBackup(entry, collection) {
const key = getEntryBackupKey(collection.get('name'), entry.get('slug'));
const raw = this.entryToRaw(collection, entry);
if (!raw.trim()) {
return;
}
await localForage.setItem(key, { raw, path: entry.get('path') });
return localForage.setItem(getEntryBackupKey(), raw);
}
async deleteLocalDraftBackup(collection, slug) {
const key = getEntryBackupKey(collection.get('name'), slug);
await localForage.removeItem(key);
return this.deleteAnonymousBackup();
}
// Unnamed backup for use in the global error boundary, should always be
// deleted on cms load.
deleteAnonymousBackup() {
return localForage.removeItem(getEntryBackupKey());
}
getEntry(collection, slug) {
const path = selectEntryPath(collection, slug);
const files = collection.get('files');
const label = files && files.find(f => f.get('file') === path).get('label');
const label = getLabelForFileCollectionEntry(collection, path);
return this.implementation.getEntry(collection, slug, path).then(loadedEntry =>
this.entryWithFormat(collection, slug)(
createEntry(collection.get('name'), slug, loadedEntry.file.path, {

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Loader } from 'netlify-cms-ui-default';
import { translate } from 'react-polyglot';
import { debounce } from 'lodash';
import history from 'Routing/history';
import { logoutUser } from 'Actions/auth';
import {
@ -16,6 +17,10 @@ import {
changeDraftFieldValidation,
persistEntry,
deleteEntry,
persistLocalBackup,
loadLocalBackup,
retrieveLocalBackup,
deleteLocalBackup,
} from 'Actions/entries';
import {
updateUnpublishedEntryStatus,
@ -84,10 +89,13 @@ class Editor extends React.Component {
loadEntry,
createEmptyDraft,
loadEntries,
retrieveLocalBackup,
collectionEntriesLoaded,
t,
} = this.props;
retrieveLocalBackup(collection, slug);
if (newEntry) {
createEmptyDraft(collection);
} else {
@ -126,6 +134,7 @@ class Editor extends React.Component {
return leaveMessage;
}
};
const unblock = history.block(navigationBlocker);
/**
@ -142,6 +151,9 @@ class Editor extends React.Component {
) {
return;
}
this.deleteBackup();
unblock();
this.unlisten();
});
@ -163,7 +175,21 @@ class Editor extends React.Component {
this.props.loadEntry(this.props.collection, newSlug);
}
if (!prevProps.localBackup && this.props.localBackup) {
const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup'));
if (confirmLoadBackup) {
this.props.loadLocalBackup();
} else {
this.deleteBackup();
}
}
if (this.props.hasChanged) {
this.createBackup(this.props.entryDraft.get('entry'), this.props.collection);
}
if (prevProps.entry === this.props.entry) return;
const { entry, newEntry, fields, collection } = this.props;
if (entry && !entry.get('isFetching') && !entry.get('error')) {
@ -181,10 +207,15 @@ class Editor extends React.Component {
}
componentWillUnmount() {
this.createBackup.flush();
this.props.discardDraft();
window.removeEventListener('beforeunload', this.exitBlocker);
}
createBackup = debounce(function(entry, collection) {
this.props.persistLocalBackup(entry, collection);
}, 2000);
createDraft = (entry, metadata) => {
if (entry) this.props.createDraftFromEntry(entry, metadata);
};
@ -206,6 +237,12 @@ class Editor extends React.Component {
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
};
deleteBackup() {
const { deleteLocalBackup, collection, slug } = this.props;
this.createBackup.cancel();
deleteLocalBackup(collection, slug);
}
handlePersistEntry = async (opts = {}) => {
const { createNew = false } = opts;
const {
@ -220,6 +257,8 @@ class Editor extends React.Component {
await persistEntry(collection);
this.deleteBackup(collection, slug);
if (createNew) {
navigateToNewEntry(collection.get('name'));
createEmptyDraft(collection);
@ -243,6 +282,8 @@ class Editor extends React.Component {
await publishUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
@ -263,6 +304,7 @@ class Editor extends React.Component {
setTimeout(async () => {
await deleteEntry(collection, slug);
this.deleteBackup();
return navigateToCollection(collection.get('name'));
}, 0);
};
@ -287,6 +329,8 @@ class Editor extends React.Component {
}
await deleteUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (isModification) {
loadEntry(collection, slug);
} else {
@ -384,6 +428,7 @@ function mapStateToProps(state, ownProps) {
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
return {
collection,
collections,
@ -401,6 +446,7 @@ function mapStateToProps(state, ownProps) {
collectionEntriesLoaded,
currentStatus,
deployPreview,
localBackup,
};
}
@ -412,6 +458,10 @@ export default connect(
loadEntry,
loadEntries,
loadDeployPreview,
loadLocalBackup,
retrieveLocalBackup,
persistLocalBackup,
deleteLocalBackup,
createDraftFromEntry,
createEmptyDraft,
discardDraft,

View File

@ -1,20 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-polyglot';
import { css } from 'react-emotion';
import { colors } from 'netlify-cms-ui-default';
import styled, { css } from 'react-emotion';
import copyToClipboard from 'copy-text-to-clipboard';
import { localForage } from 'netlify-cms-lib-util';
import { buttons, colors } from 'netlify-cms-ui-default';
const ISSUE_URL = 'https://github.com/netlify/netlify-cms/issues/new?template=bug_report.md';
const styles = {
errorBoundary: css`
padding: 0 20px;
padding: 40px;
h1 {
font-size: 28px;
}
h2 {
font-size: 20px;
}
strong {
color: ${colors.textLead};
font-weight: 500;
}
hr {
width: 200px;
margin: 30px 0;
border: 0;
height: 1px;
background-color: ${colors.text};
}
`,
errorText: css`
color: ${colors.errorText};
`,
};
const CopyButton = styled.button`
${buttons.button};
${buttons.default};
${buttons.gray};
display: block;
margin: 12px 0;
`;
class ErrorBoundary extends React.Component {
static propTypes = {
children: PropTypes.node,
@ -24,15 +55,28 @@ class ErrorBoundary extends React.Component {
state = {
hasError: false,
errorMessage: '',
backup: '',
};
componentDidCatch(error) {
static getDerivedStateFromError(error) {
console.error(error);
this.setState({ hasError: true, errorMessage: error.toString() });
return { hasError: true, errorMessage: error.toString() };
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup
);
}
async componentDidUpdate() {
const backup = await localForage.getItem('backup');
console.log(backup);
this.setState({ backup });
}
render() {
const { hasError, errorMessage } = this.state;
const { hasError, errorMessage, backup } = this.state;
if (!hasError) {
return this.props.children;
}
@ -51,7 +95,20 @@ class ErrorBoundary extends React.Component {
{t('ui.errorBoundary.reportIt')}
</a>
</p>
<hr />
<h2>Details</h2>
<p>{errorMessage}</p>
{backup && (
<>
<hr />
<h2>Recovered document</h2>
<strong>Please copy/paste this somewhere before navigating away!</strong>
<CopyButton onClick={() => copyToClipboard(backup)}>Copy to clipboard</CopyButton>
<pre>
<code>{backup}</code>
</pre>
</>
)}
</div>
);
}

View File

@ -59,6 +59,7 @@ export function getPhrases() {
onDeleteUnpublishedChanges:
'All unpublished changes to this entry will be deleted. Do you still want to delete?',
loadingEntry: 'Loading entry...',
confirmLoadBackup: 'A local backup was recovered for this entry, would you like to use it?',
},
editorToolbar: {
publishing: 'Publishing...',
@ -116,9 +117,9 @@ export function getPhrases() {
},
ui: {
errorBoundary: {
title: 'Sorry!',
title: 'Error',
details: "There's been an error - please ",
reportIt: 'report it!',
reportIt: 'report it.',
},
settingsDropdown: {
logOut: 'Log Out',

View File

@ -49,7 +49,7 @@ function inferFrontmatterFormat(str) {
case '{':
return getFormatOpts('json');
default:
throw 'Unrecognized front-matter format.';
console.warn('Unrecognized front-matter format.');
}
}

View File

@ -6,6 +6,8 @@ import {
DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS,
DRAFT_CLEAR_ERRORS,
DRAFT_LOCAL_BACKUP_RETRIEVED,
DRAFT_CREATE_FROM_LOCAL_BACKUP,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
@ -51,8 +53,22 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
});
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
// Local Backup
return state.withMutations(state => {
const backupEntry = state.get('localBackup');
state.delete('localBackup');
state.set('entry', backupEntry);
state.setIn(['entry', 'newRecord'], !backupEntry.get('path'));
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
});
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED:
return state.set('localBackup', fromJS(action.payload.entry));
case DRAFT_CHANGE_FIELD:
return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled, { cx } from 'react-emotion';
import { get, isEmpty, debounce } from 'lodash';
import { get, isEmpty, debounce, uniq } from 'lodash';
import { List } from 'immutable';
import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react';
@ -48,11 +48,41 @@ export default class Editor extends React.Component {
super(props);
this.state = {
value: createSlateValue(props.value),
lastRawValue: props.value,
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.value.equals(nextState.value);
const forcePropsValue = this.shouldForcePropsValue(
this.props.value,
this.state.lastRawValue,
nextProps.value,
nextState.lastRawValue,
);
return !this.state.value.equals(nextState.value) || forcePropsValue;
}
componentDidUpdate(prevProps, prevState) {
const forcePropsValue = this.shouldForcePropsValue(
prevProps.value,
prevState.lastRawValue,
this.props.value,
this.state.lastRawValue,
);
if (forcePropsValue) {
this.setState({ value: createSlateValue(this.props.value) });
}
}
// If the old props/state values and new state value are all the same, and
// the new props value does not match the others, the new props value
// originated from outside of this widget and should be used.
shouldForcePropsValue(oldPropsValue, oldStateValue, newPropsValue, newStateValue) {
return (
uniq([oldPropsValue, oldStateValue, newStateValue]).length === 1 &&
oldPropsValue !== newPropsValue
);
}
handlePaste = (e, data, change) => {
@ -194,7 +224,7 @@ export default class Editor extends React.Component {
const { onChange } = this.props;
const raw = change.value.document.toJSON();
const markdown = slateToMarkdown(raw);
onChange(markdown);
this.setState({ lastRawValue: markdown }, () => onChange(markdown));
}, 150);
handleChange = change => {

View File

@ -3477,6 +3477,11 @@ copy-descriptor@^0.1.0:
resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
copy-text-to-clipboard@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-1.0.4.tgz#2286ff6c53495962c5318d34746d256939069c49"
integrity sha512-4hDE+0bgqm4G/nXnt91CP3rc0vOptaePPU5WfVZuhv2AYNJogdLHR4pF1XPgXDAGY4QCzj9pD7zKATa+50sQPg==
copy-webpack-plugin@^4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz#d53444a8fea2912d806e78937390ddd7e632ee5c"