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 * 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);
}
/**

View File

@@ -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.');
}

View File

@@ -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,
}

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 {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>('field');
static INPUT = new Type<Input>('input');
static RENDERER = new Type<Renderer>('renderer');
static TOOLBOX = new Type<IToolbox>('toolbox');

View File

@@ -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);
});
});
});
});