From 686504adee2c29319fc0119c572a54e21c114be6 Mon Sep 17 00:00:00 2001
From: Shawn Erquhart
Date: Thu, 28 Feb 2019 13:30:23 -0500
Subject: [PATCH] feat(core): recover entry after unexpected quit (#2129)
---
packages/netlify-cms-core/package.json | 1 +
.../netlify-cms-core/src/actions/entries.js | 42 +++++++++++
packages/netlify-cms-core/src/backend.js | 55 ++++++++++++++-
.../src/components/Editor/Editor.js | 50 ++++++++++++++
.../src/components/UI/ErrorBoundary.js | 69 +++++++++++++++++--
.../src/constants/defaultPhrases.js | 5 +-
.../src/formats/frontmatter.js | 2 +-
.../src/reducers/entryDraft.js | 16 +++++
.../src/MarkdownControl/VisualEditor.js | 36 +++++++++-
yarn.lock | 5 ++
10 files changed, 267 insertions(+), 14 deletions(-)
diff --git a/packages/netlify-cms-core/package.json b/packages/netlify-cms-core/package.json
index f55249e2..ce6a5793 100644
--- a/packages/netlify-cms-core/package.json
+++ b/packages/netlify-cms-core/package.json
@@ -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",
diff --git a/packages/netlify-cms-core/src/actions/entries.js b/packages/netlify-cms-core/src/actions/entries.js
index 86d12ea1..46edf959 100644
--- a/packages/netlify-cms-core/src/actions/entries.js
+++ b/packages/netlify-cms-core/src/actions/entries.js
@@ -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
*/
diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js
index 2e16bcbf..1880d918 100644
--- a/packages/netlify-cms-core/src/backend.js
+++ b/packages/netlify-cms-core/src/backend.js
@@ -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, {
diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js
index f6b5f21a..b097014c 100644
--- a/packages/netlify-cms-core/src/components/Editor/Editor.js
+++ b/packages/netlify-cms-core/src/components/Editor/Editor.js
@@ -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,
diff --git a/packages/netlify-cms-core/src/components/UI/ErrorBoundary.js b/packages/netlify-cms-core/src/components/UI/ErrorBoundary.js
index f2c5dbb5..104ec388 100644
--- a/packages/netlify-cms-core/src/components/UI/ErrorBoundary.js
+++ b/packages/netlify-cms-core/src/components/UI/ErrorBoundary.js
@@ -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')}
+
+ Details
{errorMessage}
+ {backup && (
+ <>
+
+ Recovered document
+ Please copy/paste this somewhere before navigating away!
+ copyToClipboard(backup)}>Copy to clipboard
+
+ {backup}
+
+ >
+ )}
);
}
diff --git a/packages/netlify-cms-core/src/constants/defaultPhrases.js b/packages/netlify-cms-core/src/constants/defaultPhrases.js
index ebae7694..71bef85e 100644
--- a/packages/netlify-cms-core/src/constants/defaultPhrases.js
+++ b/packages/netlify-cms-core/src/constants/defaultPhrases.js
@@ -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',
diff --git a/packages/netlify-cms-core/src/formats/frontmatter.js b/packages/netlify-cms-core/src/formats/frontmatter.js
index 3f116505..76d96cab 100644
--- a/packages/netlify-cms-core/src/formats/frontmatter.js
+++ b/packages/netlify-cms-core/src/formats/frontmatter.js
@@ -49,7 +49,7 @@ function inferFrontmatterFormat(str) {
case '{':
return getFormatOpts('json');
default:
- throw 'Unrecognized front-matter format.';
+ console.warn('Unrecognized front-matter format.');
}
}
diff --git a/packages/netlify-cms-core/src/reducers/entryDraft.js b/packages/netlify-cms-core/src/reducers/entryDraft.js
index 7e72b819..093976d7 100644
--- a/packages/netlify-cms-core/src/reducers/entryDraft.js
+++ b/packages/netlify-cms-core/src/reducers/entryDraft.js
@@ -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);
diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js
index fc3f7b58..924866c1 100644
--- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js
+++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/VisualEditor.js
@@ -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 => {
diff --git a/yarn.lock b/yarn.lock
index 0d4cb444..7711862b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"