feat: workflow unpublished entry (#2914)

* feat: workflow unpublished entry

* fix: post rebase fix - load unpublished entry after unpublish

* feat: change unpublish button to dropdown

* test(cypress): add unpublish entry cypress test
This commit is contained in:
Bartholomew 2019-11-26 11:14:04 +01:00 committed by Erez Rokah
parent 465f463959
commit 41bb9aac0d
10 changed files with 121 additions and 5 deletions

View File

@ -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);
});
});

View File

@ -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: {

View File

@ -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,
};

View File

@ -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,

View File

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

View File

@ -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));
});
};
}

View File

@ -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)));

View File

@ -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,

View File

@ -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'))}
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<UnpublishButton>
{isPersisting
? t('editor.editorToolbar.unpublishing')
: t('editor.editorToolbar.published')}
</UnpublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.unpublish')}
icon="arrow"
iconDirection="right"
onClick={unPublish}
/>
</ToolbarDropdown>
</>
);
}

View File

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