diff --git a/client/src/components/StreamField/blocks/BaseSequenceBlock.js b/client/src/components/StreamField/blocks/BaseSequenceBlock.js index 216f6e7cbd..cc27db09c8 100644 --- a/client/src/components/StreamField/blocks/BaseSequenceBlock.js +++ b/client/src/components/StreamField/blocks/BaseSequenceBlock.js @@ -69,7 +69,8 @@ export class BaseSequenceChild { this.element = dom.get(0); const blockElement = dom.find('[data-streamfield-block]').get(0); - dom.find('button[data-duplicate-button]').click(() => { + this.duplicateButton = dom.find('button[data-duplicate-button]'); + this.duplicateButton.click(() => { if (this.onRequestDuplicate) this.onRequestDuplicate(this.index); }); @@ -107,6 +108,12 @@ export class BaseSequenceChild { } } + enableDuplication() { + this.duplicateButton.removeAttr('disabled'); + } + disableDuplication() { + this.duplicateButton.attr('disabled', 'true'); + } enableMoveUp() { this.moveUpButton.removeAttr('disabled'); } diff --git a/client/src/components/StreamField/blocks/StreamBlock.js b/client/src/components/StreamField/blocks/StreamBlock.js index a88fa69df0..fedadeebd5 100644 --- a/client/src/components/StreamField/blocks/StreamBlock.js +++ b/client/src/components/StreamField/blocks/StreamBlock.js @@ -62,6 +62,8 @@ class StreamBlockMenu extends BaseInsertionControl { this.innerContainer = dom.find('[data-streamblock-menu-inner]'); this.hasRenderedMenu = false; this.isOpen = false; + this.canAddBlock = true; + this.disabledBlockTypes = new Set(); this.close({ animate: false }); } @@ -94,6 +96,34 @@ class StreamBlockMenu extends BaseInsertionControl { }); }); }); + + // Disable buttons for any disabled block types + this.disabledBlockTypes.forEach(blockType => { + $(`button.action-add-block-${h(blockType)}`, this.innerContainer).attr('disabled', 'true'); + }); + } + + setNewBlockRestrictions(canAddBlock, disabledBlockTypes) { + this.canAddBlock = canAddBlock; + this.disabledBlockTypes = disabledBlockTypes; + + // Disable/enable menu open button + if (this.canAddBlock) { + this.addButton.removeAttr('disabled'); + } else { + this.addButton.attr('disabled', 'true'); + } + + // Close menu if its open and we no longer can add blocks + if (!canAddBlock && this.isOpen) { + this.close({ animate: true }); + } + + // Disable/enable individual block type buttons + $('button', this.innerContainer).removeAttr('disabled'); + disabledBlockTypes.forEach(blockType => { + $(`button.action-add-block-${h(blockType)}`, this.innerContainer).attr('disabled', 'true'); + }); } toggle() { @@ -104,6 +134,10 @@ class StreamBlockMenu extends BaseInsertionControl { } } open(opts) { + if (!this.canAddBlock) { + return; + } + this.renderMenu(); if (opts && opts.animate) { this.outerContainer.slideDown(); @@ -176,6 +210,51 @@ export class StreamBlock extends BaseSequenceBlock { } } + /* + * Called whenever a block is added or removed + * + * Updates the state of add / duplicate block buttons to prevent too many blocks being inserted. + */ + checkBlockCounts() { + this.canAddBlock = true; + + if (typeof this.blockDef.meta.maxNum === 'number' && this.children.length >= this.blockDef.meta.maxNum) { + this.canAddBlock = false; + } + + // If we can add blocks, check if there are any block types that have count limits + this.disabledBlockTypes = new Set(); + if (this.canAddBlock) { + // eslint-disable-next-line no-restricted-syntax + for (const blockType in this.blockDef.meta.blockCounts) { + if (this.blockDef.meta.blockCounts.hasOwnProperty(blockType)) { + const counts = this.blockDef.meta.blockCounts[blockType]; + + if (typeof counts.max_num === 'number') { + const currentBlockCount = this.children.filter(child => child.type === blockType).length; + + if (currentBlockCount >= counts.max_num) { + this.disabledBlockTypes.add(blockType); + } + } + } + } + } + + for (let i = 0; i < this.children.length; i++) { + const canDuplicate = this.canAddBlock && !this.disabledBlockTypes.has(this.children[i].type); + + if (canDuplicate) { + this.children[i].enableDuplication(); + } else { + this.children[i].disableDuplication(); + } + } + for (let i = 0; i < this.inserters.length; i++) { + this.inserters[i].setNewBlockRestrictions(this.canAddBlock, this.disabledBlockTypes); + } + } + _createChild(blockDef, placeholder, prefix, index, id, initialState, opts) { return new StreamChild(blockDef, placeholder, prefix, index, id, initialState, opts); } @@ -191,6 +270,12 @@ export class StreamBlock extends BaseSequenceBlock { return this._insert(childBlockDef, value, id, index, opts); } + _insert(childBlockDef, value, id, index, opts) { + const result = super._insert(childBlockDef, value, id, index, opts); + this.checkBlockCounts(); + return result; + } + _getChildDataForInsertion({ type }) { /* Called when an 'insert new block' action is triggered: given a dict of data from the insertion control, return the block definition and initial state to be used for the new block. @@ -201,11 +286,23 @@ export class StreamBlock extends BaseSequenceBlock { return [blockDef, initialState]; } + clear() { + super.clear(); + this.checkBlockCounts(); + } + duplicateBlock(index) { const childState = this.children[index].getState(); childState.id = null; this.insert(childState, index + 1, { animate: true }); this.children[index + 1].focus(); + + this.checkBlockCounts(); + } + + deleteBlock(index) { + super.deleteBlock(index); + this.checkBlockCounts(); } setState(values) { diff --git a/client/src/components/StreamField/blocks/StreamBlock.test.js b/client/src/components/StreamField/blocks/StreamBlock.test.js index b57bc0ca4a..9963ef374c 100644 --- a/client/src/components/StreamField/blocks/StreamBlock.test.js +++ b/client/src/components/StreamField/blocks/StreamBlock.test.js @@ -353,3 +353,345 @@ describe('telepath: wagtail.blocks.StreamBlock with labels that need escaping', expect(document.body.innerHTML).toMatchSnapshot(); }); }); + +describe('telepath: wagtail.blocks.StreamBlock with maxNum set', () => { + // Define a test block + const blockDef = new StreamBlockDefinition( + '', + [ + ['', [ + new FieldBlockDefinition( + 'test_block_a', + new DummyWidgetDefinition('Block A widget'), + { + label: 'Test Block ', + required: true, + icon: 'placeholder', + classname: 'field char_field widget-text_input fieldname-test_charblock' + } + ), + new FieldBlockDefinition( + 'test_block_b', + new DummyWidgetDefinition('Block B widget'), + { + label: 'Test Block ', + required: true, + icon: 'pilcrow', + classname: 'field char_field widget-admin_auto_height_text_input fieldname-test_textblock' + } + ), + ]] + ], + { + test_block_a: 'Block A options', + test_block_b: 'Block B options', + }, + { + label: '', + required: true, + icon: 'placeholder', + classname: null, + helpText: 'use plenty of these', + helpIcon: '
?
', + maxNum: 3, + minNum: null, + blockCounts: {}, + strings: { + MOVE_UP: 'Move up', + MOVE_DOWN: 'Move down', + DELETE: 'Delete & kill with fire', + DUPLICATE: 'Duplicate', + ADD: 'Add', + }, + } + ); + + const assertCanAddBlock = () => { + // Test duplicate button + // querySelector always returns the first element it sees so this only checks the first block + expect(document.querySelector('button[data-duplicate-button]').getAttribute('disabled')).toBe(null); + + // Test menu + expect(document.querySelector('button[data-streamblock-menu-open]').getAttribute('disabled')).toBe(null); + }; + + const assertCannotAddBlock = () => { + // Test duplicate button + // querySelector always returns the first element it sees so this only checks the first block + expect(document.querySelector('button[data-duplicate-button]').getAttribute('disabled')).toEqual('disabled'); + + // Test menu + expect(document.querySelector('button[data-streamblock-menu-open]').getAttribute('disabled')).toEqual('disabled'); + }; + + test('test can add block when under limit', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCanAddBlock(); + }); + + test('initialising at maxNum disables adding new block and duplication', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + { + id: '3', + type: 'test_block_b', + value: 'Third value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCannotAddBlock(); + }); + + test('insert disables new block', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCanAddBlock(); + + boundBlock.insert({ + id: '3', + type: 'test_block_b', + value: 'Third value' + }, 2); + + assertCannotAddBlock(); + }); + + test('delete enables new block', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + { + id: '3', + type: 'test_block_b', + value: 'Third value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCannotAddBlock(); + + boundBlock.deleteBlock(2); + + assertCanAddBlock(); + }); +}); + +describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', () => { + // Define a test block + const blockDef = new StreamBlockDefinition( + '', + [ + ['', [ + new FieldBlockDefinition( + 'test_block_a', + new DummyWidgetDefinition('Block A widget'), + { + label: 'Test Block
', + required: true, + icon: 'placeholder', + classname: 'field char_field widget-text_input fieldname-test_charblock' + } + ), + new FieldBlockDefinition( + 'test_block_b', + new DummyWidgetDefinition('Block B widget'), + { + label: 'Test Block ', + required: true, + icon: 'pilcrow', + classname: 'field char_field widget-admin_auto_height_text_input fieldname-test_textblock' + } + ), + ]] + ], + { + test_block_a: 'Block A options', + test_block_b: 'Block B options', + }, + { + label: '', + required: true, + icon: 'placeholder', + classname: null, + helpText: 'use plenty of these', + helpIcon: '
?
', + maxNum: null, + minNum: null, + blockCounts: { + test_block_a: { + max_num: 2 + } + }, + strings: { + MOVE_UP: 'Move up', + MOVE_DOWN: 'Move down', + DELETE: 'Delete & kill with fire', + DUPLICATE: 'Duplicate', + ADD: 'Add', + }, + } + ); + + const assertCanAddBlock = () => { + // Test duplicate button + // querySelector always returns the first element it sees so this only checks the first block + expect(document.querySelector('button[data-duplicate-button]').getAttribute('disabled')).toBe(null); + + // Test menu item + expect(document.querySelector('button.action-add-block-test_block_a').getAttribute('disabled')).toBe(null); + }; + + const assertCannotAddBlock = () => { + // Test duplicate button + // querySelector always returns the first element it sees so this only checks the first block + expect(document.querySelector('button[data-duplicate-button]').getAttribute('disabled')).toEqual('disabled'); + + // Test menu item + expect(document.querySelector('button.action-add-block-test_block_a').getAttribute('disabled')).toEqual('disabled'); + }; + + test('single instance allows creation of new block and duplication', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCanAddBlock(); + }); + + test('initialising at max_num disables adding new block of that type and duplication', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + { + id: '3', + type: 'test_block_a', + value: 'Third value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCannotAddBlock(); + }); + + test('insert disables new block', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCanAddBlock(); + + boundBlock.insert({ + id: '3', + type: 'test_block_a', + value: 'Third value' + }, 2); + + assertCannotAddBlock(); + }); + + test('delete enables new block', () => { + document.body.innerHTML = '
'; + const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [ + { + id: '1', + type: 'test_block_a', + value: 'First value' + }, + { + id: '2', + type: 'test_block_b', + value: 'Second value' + }, + { + id: '3', + type: 'test_block_a', + value: 'Third value' + }, + ]); + boundBlock.inserters[0].open(); + + assertCannotAddBlock(); + + boundBlock.deleteBlock(2); + + assertCanAddBlock(); + }); +});