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, validateObjectFieldsAndExit,
validateNestedObjectFieldsAndExit, validateNestedObjectFieldsAndExit,
validateListFieldsAndExit, validateListFieldsAndExit,
unpublishEntry,
} from '../utils/steps'; } from '../utils/steps';
import { setting1, setting2, workflowStatus, editorStatus } from '../utils/constants'; import { setting1, setting2, workflowStatus, editorStatus } from '../utils/constants';
@ -123,4 +124,15 @@ describe('Test Backend Editorial Workflow', () => {
goToWorkflow(); goToWorkflow();
assertWorkflowStatus(entry1, workflowStatus.ready); 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 = { const notifications = {
saved: 'Entry saved', saved: 'Entry saved',
published: 'Entry published', published: 'Entry published',
unpublished: 'Entry unpublished',
updated: 'Entry status updated', updated: 'Entry status updated',
deletedUnpublished: 'Unpublished changes deleted', deletedUnpublished: 'Unpublished changes deleted',
error: { error: {

View File

@ -212,6 +212,23 @@ function updateExistingPostAndExit(fromEntry, toEntry) {
cy.contains('h2', toEntry.title); 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 }) { function validateObjectFields({ limit, author }) {
cy.get('a[href^="#/collections/settings"]').click(); cy.get('a[href^="#/collections/settings"]').click();
cy.get('a[href^="#/collections/settings/entries/general"]').click(); cy.get('a[href^="#/collections/settings/entries/general"]').click();
@ -303,4 +320,5 @@ module.exports = {
validateObjectFieldsAndExit, validateObjectFieldsAndExit,
validateNestedObjectFieldsAndExit, validateNestedObjectFieldsAndExit,
validateListFieldsAndExit, validateListFieldsAndExit,
unpublishEntry,
}; };

View File

@ -667,7 +667,7 @@ export default class API {
} }
: undefined, : undefined,
user: user.name || user.login, user: user.name || user.login,
status: this.initialWorkflowStatus, status: options.status || this.initialWorkflowStatus,
branch: branchName, branch: branchName,
collection: options.collectionName, collection: options.collectionName,
commitMessage: options.commitMessage, commitMessage: options.commitMessage,

View File

@ -175,7 +175,7 @@ export default class TestBackend {
}, },
metaData: { metaData: {
collection: options.collectionName, collection: options.collectionName,
status: this.options.initialWorkflowStatus, status: options.status || this.options.initialWorkflowStatus,
title: options.parsedData && options.parsedData.title, title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description, description: options.parsedData && options.parsedData.description,
}, },

View File

@ -2,14 +2,16 @@ import uuid from 'uuid/v4';
import { get } from 'lodash'; import { get } from 'lodash';
import { actions as notifActions } from 'redux-notifications'; import { actions as notifActions } from 'redux-notifications';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { Map } from 'immutable';
import { serializeValues } from 'Lib/serializeEntryValues'; import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'coreSrc/backend'; import { currentBackend } from 'coreSrc/backend';
import { selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers'; import { selectPublishedSlugs, selectUnpublishedSlugs, selectEntry } from 'Reducers';
import { selectFields } from 'Reducers/collections'; 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 { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
import { import {
loadEntry, loadEntry,
entryDeleted,
getMediaAssets, getMediaAssets,
setDraftEntryMediaFiles, setDraftEntryMediaFiles,
clearDraftEntryMediaFiles, 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 { import {
updateUnpublishedEntryStatus, updateUnpublishedEntryStatus,
publishUnpublishedEntry, publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry, deleteUnpublishedEntry,
} from 'Actions/editorialWorkflow'; } from 'Actions/editorialWorkflow';
import { loadDeployPreview } from 'Actions/deploys'; 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 = () => { handleDeleteEntry = () => {
const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props; const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props;
if (entryDraft.get('hasChanged')) { if (entryDraft.get('hasChanged')) {
@ -404,6 +414,7 @@ export class Editor extends React.Component {
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges} onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
onChangeStatus={this.handleChangeStatus} onChangeStatus={this.handleChangeStatus}
onPublish={this.handlePublishEntry} onPublish={this.handlePublishEntry}
unPublish={this.handleUnpublishEntry}
showDelete={this.props.showDelete} showDelete={this.props.showDelete}
user={user} user={user}
hasChanged={hasChanged} hasChanged={hasChanged}
@ -481,6 +492,7 @@ export default connect(mapStateToProps, {
deleteEntry, deleteEntry,
updateUnpublishedEntryStatus, updateUnpublishedEntryStatus,
publishUnpublishedEntry, publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry, deleteUnpublishedEntry,
logoutUser, logoutUser,
})(withWorkflow(translate()(Editor))); })(withWorkflow(translate()(Editor)));

View File

@ -160,6 +160,7 @@ class EditorInterface extends Component {
onDeleteUnpublishedChanges, onDeleteUnpublishedChanges,
onChangeStatus, onChangeStatus,
onPublish, onPublish,
unPublish,
onValidate, onValidate,
user, user,
hasChanged, hasChanged,
@ -234,6 +235,7 @@ class EditorInterface extends Component {
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
showDelete={showDelete} showDelete={showDelete}
onPublish={onPublish} onPublish={onPublish}
unPublish={unPublish}
onPublishAndNew={() => this.handleOnPublish({ createNew: true })} onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
user={user} user={user}
hasChanged={hasChanged} hasChanged={hasChanged}
@ -291,6 +293,7 @@ EditorInterface.propTypes = {
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired, onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired, onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired, onChangeStatus: PropTypes.func.isRequired,
user: ImmutablePropTypes.map.isRequired, user: ImmutablePropTypes.map.isRequired,
hasChanged: PropTypes.bool, hasChanged: PropTypes.bool,

View File

@ -141,6 +141,11 @@ const SaveButton = styled(ToolbarButton)`
${buttons.lightBlue}; ${buttons.lightBlue};
`; `;
const UnpublishButton = styled(StyledDropdownButton)`
background-color: ${colorsRaw.tealLight};
color: ${colorsRaw.teal};
`;
const StatusPublished = styled.div` const StatusPublished = styled.div`
${styles.buttonMargin}; ${styles.buttonMargin};
border: 1px solid ${colors.textFieldBorder}; border: 1px solid ${colors.textFieldBorder};
@ -212,6 +217,7 @@ class EditorToolbar extends React.Component {
onDeleteUnpublishedChanges: PropTypes.func.isRequired, onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired, onChangeStatus: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired, onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
onPublishAndNew: PropTypes.func.isRequired, onPublishAndNew: PropTypes.func.isRequired,
user: ImmutablePropTypes.map.isRequired, user: ImmutablePropTypes.map.isRequired,
hasChanged: PropTypes.bool, hasChanged: PropTypes.bool,
@ -377,10 +383,12 @@ class EditorToolbar extends React.Component {
isPublishing, isPublishing,
onChangeStatus, onChangeStatus,
onPublish, onPublish,
unPublish,
onPublishAndNew, onPublishAndNew,
currentStatus, currentStatus,
isNewEntry, isNewEntry,
useOpenAuthoring, useOpenAuthoring,
isPersisting,
t, t,
} = this.props; } = this.props;
if (currentStatus) { if (currentStatus) {
@ -458,7 +466,24 @@ class EditorToolbar extends React.Component {
return ( return (
<> <>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} {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.', onPublishingNotReady: 'Please update status to "Ready" before publishing.',
onPublishingWithUnsavedChanges: 'You have unsaved changes, please save before publishing.', onPublishingWithUnsavedChanges: 'You have unsaved changes, please save before publishing.',
onPublishing: 'Are you sure you want to publish this entry?', onPublishing: 'Are you sure you want to publish this entry?',
onUnpublishing: 'Are you sure you want to unpublish this entry?',
onDeleteWithUnsavedChanges: onDeleteWithUnsavedChanges:
'Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?', '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?', onDeletePublishedEntry: 'Are you sure you want to delete this published entry?',
@ -63,6 +64,8 @@ const en = {
publishing: 'Publishing...', publishing: 'Publishing...',
publish: 'Publish', publish: 'Publish',
published: 'Published', published: 'Published',
unpublish: 'Unpublish',
unpublishing: 'Unpublishing...',
publishAndCreateNew: 'Publish and create new', publishAndCreateNew: 'Publish and create new',
deleteUnpublishedChanges: 'Delete unpublished changes', deleteUnpublishedChanges: 'Delete unpublished changes',
deleteUnpublishedEntry: 'Delete unpublished entry', deleteUnpublishedEntry: 'Delete unpublished entry',
@ -140,7 +143,9 @@ const en = {
missingRequiredField: "Oops, you've missed a required field. Please complete before saving.", missingRequiredField: "Oops, you've missed a required field. Please complete before saving.",
entrySaved: 'Entry saved', entrySaved: 'Entry saved',
entryPublished: 'Entry published', entryPublished: 'Entry published',
entryUnpublished: 'Entry unpublished',
onFailToPublishEntry: 'Failed to publish: %{details}', onFailToPublishEntry: 'Failed to publish: %{details}',
onFailToUnpublishEntry: 'Failed to unpublish entry: %{details}',
entryUpdated: 'Entry status updated', entryUpdated: 'Entry status updated',
onDeleteUnpublishedChanges: 'Unpublished changes deleted', onDeleteUnpublishedChanges: 'Unpublished changes deleted',
onFailToAuth: '%{details}', onFailToAuth: '%{details}',