feat(core): recover entry after unexpected quit (#2129)
This commit is contained in:
@ -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",
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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, {
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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 => {
|
||||||
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user