diff --git a/cypress/integration/editorial_workflow_spec_test_backend.js b/cypress/integration/editorial_workflow_spec_test_backend.js index c31ef571..0bec4d1c 100644 --- a/cypress/integration/editorial_workflow_spec_test_backend.js +++ b/cypress/integration/editorial_workflow_spec_test_backend.js @@ -18,6 +18,7 @@ import { validateObjectFieldsAndExit, validateNestedObjectFieldsAndExit, validateListFieldsAndExit, + unpublishEntry, } from '../utils/steps'; import { setting1, setting2, workflowStatus, editorStatus } from '../utils/constants'; @@ -123,4 +124,15 @@ describe('Test Backend Editorial Workflow', () => { goToWorkflow(); assertWorkflowStatus(entry1, workflowStatus.ready); }); + + it('can unpublish an existing entry', () => { + // first publish an entry + login(); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry1); + // then unpublish it + unpublishEntry(entry1); + }); }); diff --git a/cypress/utils/constants.js b/cypress/utils/constants.js index d152168a..f8f55aa0 100644 --- a/cypress/utils/constants.js +++ b/cypress/utils/constants.js @@ -5,6 +5,7 @@ const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' }; const notifications = { saved: 'Entry saved', published: 'Entry published', + unpublished: 'Entry unpublished', updated: 'Entry status updated', deletedUnpublished: 'Unpublished changes deleted', error: { diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js index ca7f7ed6..61842299 100644 --- a/cypress/utils/steps.js +++ b/cypress/utils/steps.js @@ -212,6 +212,23 @@ function updateExistingPostAndExit(fromEntry, toEntry) { cy.contains('h2', toEntry.title); } +function unpublishEntry(entry) { + goToCollections(); + cy.contains('h2', entry.title) + .parent() + .click({ force: true }); + cy.contains('[role="button"]', 'Published').as('publishedButton'); + cy.get('@publishedButton') + .parent() + .within(() => { + cy.get('@publishedButton').click(); + cy.contains('[role="menuitem"] span', 'Unpublish').click(); + }); + assertNotification(notifications.unpublished); + goToWorkflow(); + assertWorkflowStatus(entry, workflowStatus.ready); +} + function validateObjectFields({ limit, author }) { cy.get('a[href^="#/collections/settings"]').click(); cy.get('a[href^="#/collections/settings/entries/general"]').click(); @@ -303,4 +320,5 @@ module.exports = { validateObjectFieldsAndExit, validateNestedObjectFieldsAndExit, validateListFieldsAndExit, + unpublishEntry, }; diff --git a/packages/netlify-cms-backend-github/src/API.js b/packages/netlify-cms-backend-github/src/API.js index fadb195a..7a0de425 100644 --- a/packages/netlify-cms-backend-github/src/API.js +++ b/packages/netlify-cms-backend-github/src/API.js @@ -667,7 +667,7 @@ export default class API { } : undefined, user: user.name || user.login, - status: this.initialWorkflowStatus, + status: options.status || this.initialWorkflowStatus, branch: branchName, collection: options.collectionName, commitMessage: options.commitMessage, diff --git a/packages/netlify-cms-backend-test/src/implementation.js b/packages/netlify-cms-backend-test/src/implementation.js index 8578484c..85cce194 100644 --- a/packages/netlify-cms-backend-test/src/implementation.js +++ b/packages/netlify-cms-backend-test/src/implementation.js @@ -175,7 +175,7 @@ export default class TestBackend { }, metaData: { collection: options.collectionName, - status: this.options.initialWorkflowStatus, + status: options.status || this.options.initialWorkflowStatus, title: options.parsedData && options.parsedData.title, description: options.parsedData && options.parsedData.description, }, diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.js b/packages/netlify-cms-core/src/actions/editorialWorkflow.js index 7fb57c56..a8952130 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.js +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.js @@ -2,14 +2,16 @@ import uuid from 'uuid/v4'; import { get } from 'lodash'; import { actions as notifActions } from 'redux-notifications'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; +import { Map } from 'immutable'; import { serializeValues } from 'Lib/serializeEntryValues'; import { currentBackend } from 'coreSrc/backend'; -import { selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers'; +import { selectPublishedSlugs, selectUnpublishedSlugs, selectEntry } from 'Reducers'; import { selectFields } from 'Reducers/collections'; -import { EDITORIAL_WORKFLOW } from 'Constants/publishModes'; +import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes'; import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util'; import { loadEntry, + entryDeleted, getMediaAssets, setDraftEntryMediaFiles, clearDraftEntryMediaFiles, @@ -525,3 +527,41 @@ export function publishUnpublishedEntry(collection, slug) { }); }; } + +export function unpublishPublishedEntry(collection, slug) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const transactionID = uuid(); + const entry = selectEntry(state, collection.get('name'), slug); + const entryDraft = Map().set('entry', entry); + dispatch(unpublishedEntryPersisting(collection, entry, transactionID)); + return backend + .persistEntry(state.config, collection, entryDraft, [], state.integrations, [], { + status: status.get('PENDING_PUBLISH'), + }) + .then(() => backend.deleteEntry(state.config, collection, slug)) + .then(() => { + dispatch(unpublishedEntryPersisted(collection, entryDraft, transactionID, slug)); + dispatch(entryDeleted(collection, slug)); + dispatch(loadUnpublishedEntry(collection, slug)); + dispatch( + notifSend({ + message: { key: 'ui.toast.entryUnpublished' }, + kind: 'success', + dismissAfter: 4000, + }), + ); + }) + .catch(error => { + dispatch( + notifSend({ + message: { key: 'ui.toast.onFailToUnpublishEntry', details: error }, + kind: 'danger', + dismissAfter: 8000, + }), + ); + dispatch(unpublishedEntryPersistedFail(error, transactionID)); + }); + }; +} diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js index 71003dce..7a8f03f8 100644 --- a/packages/netlify-cms-core/src/components/Editor/Editor.js +++ b/packages/netlify-cms-core/src/components/Editor/Editor.js @@ -25,6 +25,7 @@ import { import { updateUnpublishedEntryStatus, publishUnpublishedEntry, + unpublishPublishedEntry, deleteUnpublishedEntry, } from 'Actions/editorialWorkflow'; import { loadDeployPreview } from 'Actions/deploys'; @@ -299,6 +300,15 @@ export class Editor extends React.Component { } }; + 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')); + }; + handleDeleteEntry = () => { const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props; if (entryDraft.get('hasChanged')) { @@ -404,6 +414,7 @@ export class Editor extends React.Component { onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges} onChangeStatus={this.handleChangeStatus} onPublish={this.handlePublishEntry} + unPublish={this.handleUnpublishEntry} showDelete={this.props.showDelete} user={user} hasChanged={hasChanged} @@ -481,6 +492,7 @@ export default connect(mapStateToProps, { deleteEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, + unpublishPublishedEntry, deleteUnpublishedEntry, logoutUser, })(withWorkflow(translate()(Editor))); diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 60560729..4dd86b63 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -160,6 +160,7 @@ class EditorInterface extends Component { onDeleteUnpublishedChanges, onChangeStatus, onPublish, + unPublish, onValidate, user, hasChanged, @@ -234,6 +235,7 @@ class EditorInterface extends Component { onChangeStatus={onChangeStatus} showDelete={showDelete} onPublish={onPublish} + unPublish={unPublish} onPublishAndNew={() => this.handleOnPublish({ createNew: true })} user={user} hasChanged={hasChanged} @@ -291,6 +293,7 @@ EditorInterface.propTypes = { onDelete: PropTypes.func.isRequired, onDeleteUnpublishedChanges: PropTypes.func.isRequired, onPublish: PropTypes.func.isRequired, + unPublish: PropTypes.func.isRequired, onChangeStatus: PropTypes.func.isRequired, user: ImmutablePropTypes.map.isRequired, hasChanged: PropTypes.bool, diff --git a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js index 60f7bb23..e76d3e3b 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js @@ -141,6 +141,11 @@ const SaveButton = styled(ToolbarButton)` ${buttons.lightBlue}; `; +const UnpublishButton = styled(StyledDropdownButton)` + background-color: ${colorsRaw.tealLight}; + color: ${colorsRaw.teal}; +`; + const StatusPublished = styled.div` ${styles.buttonMargin}; border: 1px solid ${colors.textFieldBorder}; @@ -212,6 +217,7 @@ class EditorToolbar extends React.Component { onDeleteUnpublishedChanges: PropTypes.func.isRequired, onChangeStatus: PropTypes.func.isRequired, onPublish: PropTypes.func.isRequired, + unPublish: PropTypes.func.isRequired, onPublishAndNew: PropTypes.func.isRequired, user: ImmutablePropTypes.map.isRequired, hasChanged: PropTypes.bool, @@ -377,10 +383,12 @@ class EditorToolbar extends React.Component { isPublishing, onChangeStatus, onPublish, + unPublish, onPublishAndNew, currentStatus, isNewEntry, useOpenAuthoring, + isPersisting, t, } = this.props; if (currentStatus) { @@ -458,7 +466,24 @@ class EditorToolbar extends React.Component { return ( <> {this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} - {t('editor.editorToolbar.published')} + ( + + {isPersisting + ? t('editor.editorToolbar.unpublishing') + : t('editor.editorToolbar.published')} + + )} + > + + ); } diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 5893013f..ad34831c 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -49,6 +49,7 @@ const en = { onPublishingNotReady: 'Please update status to "Ready" before publishing.', onPublishingWithUnsavedChanges: 'You have unsaved changes, please save before publishing.', onPublishing: 'Are you sure you want to publish this entry?', + onUnpublishing: 'Are you sure you want to unpublish this entry?', onDeleteWithUnsavedChanges: 'Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?', onDeletePublishedEntry: 'Are you sure you want to delete this published entry?', @@ -63,6 +64,8 @@ const en = { publishing: 'Publishing...', publish: 'Publish', published: 'Published', + unpublish: 'Unpublish', + unpublishing: 'Unpublishing...', publishAndCreateNew: 'Publish and create new', deleteUnpublishedChanges: 'Delete unpublished changes', deleteUnpublishedEntry: 'Delete unpublished entry', @@ -140,7 +143,9 @@ const en = { missingRequiredField: "Oops, you've missed a required field. Please complete before saving.", entrySaved: 'Entry saved', entryPublished: 'Entry published', + entryUnpublished: 'Entry unpublished', onFailToPublishEntry: 'Failed to publish: %{details}', + onFailToUnpublishEntry: 'Failed to unpublish entry: %{details}', entryUpdated: 'Entry status updated', onDeleteUnpublishedChanges: 'Unpublished changes deleted', onFailToAuth: '%{details}',