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')}
+{errorMessage}
+ {backup && ( + <> +
+ {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"