feat: add support for appending custom inputs (#6990)

* feat: add appendInput method

* feat: enable constructing inputs from the registry

* chore: reorganize into suites

* chore: add new input test + fixup existing

* chore: reorganize appending from registry

* chore: fix input types enum

* chore: fix tests
This commit is contained in:
Beka Westberg
2023-04-21 15:58:42 -07:00
committed by GitHub
parent 35276e9468
commit d726080eaa
5 changed files with 191 additions and 132 deletions

View File

@@ -41,6 +41,7 @@ import * as arrayUtils from './utils/array.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
import * as idGenerator from './utils/idgenerator.js'; import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js'; import * as parsing from './utils/parsing.js';
import * as registry from './registry.js';
import {Size} from './utils/size.js'; import {Size} from './utils/size.js';
import type {VariableModel} from './variable_model.js'; import type {VariableModel} from './variable_model.js';
import type {Workspace} from './workspace.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 * @param name Language-neutral identifier which may used to find this input
* again. Should be unique to this block. * 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 * @param name Language-neutral identifier which may used to find this input
* again. Should be unique to this block. * 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 * @param opt_name Language-neutral identifier which may used to find this
* input again. Should be unique to this block. * 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 || ''); 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 * Initialize this block using a cross-platform, internationalization-friendly
* JSON description. * JSON description.
@@ -1850,6 +1878,10 @@ export class Block implements IASTNodeLocation, IDeletable {
case 'input_dummy': case 'input_dummy':
input = this.appendDummyInput(element['name']); input = this.appendDummyInput(element['name']);
break; break;
default: {
input = this.appendInputFromRegistry(element['type'], element['name']);
break;
}
} }
// Should never be hit because of interpolate_'s checks, but just in case. // Should never be hit because of interpolate_'s checks, but just in case.
if (!input) { if (!input) {
@@ -1881,7 +1913,7 @@ export class Block implements IASTNodeLocation, IDeletable {
*/ */
private isInputKeyword_(str: string): boolean { private isInputKeyword_(str: string): boolean {
return str === 'input_value' || str === 'input_statement' || return str === 'input_value' || str === 'input_statement' ||
str === 'input_dummy'; str === 'input_dummy' || registry.hasItem(registry.Type.INPUT, str);
} }
/** /**

View File

@@ -41,12 +41,14 @@ export class Input {
* @param name Language-neutral identifier which may used to find this input * @param name Language-neutral identifier which may used to find this input
* again. * again.
* @param block The block containing this input. * @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( constructor(
public type: number, public name: string, block: Block, public type: number, public name: string, block: Block,
public connection: Connection|null) { public connection: Connection|null) {
if (type !== inputTypes.DUMMY && !name) { if ((type === inputTypes.VALUE || type === inputTypes.STATEMENT) && !name) {
throw Error( throw Error(
'Value inputs and statement inputs must have non-empty name.'); 'Value inputs and statement inputs must have non-empty name.');
} }

View File

@@ -19,5 +19,7 @@ export enum inputTypes {
// A down-facing block stack. E.g. 'if-do' or 'else'. // A down-facing block stack. E.g. 'if-do' or 'else'.
STATEMENT = ConnectionType.NEXT_STATEMENT, STATEMENT = ConnectionType.NEXT_STATEMENT,
// A dummy input. Used to add field(s) with no input. // 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,
} }

View File

@@ -13,6 +13,7 @@ import type {IBlockDragger} from './interfaces/i_block_dragger.js';
import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyout} from './interfaces/i_flyout.js';
import type {IMetricsManager} from './interfaces/i_metrics_manager.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 {ISerializer} from './interfaces/i_serializer.js';
import type {IToolbox} from './interfaces/i_toolbox.js'; import type {IToolbox} from './interfaces/i_toolbox.js';
import type {Cursor} from './keyboard_nav/cursor.js'; import type {Cursor} from './keyboard_nav/cursor.js';
@@ -69,6 +70,8 @@ export class Type<_T> {
static FIELD = new Type<Field>('field'); static FIELD = new Type<Field>('field');
static INPUT = new Type<Input>('input');
static RENDERER = new Type<Renderer>('renderer'); static RENDERER = new Type<Renderer>('renderer');
static TOOLBOX = new Type<IToolbox>('toolbox'); static TOOLBOX = new Type<IToolbox>('toolbox');

View File

@@ -7,9 +7,18 @@
goog.declareModuleId('Blockly.test.blockJson'); goog.declareModuleId('Blockly.test.blockJson');
import {Align} from '../../build/src/core/input.js'; import {Align} from '../../build/src/core/input.js';
import {sharedTestSetup, sharedTestTeardown} from './test_helpers/setup_teardown.js';
suite('Block JSON initialization', function() { suite('Block JSON initialization', function() {
setup(function() {
sharedTestSetup.call(this);
this.workspace = new Blockly.Workspace();
});
teardown(function() {
sharedTestTeardown.call(this);
});
suite('validateTokens_', function() { suite('validateTokens_', function() {
setup(function() { setup(function() {
this.assertError = function(tokens, count, error) { this.assertError = function(tokens, count, error) {
@@ -434,21 +443,12 @@ suite('Block JSON initialization', function() {
suite('inputFromJson_', function() { suite('inputFromJson_', function() {
setup(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) { 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); const input = block.inputFromJson_(json);
switch (type) { switch (type) {
case 'input_dummy': case 'input_dummy':
@@ -474,126 +474,146 @@ suite('Block JSON initialization', function() {
return; return;
} }
if (check) { if (check) {
chai.assert.isTrue(input.setCheck.calledWith(check), if (Array.isArray(check)) {
'Expected setCheck to be called with', check); chai.assert.deepEqual(check, input.connection.getCheck());
} else { } else {
chai.assert.isTrue(input.setCheck.notCalled, chai.assert.deepEqual([check], input.connection.getCheck());
'Expected setCheck to not be called'); }
} }
if (align !== undefined) { if (align !== undefined) {
chai.assert.isTrue(input.setAlign.calledWith(align), chai.assert.equal(align, input.align);
'Expected setAlign to be called with', align);
} else {
chai.assert.isTrue(input.setAlign.notCalled,
'Expected setAlign to not be called');
} }
}; };
}); });
test('Dummy', function() { suite('input types', function() {
this.assertInput( test('Dummy', function() {
{ this.assertInput(
'type': 'input_dummy', {
}, 'type': 'input_dummy',
'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() { suite('connection checks', function() {
this.assertInput( test('String Check', function() {
{ this.assertInput(
'type': 'input_value', {
}, 'type': 'input_value',
'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() { suite('alignment', function() {
this.assertInput( test('"Left" align', function() {
{ this.assertInput(
'type': 'input_statement', {
}, 'type': 'input_dummy',
'input_statement'); 'align': 'LEFT',
}); },
'input_dummy', undefined, Align.LEFT);
test('Bad input type', function() { });
this.assertInput(
{ test('"Right" align', function() {
'type': 'input_bad', this.assertInput(
}, {
'input_bad'); 'type': 'input_dummy',
}); 'align': 'RIGHT',
},
test('String Check', function() { 'input_dummy', undefined, Align.RIGHT);
this.assertInput( });
{
'type': 'input_dummy', test('"Center" align', function() {
'check': 'Integer', this.assertInput(
}, {
'input_dummy', 'type': 'input_dummy',
'Integer'); 'align': 'CENTER',
}); },
'input_dummy', undefined, Align.CENTRE);
test('Array check', function() { });
this.assertInput(
{ test('"Centre" align', function() {
'type': 'input_dummy', this.assertInput(
'check': ['Integer', 'Number'], {
}, 'type': 'input_dummy',
'input_dummy', 'align': 'CENTRE',
['Integer', 'Number']); },
}); 'input_dummy', undefined, Align.CENTRE);
});
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);
}); });
}); });
}); });