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
10 changed files with 267 additions and 14 deletions

View File

@ -21,6 +21,7 @@
"dependencies": { "dependencies": {
"ajv": "^6.4.0", "ajv": "^6.4.0",
"ajv-errors": "^1.0.0", "ajv-errors": "^1.0.0",
"copy-text-to-clipboard": "^1.0.4",
"diacritics": "^1.3.0", "diacritics": "^1.3.0",
"emotion": "^9.2.6", "emotion": "^9.2.6",
"fuzzy": "^0.1.1", "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_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_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_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
@ -220,6 +222,46 @@ export function clearFieldErrors() {
return { type: DRAFT_CLEAR_ERRORS }; 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 * Exported Thunk Action Creators
*/ */

View File

@ -3,6 +3,7 @@ import { Map } from 'immutable';
import { stripIndent } from 'common-tags'; import { stripIndent } from 'common-tags';
import moment from 'moment'; import moment from 'moment';
import fuzzy from 'fuzzy'; import fuzzy from 'fuzzy';
import { localForage } from 'netlify-cms-lib-util';
import { resolveFormat } from 'Formats/formats'; import { resolveFormat } from 'Formats/formats';
import { selectIntegration } from 'Reducers/integrations'; import { selectIntegration } from 'Reducers/integrations';
import { import {
@ -75,6 +76,20 @@ function getExplicitFieldReplacement(key, data) {
return data.get(fieldName, ''); 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) { function compileSlug(template, date, identifier = '', data = Map(), processor) {
let missingRequiredDate; let missingRequiredDate;
@ -240,6 +255,8 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
class Backend { class Backend {
constructor(implementation, { backendName, authStore = null, config } = {}) { 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.config = config;
this.implementation = implementation.init(config, { this.implementation = implementation.init(config, {
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW, 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) { getEntry(collection, slug) {
const path = selectEntryPath(collection, slug); const path = selectEntryPath(collection, slug);
const files = collection.get('files'); const label = getLabelForFileCollectionEntry(collection, path);
const label = files && files.find(f => f.get('file') === path).get('label');
return this.implementation.getEntry(collection, slug, path).then(loadedEntry => return this.implementation.getEntry(collection, slug, path).then(loadedEntry =>
this.entryWithFormat(collection, slug)( this.entryWithFormat(collection, slug)(
createEntry(collection.get('name'), slug, loadedEntry.file.path, { 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 { connect } from 'react-redux';
import { Loader } from 'netlify-cms-ui-default'; import { Loader } from 'netlify-cms-ui-default';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { debounce } from 'lodash';
import history from 'Routing/history'; import history from 'Routing/history';
import { logoutUser } from 'Actions/auth'; import { logoutUser } from 'Actions/auth';
import { import {
@ -16,6 +17,10 @@ import {
changeDraftFieldValidation, changeDraftFieldValidation,
persistEntry, persistEntry,
deleteEntry, deleteEntry,
persistLocalBackup,
loadLocalBackup,
retrieveLocalBackup,
deleteLocalBackup,
} from 'Actions/entries'; } from 'Actions/entries';
import { import {
updateUnpublishedEntryStatus, updateUnpublishedEntryStatus,
@ -84,10 +89,13 @@ class Editor extends React.Component {
loadEntry, loadEntry,
createEmptyDraft, createEmptyDraft,
loadEntries, loadEntries,
retrieveLocalBackup,
collectionEntriesLoaded, collectionEntriesLoaded,
t, t,
} = this.props; } = this.props;
retrieveLocalBackup(collection, slug);
if (newEntry) { if (newEntry) {
createEmptyDraft(collection); createEmptyDraft(collection);
} else { } else {
@ -126,6 +134,7 @@ class Editor extends React.Component {
return leaveMessage; return leaveMessage;
} }
}; };
const unblock = history.block(navigationBlocker); const unblock = history.block(navigationBlocker);
/** /**
@ -142,6 +151,9 @@ class Editor extends React.Component {
) { ) {
return; return;
} }
this.deleteBackup();
unblock(); unblock();
this.unlisten(); this.unlisten();
}); });
@ -163,7 +175,21 @@ class Editor extends React.Component {
this.props.loadEntry(this.props.collection, newSlug); 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; if (prevProps.entry === this.props.entry) return;
const { entry, newEntry, fields, collection } = this.props; const { entry, newEntry, fields, collection } = this.props;
if (entry && !entry.get('isFetching') && !entry.get('error')) { if (entry && !entry.get('isFetching') && !entry.get('error')) {
@ -181,10 +207,15 @@ class Editor extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.createBackup.flush();
this.props.discardDraft(); this.props.discardDraft();
window.removeEventListener('beforeunload', this.exitBlocker); window.removeEventListener('beforeunload', this.exitBlocker);
} }
createBackup = debounce(function(entry, collection) {
this.props.persistLocalBackup(entry, collection);
}, 2000);
createDraft = (entry, metadata) => { createDraft = (entry, metadata) => {
if (entry) this.props.createDraftFromEntry(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); updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
}; };
deleteBackup() {
const { deleteLocalBackup, collection, slug } = this.props;
this.createBackup.cancel();
deleteLocalBackup(collection, slug);
}
handlePersistEntry = async (opts = {}) => { handlePersistEntry = async (opts = {}) => {
const { createNew = false } = opts; const { createNew = false } = opts;
const { const {
@ -220,6 +257,8 @@ class Editor extends React.Component {
await persistEntry(collection); await persistEntry(collection);
this.deleteBackup(collection, slug);
if (createNew) { if (createNew) {
navigateToNewEntry(collection.get('name')); navigateToNewEntry(collection.get('name'));
createEmptyDraft(collection); createEmptyDraft(collection);
@ -243,6 +282,8 @@ class Editor extends React.Component {
await publishUnpublishedEntry(collection.get('name'), slug); await publishUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (createNew) { if (createNew) {
navigateToNewEntry(collection.get('name')); navigateToNewEntry(collection.get('name'));
} }
@ -263,6 +304,7 @@ class Editor extends React.Component {
setTimeout(async () => { setTimeout(async () => {
await deleteEntry(collection, slug); await deleteEntry(collection, slug);
this.deleteBackup();
return navigateToCollection(collection.get('name')); return navigateToCollection(collection.get('name'));
}, 0); }, 0);
}; };
@ -287,6 +329,8 @@ class Editor extends React.Component {
} }
await deleteUnpublishedEntry(collection.get('name'), slug); await deleteUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (isModification) { if (isModification) {
loadEntry(collection, slug); loadEntry(collection, slug);
} else { } else {
@ -384,6 +428,7 @@ function mapStateToProps(state, ownProps) {
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug); const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']); const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
const deployPreview = selectDeployPreview(state, collectionName, slug); const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
return { return {
collection, collection,
collections, collections,
@ -401,6 +446,7 @@ function mapStateToProps(state, ownProps) {
collectionEntriesLoaded, collectionEntriesLoaded,
currentStatus, currentStatus,
deployPreview, deployPreview,
localBackup,
}; };
} }
@ -412,6 +458,10 @@ export default connect(
loadEntry, loadEntry,
loadEntries, loadEntries,
loadDeployPreview, loadDeployPreview,
loadLocalBackup,
retrieveLocalBackup,
persistLocalBackup,
deleteLocalBackup,
createDraftFromEntry, createDraftFromEntry,
createEmptyDraft, createEmptyDraft,
discardDraft, discardDraft,

View File

@ -1,20 +1,51 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { css } from 'react-emotion'; import styled, { css } from 'react-emotion';
import { colors } from 'netlify-cms-ui-default'; 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 ISSUE_URL = 'https://github.com/netlify/netlify-cms/issues/new?template=bug_report.md';
const styles = { const styles = {
errorBoundary: css` 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` errorText: css`
color: ${colors.errorText}; color: ${colors.errorText};
`, `,
}; };
const CopyButton = styled.button`
${buttons.button};
${buttons.default};
${buttons.gray};
display: block;
margin: 12px 0;
`;
class ErrorBoundary extends React.Component { class ErrorBoundary extends React.Component {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
@ -24,15 +55,28 @@ class ErrorBoundary extends React.Component {
state = { state = {
hasError: false, hasError: false,
errorMessage: '', errorMessage: '',
backup: '',
}; };
componentDidCatch(error) { static getDerivedStateFromError(error) {
console.error(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() { render() {
const { hasError, errorMessage } = this.state; const { hasError, errorMessage, backup } = this.state;
if (!hasError) { if (!hasError) {
return this.props.children; return this.props.children;
} }
@ -51,7 +95,20 @@ class ErrorBoundary extends React.Component {
{t('ui.errorBoundary.reportIt')} {t('ui.errorBoundary.reportIt')}
</a> </a>
</p> </p>
<hr />
<h2>Details</h2>
<p>{errorMessage}</p> <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> </div>
); );
} }

View File

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

View File

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

View File

@ -6,6 +6,8 @@ import {
DRAFT_CHANGE_FIELD, DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS, DRAFT_VALIDATION_ERRORS,
DRAFT_CLEAR_ERRORS, DRAFT_CLEAR_ERRORS,
DRAFT_LOCAL_BACKUP_RETRIEVED,
DRAFT_CREATE_FROM_LOCAL_BACKUP,
ENTRY_PERSIST_REQUEST, ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS, ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE, ENTRY_PERSIST_FAILURE,
@ -51,8 +53,22 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsErrors', Map()); state.set('fieldsErrors', Map());
state.set('hasChanged', false); 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: case DRAFT_DISCARD:
return initialState; return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED:
return state.set('localBackup', fromJS(action.payload.entry));
case DRAFT_CHANGE_FIELD: case DRAFT_CHANGE_FIELD:
return state.withMutations(state => { return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value); 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 PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import styled, { cx } from 'react-emotion'; import styled, { cx } from 'react-emotion';
import { get, isEmpty, debounce } from 'lodash'; import { get, isEmpty, debounce, uniq } from 'lodash';
import { List } from 'immutable'; import { List } from 'immutable';
import { Value, Document, Block, Text } from 'slate'; import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react'; import { Editor as Slate } from 'slate-react';
@ -48,11 +48,41 @@ export default class Editor extends React.Component {
super(props); super(props);
this.state = { this.state = {
value: createSlateValue(props.value), value: createSlateValue(props.value),
lastRawValue: props.value,
}; };
} }
shouldComponentUpdate(nextProps, nextState) { 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) => { handlePaste = (e, data, change) => {
@ -194,7 +224,7 @@ export default class Editor extends React.Component {
const { onChange } = this.props; const { onChange } = this.props;
const raw = change.value.document.toJSON(); const raw = change.value.document.toJSON();
const markdown = slateToMarkdown(raw); const markdown = slateToMarkdown(raw);
onChange(markdown); this.setState({ lastRawValue: markdown }, () => onChange(markdown));
}, 150); }, 150);
handleChange = change => { 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" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= 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: copy-webpack-plugin@^4.5.2:
version "4.5.2" version "4.5.2"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz#d53444a8fea2912d806e78937390ddd7e632ee5c" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz#d53444a8fea2912d806e78937390ddd7e632ee5c"