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;