decap-cms-core
Version:
Decap CMS core application, see decap-cms package for the main distribution.
521 lines (520 loc) • 17.4 kB
JavaScript
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Loader } from 'decap-cms-ui-default';
import { translate } from 'react-polyglot';
import debounce from 'lodash/debounce';
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
import { logoutUser } from '../../actions/auth';
import { loadEntry, loadEntries, createDraftDuplicateFromEntry, createEmptyDraft, discardDraft, changeDraftField, changeDraftFieldValidation, persistEntry, deleteEntry, persistLocalBackup, loadLocalBackup, retrieveLocalBackup, deleteLocalBackup, loadNotesForEntry, loadNotes, stopNotesPolling, persistNote, updateNotePersist, deleteNotePersist } from '../../actions/entries';
import { updateUnpublishedEntryStatus, publishUnpublishedEntry, unpublishPublishedEntry, deleteUnpublishedEntry } from '../../actions/editorialWorkflow';
import { loadDeployPreview } from '../../actions/deploys';
import { selectCanCreateNewEntry, selectEntry, selectUnpublishedEntry, selectDeployPreview } from '../../reducers';
import { selectFields, getFileFromSlug } from '../../reducers/collections';
import { status, EDITORIAL_WORKFLOW } from '../../constants/publishModes';
import { FILES } from '../../constants/collectionTypes';
import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
import { jsx as _jsx } from "@emotion/react/jsx-runtime";
function isNotesEnabled(collection, slug) {
if (collection.get('type') === FILES) {
const file = getFileFromSlug(collection, slug);
const notesEnabled = file?.getIn(['editor', 'notes']);
if (notesEnabled != null) return notesEnabled;
}
return collection.getIn(['editor', 'notes'], false);
}
export class Editor extends React.Component {
static propTypes = {
changeDraftField: PropTypes.func.isRequired,
changeDraftFieldValidation: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
createDraftDuplicateFromEntry: PropTypes.func.isRequired,
createEmptyDraft: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map,
entryDraft: ImmutablePropTypes.map.isRequired,
loadEntry: PropTypes.func.isRequired,
loadNotes: PropTypes.func,
stopNotesPolling: PropTypes.func,
persistEntry: PropTypes.func.isRequired,
deleteEntry: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
fields: ImmutablePropTypes.list.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
useOpenAuthoring: PropTypes.bool,
unpublishedEntry: PropTypes.bool,
isModification: PropTypes.bool,
collectionEntriesLoaded: PropTypes.bool,
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
publishUnpublishedEntry: PropTypes.func.isRequired,
deleteUnpublishedEntry: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
loadEntries: PropTypes.func.isRequired,
deployPreview: PropTypes.object,
loadDeployPreview: PropTypes.func.isRequired,
currentStatus: PropTypes.string,
user: PropTypes.object,
location: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string
}),
hasChanged: PropTypes.bool,
canCreateNewEntry: PropTypes.bool,
t: PropTypes.func.isRequired,
retrieveLocalBackup: PropTypes.func.isRequired,
localBackup: ImmutablePropTypes.map,
loadLocalBackup: PropTypes.func,
persistLocalBackup: PropTypes.func.isRequired,
deleteLocalBackup: PropTypes.func
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Editor.propTypes, this.props, 'prop', 'Editor');
const {
newEntry,
collection,
slug,
loadEntry,
createEmptyDraft,
loadEntries,
retrieveLocalBackup,
collectionEntriesLoaded,
t
} = this.props;
retrieveLocalBackup(collection, slug);
if (newEntry) {
createEmptyDraft(collection, this.props.location.search);
} else {
loadEntry(collection, slug);
}
const leaveMessage = t('editor.editor.onLeavePage');
this.exitBlocker = event => {
if (this.props.entryDraft.get('hasChanged')) {
// This message is ignored in most browsers, but its presence
// triggers the confirmation dialog
event.returnValue = leaveMessage;
return leaveMessage;
}
};
window.addEventListener('beforeunload', this.exitBlocker);
const navigationBlocker = (location, action) => {
/**
* New entry being saved and redirected to it's new slug based url.
*/
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
const newEntryPath = `/collections/${collection.get('name')}/new`;
if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
return;
}
if (this.props.hasChanged) {
return leaveMessage;
}
};
const unblock = history.block(navigationBlocker);
/**
* This will run as soon as the location actually changes, unless creating
* a new post. The confirmation above will run first.
*/
this.unlisten = history.listen((location, action) => {
const newEntryPath = `/collections/${collection.get('name')}/new`;
const entriesPath = `/collections/${collection.get('name')}/entries/`;
const {
pathname
} = location;
if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
return;
}
this.deleteBackup();
unblock();
this.unlisten();
});
if (!collectionEntriesLoaded) {
loadEntries(collection);
}
}
componentDidUpdate(prevProps) {
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 (prevProps.entry !== this.props.entry && this.props.entry && !this.props.entry.get('isFetching') && !this.props.newEntry && this.props.hasWorkflow && isNotesEnabled(this.props.collection, this.props.slug)) {
this.props.loadNotes(this.props.collection, this.props.slug);
}
if (this.props.hasChanged) {
this.createBackup(this.props.entryDraft.get('entry'), this.props.collection);
}
if (prevProps.entry === this.props.entry) return;
const {
newEntry,
collection
} = this.props;
if (newEntry) {
prevProps.createEmptyDraft(collection, this.props.location.search);
}
}
componentWillUnmount() {
if (!this.props.newEntry && this.props.hasWorkflow && isNotesEnabled(this.props.collection, this.props.slug)) {
this.props.stopNotesPolling(this.props.collection, this.props.slug);
}
this.createBackup.flush();
this.props.discardDraft();
window.removeEventListener('beforeunload', this.exitBlocker);
}
createBackup = debounce(function (entry, collection) {
this.props.persistLocalBackup(entry, collection);
}, 2000);
handleChangeDraftField = (field, value, metadata, i18n) => {
const entries = [this.props.unPublishedEntry, this.props.publishedEntry].filter(Boolean);
this.props.changeDraftField({
field,
value,
metadata,
entries,
i18n
});
};
handleChangeStatus = newStatusName => {
const {
entryDraft,
updateUnpublishedEntryStatus,
collection,
slug,
currentStatus,
t
} = this.props;
if (entryDraft.get('hasChanged')) {
window.alert(t('editor.editor.onUpdatingWithUnsavedChanges'));
return;
}
const newStatus = status.get(newStatusName);
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
};
deleteBackup() {
const {
deleteLocalBackup,
collection,
slug,
newEntry
} = this.props;
this.createBackup.cancel();
deleteLocalBackup(collection, !newEntry && slug);
}
handlePersistEntry = async (opts = {}) => {
const {
createNew = false,
duplicate = false
} = opts;
const {
persistEntry,
collection,
currentStatus,
hasWorkflow,
loadEntry,
slug,
createDraftDuplicateFromEntry,
entryDraft
} = this.props;
await persistEntry(collection);
this.deleteBackup();
if (createNew) {
navigateToNewEntry(collection.get('name'));
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
} else if (slug && hasWorkflow && !currentStatus) {
loadEntry(collection, slug);
}
};
handlePublishEntry = async (opts = {}) => {
const {
createNew = false,
duplicate = false
} = opts;
const {
publishUnpublishedEntry,
createDraftDuplicateFromEntry,
entryDraft,
collection,
slug,
currentStatus,
t
} = this.props;
if (currentStatus !== status.last()) {
window.alert(t('editor.editor.onPublishingNotReady'));
return;
} else if (entryDraft.get('hasChanged')) {
window.alert(t('editor.editor.onPublishingWithUnsavedChanges'));
return;
} else if (!window.confirm(t('editor.editor.onPublishing'))) {
return;
}
await publishUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
};
handleUnpublishEntry = async () => {
const {
unpublishPublishedEntry,
collection,
slug,
t
} = this.props;
if (!window.confirm(t('editor.editor.onUnpublishing'))) return;
await unpublishPublishedEntry(collection, slug);
return navigateToCollection(collection.get('name'));
};
handleDuplicateEntry = () => {
const {
createDraftDuplicateFromEntry,
collection,
entryDraft
} = this.props;
navigateToNewEntry(collection.get('name'));
createDraftDuplicateFromEntry(entryDraft.get('entry'));
};
handleDeleteEntry = () => {
const {
entryDraft,
newEntry,
collection,
deleteEntry,
slug,
t
} = this.props;
if (entryDraft.get('hasChanged')) {
if (!window.confirm(t('editor.editor.onDeleteWithUnsavedChanges'))) {
return;
}
} else if (!window.confirm(t('editor.editor.onDeletePublishedEntry'))) {
return;
}
if (newEntry) {
return navigateToCollection(collection.get('name'));
}
setTimeout(async () => {
await deleteEntry(collection, slug);
this.deleteBackup();
return navigateToCollection(collection.get('name'));
}, 0);
};
handleDeleteUnpublishedChanges = async () => {
const {
entryDraft,
collection,
slug,
deleteUnpublishedEntry,
loadEntry,
isModification,
t
} = this.props;
if (entryDraft.get('hasChanged') && !window.confirm(t('editor.editor.onDeleteUnpublishedChangesWithUnsavedChanges'))) {
return;
} else if (!window.confirm(t('editor.editor.onDeleteUnpublishedChanges'))) {
return;
}
await deleteUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (isModification) {
loadEntry(collection, slug);
} else {
navigateToCollection(collection.get('name'));
}
};
handleNotesChange = (action, payload) => {
const {
collection,
slug
} = this.props;
switch (action) {
case 'ADD_NOTE':
this.props.persistNote(collection, slug, payload);
break;
case 'UPDATE_NOTE':
this.props.updateNotePersist(collection, slug, payload.id, payload.updates);
break;
case 'DELETE_NOTE':
this.props.deleteNotePersist(collection, slug, payload.id);
break;
default:
console.log('Unknown notes action:', action, payload);
}
};
render() {
const {
entry,
entryDraft,
fields,
collection,
changeDraftFieldValidation,
user,
hasChanged,
canCreateNewEntry,
displayUrl,
hasWorkflow,
useOpenAuthoring,
unpublishedEntry,
newEntry,
isModification,
currentStatus,
logoutUser,
deployPreview,
loadDeployPreview,
draftKey,
slug,
t,
editorBackLink
} = this.props;
const isPublished = !newEntry && !unpublishedEntry;
if (entry && entry.get('error')) {
return _jsx("div", {
children: _jsx("h3", {
children: entry.get('error')
})
});
} else if (entryDraft == null || entryDraft.get('entry') === undefined || entry && entry.get('isFetching')) {
return _jsx(Loader, {
active: true,
children: t('editor.editor.loadingEntry')
});
}
return _jsx(EditorInterface, {
draftKey: draftKey,
entry: entryDraft.get('entry'),
collection: collection,
fields: fields,
fieldsMetaData: entryDraft.get('fieldsMetaData'),
fieldsErrors: entryDraft.get('fieldsErrors'),
notes: entryDraft.get('notes'),
onChange: this.handleChangeDraftField,
onNotesChange: this.handleNotesChange,
onValidate: changeDraftFieldValidation,
onPersist: this.handlePersistEntry,
onDelete: this.handleDeleteEntry,
onDeleteUnpublishedChanges: this.handleDeleteUnpublishedChanges,
onChangeStatus: this.handleChangeStatus,
onPublish: this.handlePublishEntry,
unPublish: this.handleUnpublishEntry,
onDuplicate: this.handleDuplicateEntry,
showDelete: this.props.showDelete,
user: user,
hasChanged: hasChanged,
canCreateNewEntry: canCreateNewEntry,
displayUrl: displayUrl,
hasWorkflow: hasWorkflow,
useOpenAuthoring: useOpenAuthoring,
hasUnpublishedChanges: unpublishedEntry,
isNewEntry: newEntry,
isModification: isModification,
isPublished: isPublished,
currentStatus: currentStatus,
onLogoutClick: logoutUser,
deployPreview: deployPreview,
loadDeployPreview: opts => loadDeployPreview(collection, slug, entry, isPublished, opts),
editorBackLink: editorBackLink,
t: t
});
}
}
function mapStateToProps(state, ownProps) {
const {
collections,
entryDraft,
auth,
config,
entries,
globalUI
} = state;
const slug = ownProps.match.params[0];
const collection = collections.get(ownProps.match.params.name);
const collectionName = collection.get('name');
const newEntry = ownProps.newRecord === true;
const fields = selectFields(collection, slug);
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
const user = auth.user;
const hasChanged = entryDraft.get('hasChanged');
const canCreateNewEntry = selectCanCreateNewEntry(state, collectionName);
const displayUrl = config.display_url;
const hasWorkflow = config.publish_mode === EDITORIAL_WORKFLOW;
const useOpenAuthoring = globalUI.useOpenAuthoring;
const isModification = entryDraft.getIn(['entry', 'isModification']);
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const publishedEntry = selectEntry(state, collectionName, slug);
const currentStatus = unPublishedEntry && unPublishedEntry.get('status');
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');
let editorBackLink = `/collections/${collectionName}`;
if (new URLSearchParams(ownProps.location.search).get('ref') === 'workflow') {
editorBackLink = `/workflow`;
}
if (collection.has('nested') && slug) {
const pathParts = slug.split('/');
if (pathParts.length > 2) {
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
}
}
return {
collection,
collections,
newEntry,
entryDraft,
fields,
slug,
entry,
user,
hasChanged,
canCreateNewEntry,
displayUrl,
hasWorkflow,
useOpenAuthoring,
isModification,
collectionEntriesLoaded,
currentStatus,
deployPreview,
localBackup,
draftKey,
publishedEntry,
unPublishedEntry,
editorBackLink
};
}
const mapDispatchToProps = {
changeDraftField,
changeDraftFieldValidation,
loadEntry,
loadEntries,
loadDeployPreview,
loadLocalBackup,
retrieveLocalBackup,
persistLocalBackup,
deleteLocalBackup,
createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
persistEntry,
deleteEntry,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
loadNotesForEntry,
loadNotes,
stopNotesPolling,
persistNote,
updateNotePersist,
deleteNotePersist
};
export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor)));