UNPKG

decap-cms-core

Version:

Decap CMS core application, see decap-cms package for the main distribution.

573 lines (513 loc) 17.3 kB
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'; 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 ( <div> <h3>{entry.get('error')}</h3> </div> ); } else if ( entryDraft == null || entryDraft.get('entry') === undefined || (entry && entry.get('isFetching')) ) { return <Loader active>{t('editor.editor.loadingEntry')}</Loader>; } return ( <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)));