feat: duplicate entry (#2956)
This commit is contained in:
parent
9ee5d4f66f
commit
d180bffb44
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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'))}
|
||||
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishedButton>{t('editor.editorToolbar.published')}</PublishedButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.duplicate')}
|
||||
icon="add"
|
||||
onClick={onDuplicate}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -320,17 +323,24 @@ class EditorToolbar extends React.Component {
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label="Publish now"
|
||||
label={t('editor.editorToolbar.publishNow')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPersist}
|
||||
/>
|
||||
{collection.get('create') ? (
|
||||
<>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPersistAndNew}
|
||||
/>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndDuplicate')}
|
||||
icon="add"
|
||||
onClick={onPersistAndDuplicate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
</div>
|
||||
@ -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') ? (
|
||||
<>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPublishAndNew}
|
||||
/>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndDuplicate')}
|
||||
icon="add"
|
||||
onClick={onPublishAndDuplicate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
)}
|
||||
@ -470,11 +489,11 @@ class EditorToolbar extends React.Component {
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<UnpublishButton>
|
||||
<PublishedButton>
|
||||
{isPersisting
|
||||
? t('editor.editorToolbar.unpublishing')
|
||||
: t('editor.editorToolbar.published')}
|
||||
</UnpublishButton>
|
||||
</PublishedButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
@ -483,6 +502,11 @@ class EditorToolbar extends React.Component {
|
||||
iconDirection="right"
|
||||
onClick={unPublish}
|
||||
/>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.duplicate')}
|
||||
icon="add"
|
||||
onClick={onDuplicate}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
</>
|
||||
);
|
||||
|
@ -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({}),
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
@ -312,7 +312,6 @@ const components = {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
|
Loading…
x
Reference in New Issue
Block a user