/** * @license * Copyright 2012 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Variable input field. */ 'use strict'; /** * Variable input field. * @class */ goog.module('Blockly.FieldVariable'); const FieldDropdown = goog.require('Blockly.FieldDropdown'); /* eslint-disable-next-line no-unused-vars */ const Menu = goog.requireType('Blockly.Menu'); /* eslint-disable-next-line no-unused-vars */ const MenuItem = goog.requireType('Blockly.MenuItem'); const Msg = goog.require('Blockly.Msg'); const Size = goog.require('Blockly.utils.Size'); const VariableModel = goog.require('Blockly.VariableModel'); const Variables = goog.require('Blockly.Variables'); const Xml = goog.require('Blockly.Xml'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); const internalConstants = goog.require('Blockly.internalConstants'); const object = goog.require('Blockly.utils.object'); const utils = goog.require('Blockly.utils'); /* eslint-disable-next-line no-unused-vars */ const {Block} = goog.requireType('Blockly.Block'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockChange'); /** * Class for a variable's dropdown field. * @param {?string} varName The default name for the variable. If null, * a unique variable name will be generated. * @param {Function=} opt_validator A function that is called to validate * changes to the field's value. Takes in a variable ID & returns a * validated variable ID, or null to abort the change. * @param {Array=} opt_variableTypes A list of the types of variables * to include in the dropdown. * @param {string=} opt_defaultType The type of variable to create if this * field's value is not explicitly set. Defaults to ''. * @param {Object=} opt_config A map of options used to configure the field. * See the [field creation documentation]{@link * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation} * for a list of properties this parameter supports. * @extends {FieldDropdown} * @constructor * @alias Blockly.FieldVariable */ const FieldVariable = function( varName, opt_validator, opt_variableTypes, opt_defaultType, opt_config) { // The FieldDropdown constructor expects the field's initial value to be // the first entry in the menu generator, which it may or may not be. // Just do the relevant parts of the constructor. /** * An array of options for a dropdown list, * or a function which generates these options. * @type {(!Array| * !function(this:FieldDropdown): !Array)} * @protected */ this.menuGenerator_ = FieldVariable.dropdownCreate; /** * The initial variable name passed to this field's constructor, or an * empty string if a name wasn't provided. Used to create the initial * variable. * @type {string} */ this.defaultVariableName = typeof varName === 'string' ? varName : ''; /** * The size of the area rendered by the field. * @type {Size} * @protected * @override */ this.size_ = new Size(0, 0); opt_config && this.configure_(opt_config); opt_validator && this.setValidator(opt_validator); if (!opt_config) { // Only do one kind of configuration or the other. this.setTypes_(opt_variableTypes, opt_defaultType); } }; object.inherits(FieldVariable, FieldDropdown); /** * Construct a FieldVariable from a JSON arg object, * dereferencing any string table references. * @param {!Object} options A JSON object with options (variable, * variableTypes, and defaultType). * @return {!FieldVariable} The new field instance. * @package * @nocollapse */ FieldVariable.fromJson = function(options) { const varName = utils.replaceMessageReferences(options['variable']); // `this` might be a subclass of FieldVariable if that class doesn't override // the static fromJson method. return new this(varName, undefined, undefined, undefined, options); }; /** * Serializable fields are saved by the XML renderer, non-serializable fields * are not. Editable fields should also be serializable. * @type {boolean} */ FieldVariable.prototype.SERIALIZABLE = true; /** * Configure the field based on the given map of options. * @param {!Object} config A map of options to configure the field based on. * @protected */ FieldVariable.prototype.configure_ = function(config) { FieldVariable.superClass_.configure_.call(this, config); this.setTypes_(config['variableTypes'], config['defaultType']); }; /** * Initialize the model for this field if it has not already been initialized. * If the value has not been set to a variable by the first render, we make up a * variable rather than let the value be invalid. * @package */ FieldVariable.prototype.initModel = function() { if (this.variable_) { return; // Initialization already happened. } const variable = Variables.getOrCreateVariablePackage( this.sourceBlock_.workspace, null, this.defaultVariableName, this.defaultType_); // Don't call setValue because we don't want to cause a rerender. this.doValueUpdate_(variable.getId()); }; /** * @override */ FieldVariable.prototype.shouldAddBorderRect_ = function() { return FieldVariable.superClass_.shouldAddBorderRect_.call(this) && (!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || this.sourceBlock_.type !== 'variables_get'); }; /** * Initialize this field based on the given XML. * @param {!Element} fieldElement The element containing information about the * variable field's state. */ FieldVariable.prototype.fromXml = function(fieldElement) { const id = fieldElement.getAttribute('id'); const variableName = fieldElement.textContent; // 'variabletype' should be lowercase, but until July 2019 it was sometimes // recorded as 'variableType'. Thus we need to check for both. const variableType = fieldElement.getAttribute('variabletype') || fieldElement.getAttribute('variableType') || ''; const variable = Variables.getOrCreateVariablePackage( this.sourceBlock_.workspace, id, variableName, variableType); // This should never happen :) if (variableType !== null && variableType !== variable.type) { throw Error( 'Serialized variable type with id \'' + variable.getId() + '\' had type ' + variable.type + ', and ' + 'does not match variable field that references it: ' + Xml.domToText(fieldElement) + '.'); } this.setValue(variable.getId()); }; /** * Serialize this field to XML. * @param {!Element} fieldElement The element to populate with info about the * field's state. * @return {!Element} The element containing info about the field's state. */ FieldVariable.prototype.toXml = function(fieldElement) { // Make sure the variable is initialized. this.initModel(); fieldElement.id = this.variable_.getId(); fieldElement.textContent = this.variable_.name; if (this.variable_.type) { fieldElement.setAttribute('variabletype', this.variable_.type); } return fieldElement; }; /** * Saves this field's value. * @param {boolean=} doFullSerialization If true, the variable field will * serialize the full state of the field being referenced (ie ID, name, * and type) rather than just a reference to it (ie ID). * @return {*} The state of the variable field. * @override * @package */ FieldVariable.prototype.saveState = function(doFullSerialization) { const legacyState = this.saveLegacyState(FieldVariable); if (legacyState !== null) { return legacyState; } // Make sure the variable is initialized. this.initModel(); const state = {'id': this.variable_.getId()}; if (doFullSerialization) { state['name'] = this.variable_.name; state['type'] = this.variable_.type; } return state; }; /** * Sets the field's value based on the given state. * @param {*} state The state of the variable to assign to this variable field. * @override * @package */ FieldVariable.prototype.loadState = function(state) { if (this.loadLegacyState(FieldVariable, state)) { return; } // This is necessary so that blocks in the flyout can have custom var names. const variable = Variables.getOrCreateVariablePackage( this.sourceBlock_.workspace, state['id'] || null, state['name'], state['type'] || ''); this.setValue(variable.getId()); }; /** * Attach this field to a block. * @param {!Block} block The block containing this field. */ FieldVariable.prototype.setSourceBlock = function(block) { if (block.isShadow()) { throw Error('Variable fields are not allowed to exist on shadow blocks.'); } FieldVariable.superClass_.setSourceBlock.call(this, block); }; /** * Get the variable's ID. * @return {string} Current variable's ID. */ FieldVariable.prototype.getValue = function() { return this.variable_ ? this.variable_.getId() : null; }; /** * Get the text from this field, which is the selected variable's name. * @return {string} The selected variable's name, or the empty string if no * variable is selected. */ FieldVariable.prototype.getText = function() { return this.variable_ ? this.variable_.name : ''; }; /** * Get the variable model for the selected variable. * Not guaranteed to be in the variable map on the workspace (e.g. if accessed * after the variable has been deleted). * @return {?VariableModel} The selected variable, or null if none was * selected. * @package */ FieldVariable.prototype.getVariable = function() { return this.variable_; }; /** * Gets the validation function for this field, or null if not set. * Returns null if the variable is not set, because validators should not * run on the initial setValue call, because the field won't be attached to * a block and workspace at that point. * @return {?Function} Validation function, or null. */ FieldVariable.prototype.getValidator = function() { // Validators shouldn't operate on the initial setValue call. // Normally this is achieved by calling setValidator after setValue, but // this is not a possibility with variable fields. if (this.variable_) { return this.validator_; } return null; }; /** * Ensure that the ID belongs to a valid variable of an allowed type. * @param {*=} opt_newValue The ID of the new variable to set. * @return {?string} The validated ID, or null if invalid. * @protected */ FieldVariable.prototype.doClassValidation_ = function(opt_newValue) { if (opt_newValue === null) { return null; } const newId = /** @type {string} */ (opt_newValue); const variable = Variables.getVariable(this.sourceBlock_.workspace, newId); if (!variable) { console.warn( 'Variable id doesn\'t point to a real variable! ' + 'ID was ' + newId); return null; } // Type Checks. const type = variable.type; if (!this.typeIsAllowed_(type)) { console.warn('Variable type doesn\'t match this field! Type was ' + type); return null; } return newId; }; /** * Update the value of this variable field, as well as its variable and text. * * The variable ID should be valid at this point, but if a variable field * validator returns a bad ID, this could break. * @param {*} newId The value to be saved. * @protected */ FieldVariable.prototype.doValueUpdate_ = function(newId) { this.variable_ = Variables.getVariable( this.sourceBlock_.workspace, /** @type {string} */ (newId)); FieldVariable.superClass_.doValueUpdate_.call(this, newId); }; /** * Check whether the given variable type is allowed on this field. * @param {string} type The type to check. * @return {boolean} True if the type is in the list of allowed types. * @private */ FieldVariable.prototype.typeIsAllowed_ = function(type) { const typeList = this.getVariableTypes_(); if (!typeList) { return true; // If it's null, all types are valid. } for (let i = 0; i < typeList.length; i++) { if (type === typeList[i]) { return true; } } return false; }; /** * Return a list of variable types to include in the dropdown. * @return {!Array} Array of variable types. * @throws {Error} if variableTypes is an empty array. * @private */ FieldVariable.prototype.getVariableTypes_ = function() { // TODO (#1513): Try to avoid calling this every time the field is edited. let variableTypes = this.variableTypes; if (variableTypes === null) { // If variableTypes is null, return all variable types. if (this.sourceBlock_ && this.sourceBlock_.workspace) { return this.sourceBlock_.workspace.getVariableTypes(); } } variableTypes = variableTypes || ['']; if (variableTypes.length === 0) { // Throw an error if variableTypes is an empty list. const name = this.getText(); throw Error( '\'variableTypes\' of field variable ' + name + ' was an empty list'); } return variableTypes; }; /** * Parse the optional arguments representing the allowed variable types and the * default variable type. * @param {Array=} opt_variableTypes A list of the types of variables * to include in the dropdown. If null or undefined, variables of all types * will be displayed in the dropdown. * @param {string=} opt_defaultType The type of the variable to create if this * field's value is not explicitly set. Defaults to ''. * @private */ FieldVariable.prototype.setTypes_ = function( opt_variableTypes, opt_defaultType) { // If you expected that the default type would be the same as the only entry // in the variable types array, tell the Blockly team by commenting on #1499. const defaultType = opt_defaultType || ''; let variableTypes; // Set the allowable variable types. Null means all types on the workspace. if (opt_variableTypes === null || opt_variableTypes === undefined) { variableTypes = null; } else if (Array.isArray(opt_variableTypes)) { variableTypes = opt_variableTypes; // Make sure the default type is valid. let isInArray = false; for (let i = 0; i < variableTypes.length; i++) { if (variableTypes[i] === defaultType) { isInArray = true; } } if (!isInArray) { throw Error( 'Invalid default type \'' + defaultType + '\' in ' + 'the definition of a FieldVariable'); } } else { throw Error( '\'variableTypes\' was not an array in the definition of ' + 'a FieldVariable'); } // Only update the field once all checks pass. this.defaultType_ = defaultType; this.variableTypes = variableTypes; }; /** * Refreshes the name of the variable by grabbing the name of the model. * Used when a variable gets renamed, but the ID stays the same. Should only * be called by the block. * @package */ FieldVariable.prototype.refreshVariableName = function() { this.forceRerender(); }; /** * Return a sorted list of variable names for variable dropdown menus. * Include a special option at the end for creating a new variable name. * @return {!Array} Array of variable names/id tuples. * @this {FieldVariable} */ FieldVariable.dropdownCreate = function() { if (!this.variable_) { throw Error( 'Tried to call dropdownCreate on a variable field with no' + ' variable selected.'); } const name = this.getText(); let variableModelList = []; if (this.sourceBlock_ && this.sourceBlock_.workspace) { const variableTypes = this.getVariableTypes_(); // Get a copy of the list, so that adding rename and new variable options // doesn't modify the workspace's list. for (let i = 0; i < variableTypes.length; i++) { const variableType = variableTypes[i]; const variables = this.sourceBlock_.workspace.getVariablesOfType(variableType); variableModelList = variableModelList.concat(variables); } } variableModelList.sort(VariableModel.compareByName); const options = []; for (let i = 0; i < variableModelList.length; i++) { // Set the UUID as the internal representation of the variable. options[i] = [variableModelList[i].name, variableModelList[i].getId()]; } options.push([Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID]); if (Msg['DELETE_VARIABLE']) { options.push([ Msg['DELETE_VARIABLE'].replace('%1', name), internalConstants.DELETE_VARIABLE_ID ]); } return options; }; /** * Handle the selection of an item in the variable dropdown menu. * Special case the 'Rename variable...' and 'Delete variable...' options. * In the rename case, prompt the user for a new name. * @param {!Menu} menu The Menu component clicked. * @param {!MenuItem} menuItem The MenuItem selected within menu. * @protected */ FieldVariable.prototype.onItemSelected_ = function(menu, menuItem) { const id = menuItem.getValue(); // Handle special cases. if (this.sourceBlock_ && this.sourceBlock_.workspace) { if (id === internalConstants.RENAME_VARIABLE_ID) { // Rename variable. Variables.renameVariable(this.sourceBlock_.workspace, this.variable_); return; } else if (id === internalConstants.DELETE_VARIABLE_ID) { // Delete variable. this.sourceBlock_.workspace.deleteVariableById(this.variable_.getId()); return; } } // Handle unspecial case. this.setValue(id); }; /** * Overrides referencesVariables(), indicating this field refers to a variable. * @return {boolean} True. * @package * @override */ FieldVariable.prototype.referencesVariables = function() { return true; }; fieldRegistry.register('field_variable', FieldVariable); exports = FieldVariable;