From df33bc64a996eedcb10835064a7cab8e7862e94d Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Mon, 23 Mar 2020 12:01:37 +0200 Subject: [PATCH] feat: add publish configuration option to collections (#3467) --- .../editorial_workflow_spec_test_backend.js | 62 ++++ .../src/implementation.ts | 3 +- .../src/actions/__tests__/config.spec.js | 136 ++++---- .../netlify-cms-core/src/actions/config.js | 4 + .../src/components/Editor/EditorToolbar.js | 309 +++++++++--------- .../src/components/Workflow/Workflow.js | 2 +- .../src/components/Workflow/WorkflowCard.js | 14 +- .../src/components/Workflow/WorkflowList.js | 13 +- .../constants/__tests__/configSchema.spec.js | 12 + .../src/constants/configSchema.js | 1 + website/content/docs/configuration-options.md | 1 + 11 files changed, 331 insertions(+), 226 deletions(-) diff --git a/cypress/integration/editorial_workflow_spec_test_backend.js b/cypress/integration/editorial_workflow_spec_test_backend.js index 236f8148..555f8ca1 100644 --- a/cypress/integration/editorial_workflow_spec_test_backend.js +++ b/cypress/integration/editorial_workflow_spec_test_backend.js @@ -23,6 +23,7 @@ import { duplicateEntry, } from '../utils/steps'; import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants'; +import { fromJS } from 'immutable'; const entry1 = { title: 'first title', @@ -145,4 +146,65 @@ describe('Test Backend Editorial Workflow', () => { publishEntryInEditor(publishTypes.publishNow); duplicateEntry(entry1); }); + + it('cannot publish when "publish" is false', () => { + cy.visit('/', { + onBeforeLoad: window => { + window.CMS_MANUAL_INIT = true; + }, + onLoad: window => { + window.CMS.init({ + config: fromJS({ + backend: { + name: 'test-repo', + }, + publish_mode: 'editorial_workflow', + load_config_file: false, + media_folder: 'assets/uploads', + collections: [ + { + label: 'Posts', + name: 'post', + folder: '_posts', + label_singular: 'Post', + create: true, + publish: false, + fields: [ + { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }, + { + label: 'Publish Date', + name: 'date', + widget: 'datetime', + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm', + format: 'YYYY-MM-DD HH:mm', + }, + { + label: 'Cover Image', + name: 'image', + widget: 'image', + required: false, + tagname: '', + }, + { + label: 'Body', + name: 'body', + widget: 'markdown', + hint: 'Main content goes here.', + }, + ], + }, + ], + }), + }); + }, + }); + cy.contains('button', 'Login').click(); + createPost(entry1); + cy.contains('span', 'Publish').should('not.exist'); + exitEditor(); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + cy.contains('button', 'Publish new entry').should('not.exist'); + }); }); diff --git a/packages/netlify-cms-backend-test/src/implementation.ts b/packages/netlify-cms-backend-test/src/implementation.ts index 7b97db2b..112569f8 100644 --- a/packages/netlify-cms-backend-test/src/implementation.ts +++ b/packages/netlify-cms-backend-test/src/implementation.ts @@ -1,4 +1,4 @@ -import { attempt, isError, take, unset } from 'lodash'; +import { attempt, isError, take, unset, isEmpty } from 'lodash'; import uuid from 'uuid/v4'; import { EditorialWorkflowError, @@ -243,6 +243,7 @@ export default class TestBackend implements Implementation { }, slug, mediaFiles: assetProxies.map(this.normalizeAsset), + isModification: !isEmpty(getFile(path)), }; unpubStore.push(unpubEntry); } diff --git a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js index 688726db..76d96a1a 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -55,6 +55,41 @@ describe('config', () => { }); }); + describe('slug', () => { + it('should set default slug config if not set', () => { + expect(applyDefaults(fromJS({ collections: [] })).get('slug')).toEqual( + fromJS({ encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }), + ); + }); + + it('should not overwrite slug encoding if set', () => { + expect( + applyDefaults(fromJS({ collections: [], slug: { encoding: 'ascii' } })).getIn([ + 'slug', + 'encoding', + ]), + ).toEqual('ascii'); + }); + + it('should not overwrite slug clean_accents if set', () => { + expect( + applyDefaults(fromJS({ collections: [], slug: { clean_accents: true } })).getIn([ + 'slug', + 'clean_accents', + ]), + ).toEqual(true); + }); + + it('should not overwrite slug sanitize_replacement if set', () => { + expect( + applyDefaults(fromJS({ collections: [], slug: { sanitize_replacement: '_' } })).getIn([ + 'slug', + 'sanitize_replacement', + ]), + ).toEqual('_'); + }); + }); + describe('collections', () => { it('should strip leading slashes from collection folder', () => { expect( @@ -62,8 +97,8 @@ describe('config', () => { fromJS({ collections: [{ folder: '/foo' }], }), - ).get('collections'), - ).toEqual(fromJS([{ folder: 'foo' }])); + ).getIn(['collections', 0, 'folder']), + ).toEqual('foo'); }); it('should strip leading slashes from collection files', () => { @@ -72,43 +107,8 @@ describe('config', () => { fromJS({ collections: [{ files: [{ file: '/foo' }] }], }), - ).get('collections'), - ).toEqual(fromJS([{ files: [{ file: 'foo' }] }])); - }); - - describe('slug', () => { - it('should set default slug config if not set', () => { - expect(applyDefaults(fromJS({ collections: [] })).get('slug')).toEqual( - fromJS({ encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }), - ); - }); - - it('should not overwrite slug encoding if set', () => { - expect( - applyDefaults(fromJS({ collections: [], slug: { encoding: 'ascii' } })).getIn([ - 'slug', - 'encoding', - ]), - ).toEqual('ascii'); - }); - - it('should not overwrite slug clean_accents if set', () => { - expect( - applyDefaults(fromJS({ collections: [], slug: { clean_accents: true } })).getIn([ - 'slug', - 'clean_accents', - ]), - ).toEqual(true); - }); - - it('should not overwrite slug sanitize_replacement if set', () => { - expect( - applyDefaults(fromJS({ collections: [], slug: { sanitize_replacement: '_' } })).getIn([ - 'slug', - 'sanitize_replacement', - ]), - ).toEqual('_'); - }); + ).getIn(['collections', 0, 'files', 0, 'file']), + ).toEqual('foo'); }); describe('public_folder and media_folder', () => { @@ -118,16 +118,8 @@ describe('config', () => { fromJS({ collections: [{ folder: 'foo', media_folder: 'static/images/docs' }], }), - ).get('collections'), - ).toEqual( - fromJS([ - { - folder: 'foo', - media_folder: 'static/images/docs', - public_folder: 'static/images/docs', - }, - ]), - ); + ).getIn(['collections', 0, 'public_folder']), + ).toEqual('static/images/docs'); }); it('should not overwrite collection public_folder if set', () => { @@ -142,30 +134,42 @@ describe('config', () => { }, ], }), - ).get('collections'), - ).toEqual( - fromJS([ - { - folder: 'foo', - media_folder: 'static/images/docs', - public_folder: 'images/docs', - }, - ]), - ); + ).getIn(['collections', 0, 'public_folder']), + ).toEqual('images/docs'); }); it("should set collection media_folder and public_folder to an empty string when collection path exists, but collection media_folder doesn't", () => { + const result = applyDefaults( + fromJS({ + collections: [{ folder: 'foo', path: '{{slug}}/index' }], + }), + ); + expect(result.getIn(['collections', 0, 'media_folder'])).toEqual(''); + expect(result.getIn(['collections', 0, 'public_folder'])).toEqual(''); + }); + }); + + describe('publish', () => { + it('should set publish to true if not set', () => { expect( applyDefaults( fromJS({ - collections: [{ folder: 'foo', path: '{{slug}}/index' }], + collections: [{ folder: 'foo', media_folder: 'static/images/docs' }], }), - ).get('collections'), - ).toEqual( - fromJS([ - { folder: 'foo', path: '{{slug}}/index', media_folder: '', public_folder: '' }, - ]), - ); + ).getIn(['collections', 0, 'publish']), + ).toEqual(true); + }); + + it('should not override existing publish config', () => { + expect( + applyDefaults( + fromJS({ + collections: [ + { folder: 'foo', media_folder: 'static/images/docs', publish: false }, + ], + }), + ).getIn(['collections', 0, 'publish']), + ).toEqual(false); }); }); }); diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 678b448f..fcbadb5a 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -58,6 +58,10 @@ export function applyDefaults(config) { map.set( 'collections', map.get('collections').map(collection => { + if (!collection.has('publish')) { + collection = collection.set('publish', true); + } + const folder = collection.get('folder'); if (folder) { if (collection.has('path') && !collection.has('media_folder')) { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js index ece3129e..de6333f2 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorToolbar.js @@ -301,48 +301,146 @@ class EditorToolbar extends React.Component { ); }; - renderSimplePublishControls = () => { - const { - collection, - onPersist, - onPersistAndNew, - onPersistAndDuplicate, - onDuplicate, - isPersisting, - hasChanged, - isNewEntry, - t, - } = this.props; + renderWorkflowStatusControls = () => { + const { isUpdatingStatus, onChangeStatus, currentStatus, t, useOpenAuthoring } = this.props; + return ( + ( + + {isUpdatingStatus + ? t('editor.editorToolbar.updating') + : t('editor.editorToolbar.setStatus')} + + )} + > + onChangeStatus('DRAFT')} + icon={currentStatus === status.get('DRAFT') ? 'check' : null} + /> + onChangeStatus('PENDING_REVIEW')} + icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null} + /> + {useOpenAuthoring ? ( + '' + ) : ( + onChangeStatus('PENDING_PUBLISH')} + icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null} + /> + )} + + ); + }; + + renderNewEntryWorkflowPublishControls = ({ canCreate, canPublish }) => { + const { isPublishing, onPublish, onPublishAndNew, onPublishAndDuplicate, t } = this.props; + + return canPublish ? ( + ( + + {isPublishing + ? t('editor.editorToolbar.publishing') + : t('editor.editorToolbar.publish')} + + )} + > + + {canCreate ? ( + <> + + + + ) : null} + + ) : ( + '' + ); + }; + + renderExistingEntryWorkflowPublishControls = ({ canCreate, canPublish }) => { + const { unPublish, onDuplicate, isPersisting, t } = this.props; + + return canPublish || canCreate ? ( + ( + + {isPersisting + ? t('editor.editorToolbar.unpublishing') + : t('editor.editorToolbar.published')} + + )} + > + {canPublish && ( + + )} + {canCreate && ( + + )} + + ) : ( + '' + ); + }; + + renderExistingEntrySimplePublishControls = ({ canCreate }) => { + const { onDuplicate, t } = this.props; + return canCreate ? ( + ( + {t('editor.editorToolbar.published')} + )} + > + { + + } + + ) : ( + {t('editor.editorToolbar.published')} + ); + }; + + renderNewEntrySimplePublishControls = ({ canCreate }) => { + const { onPersist, onPersistAndNew, onPersistAndDuplicate, isPersisting, t } = this.props; - const canCreate = collection.get('create'); - if (!isNewEntry && !hasChanged) { - return ( - <> - {this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} - {canCreate ? ( - ( - - {t('editor.editorToolbar.published')} - - )} - > - { - - } - - ) : ( - {t('editor.editorToolbar.published')} - )} - - ); - } return (
{ + const { collection, hasChanged, isNewEntry, t } = this.props; + + const canCreate = collection.get('create'); + if (!isNewEntry && !hasChanged) { + return ( + <> + {this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} + {this.renderExistingEntrySimplePublishControls({ canCreate })} + + ); + } + return this.renderNewEntrySimplePublishControls({ canCreate }); + }; + renderWorkflowSaveControls = () => { const { onPersist, @@ -421,95 +534,17 @@ class EditorToolbar extends React.Component { }; renderWorkflowPublishControls = () => { - const { - collection, - isUpdatingStatus, - isPublishing, - onChangeStatus, - onPublish, - unPublish, - onDuplicate, - onPublishAndNew, - onPublishAndDuplicate, - currentStatus, - isNewEntry, - useOpenAuthoring, - isPersisting, - t, - } = this.props; + const { collection, currentStatus, isNewEntry, useOpenAuthoring, t } = this.props; const canCreate = collection.get('create'); + const canPublish = collection.get('publish') && !useOpenAuthoring; + if (currentStatus) { return ( <> {this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'))} - ( - - {isUpdatingStatus - ? t('editor.editorToolbar.updating') - : t('editor.editorToolbar.setStatus')} - - )} - > - onChangeStatus('DRAFT')} - icon={currentStatus === status.get('DRAFT') ? 'check' : null} - /> - onChangeStatus('PENDING_REVIEW')} - icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null} - /> - {useOpenAuthoring ? ( - '' - ) : ( - onChangeStatus('PENDING_PUBLISH')} - icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null} - /> - )} - - {useOpenAuthoring ? ( - '' - ) : ( - ( - - {isPublishing - ? t('editor.editorToolbar.publishing') - : t('editor.editorToolbar.publish')} - - )} - > - - {canCreate ? ( - <> - - - - ) : null} - - )} + {this.renderWorkflowStatusControls()} + {this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish })} ); } @@ -521,31 +556,7 @@ class EditorToolbar extends React.Component { return ( <> {this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} - ( - - {isPersisting - ? t('editor.editorToolbar.unpublishing') - : t('editor.editorToolbar.published')} - - )} - > - - {canCreate && ( - - )} - + {this.renderExistingEntryWorkflowPublishControls({ canCreate, canPublish })} ); } diff --git a/packages/netlify-cms-core/src/components/Workflow/Workflow.js b/packages/netlify-cms-core/src/components/Workflow/Workflow.js index 86468a3e..c46e0cc7 100644 --- a/packages/netlify-cms-core/src/components/Workflow/Workflow.js +++ b/packages/netlify-cms-core/src/components/Workflow/Workflow.js @@ -53,7 +53,7 @@ const WorkflowTopDescription = styled.p` class Workflow extends Component { static propTypes = { - collections: ImmutablePropTypes.orderedMap, + collections: ImmutablePropTypes.orderedMap.isRequired, isEditorialWorkflow: PropTypes.bool.isRequired, isOpenAuthoring: PropTypes.bool, isFetching: PropTypes.bool, diff --git a/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js b/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js index 9e52cf8f..b9db491e 100644 --- a/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js +++ b/packages/netlify-cms-core/src/components/Workflow/WorkflowCard.js @@ -126,6 +126,7 @@ const WorkflowCard = ({ editLink, timestamp, onDelete, + allowPublish, canPublish, onPublish, t, @@ -143,11 +144,13 @@ const WorkflowCard = ({ ? t('workflow.workflowCard.deleteChanges') : t('workflow.workflowCard.deleteNewEntry')} - - {isModification - ? t('workflow.workflowCard.publishChanges') - : t('workflow.workflowCard.publishNewEntry')} - + {allowPublish && ( + + {isModification + ? t('workflow.workflowCard.publishChanges') + : t('workflow.workflowCard.publishNewEntry')} + + )} ); @@ -161,6 +164,7 @@ WorkflowCard.propTypes = { editLink: PropTypes.string.isRequired, timestamp: PropTypes.string.isRequired, onDelete: PropTypes.func.isRequired, + allowPublish: PropTypes.bool.isRequired, canPublish: PropTypes.bool.isRequired, onPublish: PropTypes.func.isRequired, t: PropTypes.func.isRequired, diff --git a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js index b44a4276..85fc6eeb 100644 --- a/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js +++ b/packages/netlify-cms-core/src/components/Workflow/WorkflowList.js @@ -134,7 +134,7 @@ class WorkflowList extends React.Component { handleDelete: PropTypes.func.isRequired, t: PropTypes.func.isRequired, isOpenAuthoring: PropTypes.bool, - collections: ImmutablePropTypes.orderedMap, + collections: ImmutablePropTypes.orderedMap.isRequired, }; handleChangeStatus = (newStatus, dragProps) => { @@ -210,11 +210,15 @@ class WorkflowList extends React.Component { const editLink = `collections/${entry.getIn(['metaData', 'collection'])}/entries/${slug}`; const ownStatus = entry.getIn(['metaData', 'status']); const collectionName = entry.getIn(['metaData', 'collection']); - const collectionLabel = collections - ?.find(collection => collection.get('name') === collectionName) - ?.get('label'); + const collection = collections.find( + collection => collection.get('name') === collectionName, + ); + const collectionLabel = collection?.get('label'); const isModification = entry.get('isModification'); + + const allowPublish = collection?.get('publish'); const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false); + return ( diff --git a/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js b/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js index 46fa29dd..3be9286a 100644 --- a/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js +++ b/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js @@ -152,5 +152,17 @@ describe('config', () => { ); }).not.toThrowError(); }); + + it('should throw if collection publish is not a boolean', () => { + expect(() => { + validateConfig(merge(validConfig, { collections: [{ publish: 'false' }] })); + }).toThrowError("'collections[0].publish' should be boolean"); + }); + + it('should not throw if collection publish is a boolean', () => { + expect(() => { + validateConfig(merge(validConfig, { collections: [{ publish: false }] })); + }).not.toThrowError(); + }); }); }); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 5bb2bbdc..f45506ef 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -117,6 +117,7 @@ const getConfigSchema = () => ({ preview_path: { type: 'string' }, preview_path_date_field: { type: 'string' }, create: { type: 'boolean' }, + publish: { type: 'boolean' }, editor: { type: 'object', properties: { diff --git a/website/content/docs/configuration-options.md b/website/content/docs/configuration-options.md index bfd3d4cc..76a9e364 100644 --- a/website/content/docs/configuration-options.md +++ b/website/content/docs/configuration-options.md @@ -194,6 +194,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it * `files` or `folder` (requires one of these): specifies the collection type and location; details in [Collection Types](../collection-types) * `filter`: optional filter for `folder` collections; details in [Collection Types](../collection-types) * `create`: for `folder` collections only; `true` allows users to create new items in the collection; defaults to `false` +* `publish`: for `publish_mode: editorial_workflow` only; `false` hides UI publishing controls for a collection; defaults to `true` * `delete`: `false` prevents users from deleting items in a collection; defaults to `true` * `extension`: see detailed description below * `format`: see detailed description below