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}',