diff --git a/cypress/integration/editorial_workflow_spec_test_backend.js b/cypress/integration/editorial_workflow_spec_test_backend.js
index 0bec4d1c..236f8148 100644
--- a/cypress/integration/editorial_workflow_spec_test_backend.js
+++ b/cypress/integration/editorial_workflow_spec_test_backend.js
@@ -19,8 +19,10 @@ import {
validateNestedObjectFieldsAndExit,
validateListFieldsAndExit,
unpublishEntry,
+ publishEntryInEditor,
+ duplicateEntry,
} from '../utils/steps';
-import { setting1, setting2, workflowStatus, editorStatus } from '../utils/constants';
+import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants';
const entry1 = {
title: 'first title',
@@ -135,4 +137,12 @@ describe('Test Backend Editorial Workflow', () => {
// then unpublish it
unpublishEntry(entry1);
});
+
+ it('can duplicate an existing entry', () => {
+ login();
+ createPost(entry1);
+ updateWorkflowStatusInEditor(editorStatus.ready);
+ publishEntryInEditor(publishTypes.publishNow);
+ duplicateEntry(entry1);
+ });
});
diff --git a/cypress/utils/constants.js b/cypress/utils/constants.js
index f8f55aa0..eaecdfde 100644
--- a/cypress/utils/constants.js
+++ b/cypress/utils/constants.js
@@ -2,6 +2,7 @@ const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' };
const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' };
const setting1 = { limit: 10, author: 'John Doe' };
const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' };
+const publishTypes = { publishNow: 'Publish now' };
const notifications = {
saved: 'Entry saved',
published: 'Entry published',
@@ -25,4 +26,5 @@ module.exports = {
setting1,
setting2,
notifications,
+ publishTypes,
};
diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js
index 61842299..24c6549e 100644
--- a/cypress/utils/steps.js
+++ b/cypress/utils/steps.js
@@ -1,4 +1,4 @@
-const { notifications, workflowStatus } = require('./constants');
+const { notifications, workflowStatus, editorStatus, publishTypes } = require('./constants');
function login(user) {
cy.viewport(1200, 1200);
@@ -147,14 +147,23 @@ function assertWorkflowStatus({ title }, status) {
}
function updateWorkflowStatusInEditor(newStatus) {
- cy.contains('[role="button"]', 'Set status').as('setStatusButton');
- cy.get('@setStatusButton')
+ selectDropdownItem('Set status', newStatus);
+ assertNotification(notifications.updated);
+}
+
+function publishEntryInEditor(publishType) {
+ selectDropdownItem('Publish', publishType);
+ assertNotification(notifications.published);
+}
+
+function selectDropdownItem(label, item){
+ cy.contains('[role="button"]', label).as('dropDownButton');
+ cy.get('@dropDownButton')
.parent()
.within(() => {
- cy.get('@setStatusButton').click();
- cy.contains('[role="menuitem"] span', newStatus).click();
+ cy.get('@dropDownButton').click();
+ cy.contains('[role="menuitem"] span', item).click();
});
- assertNotification(notifications.updated);
}
function populateEntry(entry) {
@@ -217,18 +226,25 @@ function unpublishEntry(entry) {
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();
- });
+ selectDropdownItem('Published', 'Unpublish');
assertNotification(notifications.unpublished);
goToWorkflow();
assertWorkflowStatus(entry, workflowStatus.ready);
}
+function duplicateEntry(entry) {
+ selectDropdownItem('Published', 'Duplicate');
+ cy.url().should('contain', '/#/collections/posts/new');
+ cy.contains('button', 'Save').click();
+ updateWorkflowStatusInEditor(editorStatus.ready);
+ publishEntryInEditor(publishTypes.publishNow);
+ exitEditor();
+ cy.get('a h2').should(($h2s) => {
+ expect($h2s.eq(0)).to.contain(entry.title);
+ expect($h2s.eq(1)).to.contain(entry.title);
+ });
+}
+
function validateObjectFields({ limit, author }) {
cy.get('a[href^="#/collections/settings"]').click();
cy.get('a[href^="#/collections/settings/entries/general"]').click();
@@ -321,4 +337,6 @@ module.exports = {
validateNestedObjectFieldsAndExit,
validateListFieldsAndExit,
unpublishEntry,
+ publishEntryInEditor,
+ duplicateEntry,
};
diff --git a/packages/netlify-cms-core/src/actions/entries.js b/packages/netlify-cms-core/src/actions/entries.js
index a0b30177..4757b387 100644
--- a/packages/netlify-cms-core/src/actions/entries.js
+++ b/packages/netlify-cms-core/src/actions/entries.js
@@ -36,6 +36,7 @@ export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS';
export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED';
export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP';
+export const DRAFT_CREATE_DUPLICATE_FROM_ENTRY = 'DRAFT_CREATE_DUPLICATE_FROM_ENTRY';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
@@ -200,6 +201,13 @@ export function createDraftFromEntry(entry, metadata, mediaFiles) {
};
}
+export function createDraftDuplicateFromEntry(entry) {
+ return {
+ type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
+ payload: createEntry(entry.get('collection'), '', '', { data: entry.get('data') }),
+ };
+}
+
export function discardDraft() {
return (dispatch, getState) => {
const state = getState();
diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js
index 0c1bc309..554bdce0 100644
--- a/packages/netlify-cms-core/src/components/Editor/Editor.js
+++ b/packages/netlify-cms-core/src/components/Editor/Editor.js
@@ -11,6 +11,7 @@ import {
loadEntry,
loadEntries,
createDraftFromEntry,
+ createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
changeDraftField,
@@ -49,6 +50,7 @@ export class Editor extends React.Component {
changeDraftFieldValidation: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
createDraftFromEntry: PropTypes.func.isRequired,
+ createDraftDuplicateFromEntry: PropTypes.func.isRequired,
createEmptyDraft: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map,
@@ -255,7 +257,7 @@ export class Editor extends React.Component {
}
handlePersistEntry = async (opts = {}) => {
- const { createNew = false } = opts;
+ const { createNew = false, duplicate = false } = opts;
const {
persistEntry,
collection,
@@ -263,7 +265,8 @@ export class Editor extends React.Component {
hasWorkflow,
loadEntry,
slug,
- createEmptyDraft,
+ createDraftDuplicateFromEntry,
+ entryDraft,
} = this.props;
await persistEntry(collection);
@@ -272,15 +275,23 @@ export class Editor extends React.Component {
if (createNew) {
navigateToNewEntry(collection.get('name'));
- createEmptyDraft(collection);
+ duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
} else if (slug && hasWorkflow && !currentStatus) {
loadEntry(collection, slug);
}
};
handlePublishEntry = async (opts = {}) => {
- const { createNew = false } = opts;
- const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, t } = this.props;
+ 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;
@@ -298,6 +309,8 @@ export class Editor extends React.Component {
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
+
+ duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
};
handleUnpublishEntry = async () => {
@@ -309,6 +322,13 @@ export class Editor extends React.Component {
return navigateToCollection(collection.get('name'));
};
+ handleDuplicateEntry = async () => {
+ const { createDraftDuplicateFromEntry, collection, entryDraft } = this.props;
+
+ await navigateToNewEntry(collection.get('name'));
+ createDraftDuplicateFromEntry(entryDraft.get('entry'));
+ };
+
handleDeleteEntry = () => {
const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props;
if (entryDraft.get('hasChanged')) {
@@ -415,6 +435,7 @@ export class Editor extends React.Component {
onChangeStatus={this.handleChangeStatus}
onPublish={this.handlePublishEntry}
unPublish={this.handleUnpublishEntry}
+ onDuplicate={this.handleDuplicateEntry}
showDelete={this.props.showDelete}
user={user}
hasChanged={hasChanged}
@@ -486,6 +507,7 @@ export default connect(mapStateToProps, {
persistLocalBackup,
deleteLocalBackup,
createDraftFromEntry,
+ createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
persistEntry,
diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
index 4dd86b63..2ea9675a 100644
--- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
+++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
@@ -123,15 +123,15 @@ class EditorInterface extends Component {
};
handleOnPersist = (opts = {}) => {
- const { createNew = false } = opts;
+ const { createNew = false, duplicate = false } = opts;
this.controlPaneRef.validate();
- this.props.onPersist({ createNew });
+ this.props.onPersist({ createNew, duplicate });
};
handleOnPublish = (opts = {}) => {
- const { createNew = false } = opts;
+ const { createNew = false, duplicate = false } = opts;
this.controlPaneRef.validate();
- this.props.onPublish({ createNew });
+ this.props.onPublish({ createNew, duplicate });
};
handleTogglePreview = () => {
@@ -161,6 +161,7 @@ class EditorInterface extends Component {
onChangeStatus,
onPublish,
unPublish,
+ onDuplicate,
onValidate,
user,
hasChanged,
@@ -230,13 +231,16 @@ class EditorInterface extends Component {
isDeleting={entry.get('isDeleting')}
onPersist={this.handleOnPersist}
onPersistAndNew={() => this.handleOnPersist({ createNew: true })}
+ onPersistAndDuplicate={() => this.handleOnPersist({ createNew: true, duplicate: true })}
onDelete={onDelete}
onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
onChangeStatus={onChangeStatus}
showDelete={showDelete}
onPublish={onPublish}
unPublish={unPublish}
+ onDuplicate={onDuplicate}
onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
+ onPublishAndDuplicate={() => this.handleOnPublish({ createNew: true, duplicate: true })}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
@@ -294,6 +298,7 @@ EditorInterface.propTypes = {
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
+ onDuplicate: 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 e76d3e3b..94fade0e 100644
--- a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js
+++ b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js
@@ -15,7 +15,6 @@ import {
colors,
components,
buttons,
- lengths,
} from 'netlify-cms-ui-default';
import { status } from 'Constants/publishModes';
import SettingsDropdown from 'UI/SettingsDropdown';
@@ -141,24 +140,11 @@ const SaveButton = styled(ToolbarButton)`
${buttons.lightBlue};
`;
-const UnpublishButton = styled(StyledDropdownButton)`
+const PublishedButton = styled(StyledDropdownButton)`
background-color: ${colorsRaw.tealLight};
color: ${colorsRaw.teal};
`;
-const StatusPublished = styled.div`
- ${styles.buttonMargin};
- border: 1px solid ${colors.textFieldBorder};
- border-radius: ${lengths.borderRadius};
- background-color: ${colorsRaw.white};
- color: ${colorsRaw.teal};
- padding: 0 24px;
- line-height: 36px;
- cursor: default;
- font-size: 14px;
- font-weight: 500;
-`;
-
const PublishButton = styled(StyledDropdownButton)`
background-color: ${colorsRaw.teal};
`;
@@ -212,13 +198,16 @@ class EditorToolbar extends React.Component {
isDeleting: PropTypes.bool,
onPersist: PropTypes.func.isRequired,
onPersistAndNew: PropTypes.func.isRequired,
+ onPersistAndDuplicate: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
+ onDuplicate: PropTypes.func.isRequired,
onPublishAndNew: PropTypes.func.isRequired,
+ onPublishAndDuplicate: PropTypes.func.isRequired,
user: ImmutablePropTypes.map.isRequired,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
@@ -293,6 +282,8 @@ class EditorToolbar extends React.Component {
collection,
onPersist,
onPersistAndNew,
+ onPersistAndDuplicate,
+ onDuplicate,
isPersisting,
hasChanged,
isNewEntry,
@@ -302,7 +293,19 @@ class EditorToolbar extends React.Component {
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
- {t('editor.editorToolbar.published')}
+ (
+ {t('editor.editorToolbar.published')}
+ )}
+ >
+
+
>
);
}
@@ -320,17 +323,24 @@ class EditorToolbar extends React.Component {
)}
>
{collection.get('create') ? (
-
+ <>
+
+
+ >
) : null}
@@ -384,7 +394,9 @@ class EditorToolbar extends React.Component {
onChangeStatus,
onPublish,
unPublish,
+ onDuplicate,
onPublishAndNew,
+ onPublishAndDuplicate,
currentStatus,
isNewEntry,
useOpenAuthoring,
@@ -447,11 +459,18 @@ class EditorToolbar extends React.Component {
onClick={onPublish}
/>
{collection.get('create') ? (
-
+ <>
+
+
+ >
) : null}
)}
@@ -470,11 +489,11 @@ class EditorToolbar extends React.Component {
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
-
+
{isPersisting
? t('editor.editorToolbar.unpublishing')
: t('editor.editorToolbar.published')}
-
+
)}
>
+
>
);
diff --git a/packages/netlify-cms-core/src/components/Editor/__tests__/Editor.spec.js b/packages/netlify-cms-core/src/components/Editor/__tests__/Editor.spec.js
index d5cb4bf5..0cf2f8e1 100644
--- a/packages/netlify-cms-core/src/components/Editor/__tests__/Editor.spec.js
+++ b/packages/netlify-cms-core/src/components/Editor/__tests__/Editor.spec.js
@@ -26,6 +26,7 @@ describe('Editor', () => {
changeDraftFieldValidation: jest.fn(),
collection: fromJS({ name: 'posts' }),
createDraftFromEntry: jest.fn(),
+ createDraftDuplicateFromEntry: jest.fn(),
createEmptyDraft: jest.fn(),
discardDraft: jest.fn(),
entry: fromJS({}),
diff --git a/packages/netlify-cms-core/src/reducers/entryDraft.js b/packages/netlify-cms-core/src/reducers/entryDraft.js
index 35a4d9f3..636b6cef 100644
--- a/packages/netlify-cms-core/src/reducers/entryDraft.js
+++ b/packages/netlify-cms-core/src/reducers/entryDraft.js
@@ -8,6 +8,7 @@ import {
DRAFT_CLEAR_ERRORS,
DRAFT_LOCAL_BACKUP_RETRIEVED,
DRAFT_CREATE_FROM_LOCAL_BACKUP,
+ DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
@@ -69,6 +70,16 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
});
+ case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
+ // Duplicate Entry
+ return state.withMutations(state => {
+ state.set('entry', fromJS(action.payload));
+ state.setIn(['entry', 'newRecord'], true);
+ state.set('mediaFiles', List());
+ state.set('fieldsMetaData', Map());
+ state.set('fieldsErrors', Map());
+ state.set('hasChanged', true);
+ });
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js
index ad34831c..65975533 100644
--- a/packages/netlify-cms-locales/src/en/index.js
+++ b/packages/netlify-cms-locales/src/en/index.js
@@ -65,8 +65,10 @@ const en = {
publish: 'Publish',
published: 'Published',
unpublish: 'Unpublish',
+ duplicate: 'Duplicate',
unpublishing: 'Unpublishing...',
publishAndCreateNew: 'Publish and create new',
+ publishAndDuplicate: 'Publish and duplicate',
deleteUnpublishedChanges: 'Delete unpublished changes',
deleteUnpublishedEntry: 'Delete unpublished entry',
deletePublishedEntry: 'Delete published entry',
diff --git a/packages/netlify-cms-ui-default/src/styles.js b/packages/netlify-cms-ui-default/src/styles.js
index c8645d29..4b2aba9a 100644
--- a/packages/netlify-cms-ui-default/src/styles.js
+++ b/packages/netlify-cms-ui-default/src/styles.js
@@ -312,7 +312,6 @@ const components = {
display: flex;
justify-content: space-between;
align-items: center;
- white-space: nowrap;
&:last-of-type {
border-bottom: 0;