feat(core): recover entry after unexpected quit (#2129)
This commit is contained in:
parent
7577443849
commit
686504adee
@ -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",
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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, {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -49,7 +49,7 @@ function inferFrontmatterFormat(str) {
|
||||
case '{':
|
||||
return getFormatOpts('json');
|
||||
default:
|
||||
throw 'Unrecognized front-matter format.';
|
||||
console.warn('Unrecognized front-matter format.');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 => {
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user