diff --git a/core/block.ts b/core/block.ts index 33dc56af3..c3da45136 100644 --- a/core/block.ts +++ b/core/block.ts @@ -41,6 +41,7 @@ import * as arrayUtils from './utils/array.js'; import {Coordinate} from './utils/coordinate.js'; import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; +import * as registry from './registry.js'; import {Size} from './utils/size.js'; import type {VariableModel} from './variable_model.js'; import type {Workspace} from './workspace.js'; @@ -1482,7 +1483,7 @@ export class Block implements IASTNodeLocation, IDeletable { } /** - * Shortcut for appending a value input row. + * Appends a value input row. * * @param name Language-neutral identifier which may used to find this input * again. Should be unique to this block. @@ -1493,7 +1494,7 @@ export class Block implements IASTNodeLocation, IDeletable { } /** - * Shortcut for appending a statement input row. + * Appends a statement input row. * * @param name Language-neutral identifier which may used to find this input * again. Should be unique to this block. @@ -1504,7 +1505,7 @@ export class Block implements IASTNodeLocation, IDeletable { } /** - * Shortcut for appending a dummy input row. + * Appends a dummy input row. * * @param opt_name Language-neutral identifier which may used to find this * input again. Should be unique to this block. @@ -1514,6 +1515,33 @@ export class Block implements IASTNodeLocation, IDeletable { return this.appendInput_(inputTypes.DUMMY, opt_name || ''); } + /** + * Appends the given input row. + * + * Allows for custom inputs to be appended to the block. + */ + appendInput(input: Input): Input { + this.inputList.push(input); + return input; + } + + /** + * Appends an input with the given input type and name to the block after + * constructing it from the registry. + * + * @param type The name the input is registered under in the registry. + * @param name The name the input will have within the block. + * @returns The constucted input, or null if there was no constructor + * associated with the type. + */ + private appendInputFromRegistry(type: string, name: string): Input|null { + const inputConstructor = + registry.getClass(registry.Type.INPUT, type, false); + if (!inputConstructor) return null; + return this.appendInput( + new inputConstructor(inputTypes.CUSTOM, name, this, null)); + } + /** * Initialize this block using a cross-platform, internationalization-friendly * JSON description. @@ -1850,6 +1878,10 @@ export class Block implements IASTNodeLocation, IDeletable { case 'input_dummy': input = this.appendDummyInput(element['name']); break; + default: { + input = this.appendInputFromRegistry(element['type'], element['name']); + break; + } } // Should never be hit because of interpolate_'s checks, but just in case. if (!input) { @@ -1881,7 +1913,7 @@ export class Block implements IASTNodeLocation, IDeletable { */ private isInputKeyword_(str: string): boolean { return str === 'input_value' || str === 'input_statement' || - str === 'input_dummy'; + str === 'input_dummy' || registry.hasItem(registry.Type.INPUT, str); } /** diff --git a/core/input.ts b/core/input.ts index 09a8a2dc6..e0ad29537 100644 --- a/core/input.ts +++ b/core/input.ts @@ -41,12 +41,14 @@ export class Input { * @param name Language-neutral identifier which may used to find this input * again. * @param block The block containing this input. - * @param connection Optional connection for this input. + * @param connection Optional connection for this input. If this is a custom + * input, `null` will always be passed, and then the subclass can + * optionally construct a connection. */ constructor( public type: number, public name: string, block: Block, public connection: Connection|null) { - if (type !== inputTypes.DUMMY && !name) { + if ((type === inputTypes.VALUE || type === inputTypes.STATEMENT) && !name) { throw Error( 'Value inputs and statement inputs must have non-empty name.'); } diff --git a/core/input_types.ts b/core/input_types.ts index 8f50346ad..039bc6b70 100644 --- a/core/input_types.ts +++ b/core/input_types.ts @@ -19,5 +19,7 @@ export enum inputTypes { // A down-facing block stack. E.g. 'if-do' or 'else'. STATEMENT = ConnectionType.NEXT_STATEMENT, // A dummy input. Used to add field(s) with no input. - DUMMY = 5 + DUMMY = 5, + // An unknown type of input defined by an external developer. + CUSTOM = 6, } diff --git a/core/registry.ts b/core/registry.ts index 32abc14d7..a7839916d 100644 --- a/core/registry.ts +++ b/core/registry.ts @@ -13,6 +13,7 @@ import type {IBlockDragger} from './interfaces/i_block_dragger.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; +import type {Input} from './input.js'; import type {ISerializer} from './interfaces/i_serializer.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; import type {Cursor} from './keyboard_nav/cursor.js'; @@ -69,6 +70,8 @@ export class Type<_T> { static FIELD = new Type('field'); + static INPUT = new Type('input'); + static RENDERER = new Type('renderer'); static TOOLBOX = new Type('toolbox'); diff --git a/tests/mocha/block_json_test.js b/tests/mocha/block_json_test.js index b2b15f2e0..894e1dacc 100644 --- a/tests/mocha/block_json_test.js +++ b/tests/mocha/block_json_test.js @@ -7,9 +7,18 @@ goog.declareModuleId('Blockly.test.blockJson'); import {Align} from '../../build/src/core/input.js'; - +import {sharedTestSetup, sharedTestTeardown} from './test_helpers/setup_teardown.js'; suite('Block JSON initialization', function() { + setup(function() { + sharedTestSetup.call(this); + this.workspace = new Blockly.Workspace(); + }); + + teardown(function() { + sharedTestTeardown.call(this); + }); + suite('validateTokens_', function() { setup(function() { this.assertError = function(tokens, count, error) { @@ -434,21 +443,12 @@ suite('Block JSON initialization', function() { suite('inputFromJson_', function() { setup(function() { - const Input = function(type) { - this.type = type; - this.setCheck = sinon.fake(); - this.setAlign = sinon.fake(); - }; - const Block = function() { - this.type = 'test'; - this.appendDummyInput = sinon.fake.returns(new Input()); - this.appendValueInput = sinon.fake.returns(new Input()); - this.appendStatementInput = sinon.fake.returns(new Input()); - this.inputFromJson_ = Blockly.Block.prototype.inputFromJson_; - }; - this.assertInput = function(json, type, check, align) { - const block = new Block(); + const block = this.workspace.newBlock('test_basic_empty'); + sinon.spy(block, 'appendDummyInput'); + sinon.spy(block, 'appendValueInput'); + sinon.spy(block, 'appendStatementInput'); + const input = block.inputFromJson_(json); switch (type) { case 'input_dummy': @@ -474,126 +474,146 @@ suite('Block JSON initialization', function() { return; } if (check) { - chai.assert.isTrue(input.setCheck.calledWith(check), - 'Expected setCheck to be called with', check); - } else { - chai.assert.isTrue(input.setCheck.notCalled, - 'Expected setCheck to not be called'); + if (Array.isArray(check)) { + chai.assert.deepEqual(check, input.connection.getCheck()); + } else { + chai.assert.deepEqual([check], input.connection.getCheck()); + } } if (align !== undefined) { - chai.assert.isTrue(input.setAlign.calledWith(align), - 'Expected setAlign to be called with', align); - } else { - chai.assert.isTrue(input.setAlign.notCalled, - 'Expected setAlign to not be called'); + chai.assert.equal(align, input.align); } }; }); - test('Dummy', function() { - this.assertInput( - { - 'type': 'input_dummy', - }, - 'input_dummy'); + suite('input types', function() { + test('Dummy', function() { + this.assertInput( + { + 'type': 'input_dummy', + }, + 'input_dummy'); + }); + + test('Value', function() { + this.assertInput( + { + 'type': 'input_value', + 'name': 'NAME', + }, + 'input_value'); + }); + + test('Statement', function() { + this.assertInput( + { + 'type': 'input_statement', + 'name': 'NAME', + }, + 'input_statement'); + }); + + test('Bad input type', function() { + this.assertInput( + { + 'type': 'input_bad', + }, + 'input_bad'); + }); + + test('custom input types are constructed from the registry', function() { + class CustomInput extends Blockly.Input { } + Blockly.registry.register( + Blockly.registry.Type.INPUT, 'custom', CustomInput); + const block = this.workspace.newBlock('test_basic_empty'); + block.inputFromJson_({'type': 'custom'}); + chai.assert.instanceOf( + block.inputList[0], + CustomInput, + 'Expected the registered input to be constructed'); + }); }); - test('Value', function() { - this.assertInput( - { - 'type': 'input_value', - }, - 'input_value'); + suite('connection checks', function() { + test('String Check', function() { + this.assertInput( + { + 'type': 'input_value', + 'name': 'NAME', + 'check': 'Integer', + }, + 'input_value', + 'Integer'); + }); + + test('Array check', function() { + this.assertInput( + { + 'type': 'input_value', + 'name': 'NAME', + 'check': ['Integer', 'Number'], + }, + 'input_value', + ['Integer', 'Number']); + }); + + test('Empty check', function() { + this.assertInput( + { + 'type': 'input_value', + 'name': 'NAME', + 'check': '', + }, + 'input_value'); + }); + + test('Null check', function() { + this.assertInput( + { + 'type': 'input_value', + 'name': 'NAME', + 'check': null, + }, + 'input_value'); + }); }); - test('Statement', function() { - this.assertInput( - { - 'type': 'input_statement', - }, - 'input_statement'); - }); - - test('Bad input type', function() { - this.assertInput( - { - 'type': 'input_bad', - }, - 'input_bad'); - }); - - test('String Check', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'check': 'Integer', - }, - 'input_dummy', - 'Integer'); - }); - - test('Array check', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'check': ['Integer', 'Number'], - }, - 'input_dummy', - ['Integer', 'Number']); - }); - - test('Empty check', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'check': '', - }, - 'input_dummy'); - }); - - test('Null check', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'check': null, - }, - 'input_dummy'); - }); - - test('"Left" align', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'align': 'LEFT', - }, - 'input_dummy', undefined, Align.LEFT); - }); - - test('"Right" align', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'align': 'RIGHT', - }, - 'input_dummy', undefined, Align.RIGHT); - }); - - test('"Center" align', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'align': 'CENTER', - }, - 'input_dummy', undefined, Align.CENTRE); - }); - - test('"Centre" align', function() { - this.assertInput( - { - 'type': 'input_dummy', - 'align': 'CENTRE', - }, - 'input_dummy', undefined, Align.CENTRE); + suite('alignment', function() { + test('"Left" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'LEFT', + }, + 'input_dummy', undefined, Align.LEFT); + }); + + test('"Right" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'RIGHT', + }, + 'input_dummy', undefined, Align.RIGHT); + }); + + test('"Center" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'CENTER', + }, + 'input_dummy', undefined, Align.CENTRE); + }); + + test('"Centre" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'CENTRE', + }, + 'input_dummy', undefined, Align.CENTRE); + }); }); }); });