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"