diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 1fe29609ba6..e778a82b5a8 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -671,5 +671,10 @@ "WORKSPACE_SEARCH_FIND_PREVIOUS": "Find previous", "WORKSPACE_SEARCH_CLOSE": "Close search bar", "WORKSPACE_SEARCH_NO_MATCHES": "No matching blocks", - "WORKSPACE_SEARCH_MATCH": "Match %1 of %2: %3" + "WORKSPACE_SEARCH_MATCH": "Match %1 of %2: %3", + "TOOLBOX_SEARCH_PLACEHOLDER": "Search for blocks", + "TOOLBOX_SEARCH_PROMPT": "Type to search for blocks", + "TOOLBOX_SEARCH_NO_RESULTS": "No matching blocks found", + "TOOLBOX_SEARCH_RESULT_COUNT_ONE": "1 matching block", + "TOOLBOX_SEARCH_RESULT_COUNT": "%1 matching blocks" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 320dba23d35..7a9770ce39d 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -676,5 +676,10 @@ "WORKSPACE_SEARCH_FIND_PREVIOUS": "ARIA label for the workspace search button that selects the previous matching block.", "WORKSPACE_SEARCH_CLOSE": "ARIA label for the button that closes the workspace search bar.", "WORKSPACE_SEARCH_NO_MATCHES": "ARIA live region message announced when workspace search finds no matching blocks.", - "WORKSPACE_SEARCH_MATCH": "ARIA live region message announcing the currently highlighted workspace search match. \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block \n\nExamples:\n* 'Match 1 of 3: print, hello'" + "WORKSPACE_SEARCH_MATCH": "ARIA live region message announcing the currently highlighted workspace search match. \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block \n\nExamples:\n* 'Match 1 of 3: print, hello'", + "TOOLBOX_SEARCH_PLACEHOLDER": "Placeholder text in the toolbox search category's input field.", + "TOOLBOX_SEARCH_PROMPT": "Message shown in the toolbox search flyout before a query has been entered.", + "TOOLBOX_SEARCH_NO_RESULTS": "Message shown in the toolbox search flyout when a query matches no blocks.", + "TOOLBOX_SEARCH_RESULT_COUNT_ONE": "Screen reader announcement when a toolbox search matches exactly one block.", + "TOOLBOX_SEARCH_RESULT_COUNT": "Screen reader announcement of how many blocks a toolbox search matched. \n\nParameters:\n* %1 - a number of blocks, which may be zero or greater than one." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 068eda42a32..2529f5def0c 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -2533,4 +2533,20 @@ Blockly.Msg.WORKSPACE_SEARCH_NO_MATCHES = 'No matching blocks'; /// ARIA live region message announcing the currently highlighted workspace search match. /// \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block /// \n\nExamples:\n* "Match 1 of 3: print, hello" -Blockly.Msg.WORKSPACE_SEARCH_MATCH = 'Match %1 of %2: %3'; \ No newline at end of file +Blockly.Msg.WORKSPACE_SEARCH_MATCH = 'Match %1 of %2: %3'; +/** @type {string} */ +/// Placeholder text in the toolbox search category's input field. +Blockly.Msg.TOOLBOX_SEARCH_PLACEHOLDER = 'Search for blocks'; +/** @type {string} */ +/// Message shown in the toolbox search flyout before a query has been entered. +Blockly.Msg.TOOLBOX_SEARCH_PROMPT = 'Type to search for blocks'; +/** @type {string} */ +/// Message shown in the toolbox search flyout when a query matches no blocks. +Blockly.Msg.TOOLBOX_SEARCH_NO_RESULTS = 'No matching blocks found'; +/** @type {string} */ +/// Screen reader announcement when a toolbox search matches exactly one block. +Blockly.Msg.TOOLBOX_SEARCH_RESULT_COUNT_ONE = '1 matching block'; +/** @type {string} */ +/// Screen reader announcement of how many blocks a toolbox search matched. +/// \n\nParameters:\n* %1 - a number of blocks, which may be zero or greater than one. +Blockly.Msg.TOOLBOX_SEARCH_RESULT_COUNT = '%1 matching blocks'; \ No newline at end of file diff --git a/packages/plugins/toolbox-search/package.json b/packages/plugins/toolbox-search/package.json index 88e551896d3..3d703e92c05 100644 --- a/packages/plugins/toolbox-search/package.json +++ b/packages/plugins/toolbox-search/package.json @@ -43,6 +43,9 @@ "@blockly/dev-scripts": "^13.1.0", "@blockly/dev-tools": "^13.1.0", "chai": "^6.2.2", + "jsdom": "^30.0.1", + "jsdom-global": "^3.0.2", + "sinon": "^22.1.0", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/plugins/toolbox-search/src/block_searcher.ts b/packages/plugins/toolbox-search/src/block_searcher.ts index 005974b730b..b1cf9301078 100644 --- a/packages/plugins/toolbox-search/src/block_searcher.ts +++ b/packages/plugins/toolbox-search/src/block_searcher.ts @@ -6,6 +6,13 @@ import * as Blockly from 'blockly/core'; +interface DropdownOption { + fieldName: string; + label: string; + value: string; + selected: boolean; +} + /** * A class that provides methods for indexing and searching blocks. */ @@ -15,6 +22,23 @@ export class BlockSearcher { Set >(); + // A map of blocks to the text that was indexed for them, used to filter + // the results of a search to only those blocks that contain the search term. + private blockText = new Map(); + // A map of blocks to the options of their dropdown fields, used to generate + // variants of blocks with different dropdown values. + private dropdownOptions = new Map< + Blockly.utils.toolbox.BlockInfo, + DropdownOption[] + >(); + // A map of blocks to the names of their variable fields, used to generate + // variants of blocks with different variable values. + private variableFields = new Map(); + // All workspace variables, sorted by name, updated when blocks are indexed. + private workspaceVariables: Array< + Blockly.IVariableModel + > = []; + /** * Populates the cached map of trigrams to the blocks they correspond to. * @@ -24,21 +48,76 @@ export class BlockSearcher { * itself. * * @param blockInfos A list of blocks to index. + * @param workspace The workspace source of truth for variables. This is + * used to index variable names and update variable fields. */ - indexBlocks(blockInfos: Blockly.utils.toolbox.BlockInfo[]) { + indexBlocks( + blockInfos: Blockly.utils.toolbox.BlockInfo[], + workspace: Blockly.Workspace, + ) { + this.blockText.clear(); + this.dropdownOptions.clear(); + this.trigramsToBlocks.clear(); + this.variableFields.clear(); + this.workspaceVariables = workspace + .getVariableMap() + .getAllVariables() + .sort(Blockly.Variables.compareByName); + const blockCreationWorkspace = new Blockly.Workspace(); blockInfos.forEach((blockInfo) => { const type = blockInfo.type; if (!type || type === '') return; - const block = blockCreationWorkspace.newBlock(type); + blockCreationWorkspace.clear(); + this.workspaceVariables.forEach((variable) => + blockCreationWorkspace + .getVariableMap() + .createVariable(variable.getName(), variable.getType()), + ); + const block = Blockly.serialization.blocks.append( + blockInfo as Blockly.serialization.blocks.State, + blockCreationWorkspace, + ); this.indexBlockText(type.replaceAll('_', ' '), blockInfo); - block.inputList.forEach((input) => { - input.fieldRow.forEach((field) => { - this.indexDropdownOption(field, blockInfo); - this.indexBlockText(field.getText(), blockInfo); + const variableFieldNames: string[] = []; + + // Index the text of every field on the block and its descendants, and record + // the names of any variable fields for later use in generating variants. + block.getDescendants(false).forEach((descendantBlock) => { + descendantBlock.inputList.forEach((input) => { + input.fieldRow.forEach((field) => { + if (field instanceof Blockly.FieldVariable) { + this.indexBlockText(field.getText(), blockInfo); + // If the current variable is one of the workspace variables, record + // the field name for later use in generating variants. + if ( + descendantBlock === block && + field.name && + this.workspaceVariables.some( + (v) => v.getName() === field.getText(), + ) + ) { + variableFieldNames.push(field.name); + } + } else { + // Index the text of the dropdown option and the block. + this.indexDropdownOption(field, blockInfo); + this.indexBlockText(field.getText(), blockInfo); + } + }); }); }); + if (variableFieldNames.length) { + // Index all workspace variable names for the block, so that a search for any of them + // will return the block, and record the names of the variable fields for later use + // in generating variants. + this.variableFields.set(blockInfo, variableFieldNames); + this.workspaceVariables.forEach((variable) => { + this.indexBlockText(variable.getName(), blockInfo); + }); + } }); + blockCreationWorkspace.dispose(); } /** @@ -51,15 +130,49 @@ export class BlockSearcher { field: Blockly.Field, block: Blockly.utils.toolbox.BlockInfo, ) { - if (field instanceof Blockly.FieldDropdown) { - field.getOptions(true).forEach((option) => { - if (typeof option[0] === 'string') { - this.indexBlockText(option[0], block); - } else if ('alt' in option[0]) { - this.indexBlockText(option[0].alt, block); - } - }); + if (!(field instanceof Blockly.FieldDropdown)) { + return; } + field.getOptions(true).forEach(([label, value]) => { + const text = + typeof label === 'string' + ? label + : label && 'alt' in label + ? label.alt + : ''; + if (!text) return; + this.indexBlockText(text, block); + if (!field.name || typeof value !== 'string') return; + const options = this.dropdownOptions.get(block) ?? []; + options.push({ + fieldName: field.name, + label: text.toLowerCase(), + value, + selected: value === field.getValue(), + }); + this.dropdownOptions.set(block, options); + }); + } + + /** + * Returns a list of variants of the given block with different dropdown values + * + * @param info The block to vary. + * @param options The options whose labels matched the query. + * @returns One block per matching option. + */ + private createMatchingBlockVariants( + info: Blockly.utils.toolbox.BlockInfo, + options: Array<{fieldName: string; value: string; selected: boolean}>, + ): Blockly.utils.toolbox.BlockInfo[] { + if (!options.length) return [info]; + // One variant per matching option, each differing in a single field, so + // two matching dropdowns give two results rather than four. + return options.map((option) => + option.selected + ? info + : {...info, fields: {...info.fields, [option.fieldName]: option.value}}, + ); } /** @@ -69,7 +182,7 @@ export class BlockSearcher { * @returns A list of blocks matching the query. */ blockTypesMatching(query: string): Blockly.utils.toolbox.BlockInfo[] { - return [ + const candidates = [ ...this.generateTrigrams(query) .map((trigram) => { return ( @@ -82,6 +195,47 @@ export class BlockSearcher { }) .values(), ]; + + const searchTerm = query.toLowerCase(); + const matches = candidates.filter((block) => + this.blockText.get(block)?.some((text) => text.includes(searchTerm)), + ); + + const matchedVariables = this.workspaceVariables.filter((v) => + v.getName().toLowerCase().includes(searchTerm), + ); + // The flyout creates one getter per variable, and they all collapse onto + // the same block once bound, so results are keyed by content. + const results = new Map(); + for (const match of matches) { + const variableFieldNames = this.variableFields.get(match); + const bound = + variableFieldNames && matchedVariables.length + ? matchedVariables.map((variable) => ({ + ...match, + fields: { + ...match.fields, + ...Object.fromEntries( + variableFieldNames.map((name) => [ + name, + {name: variable.getName(), type: variable.getType()}, + ]), + ), + }, + })) + : [match]; + + const options = (this.dropdownOptions.get(match) ?? []).filter((option) => + option.label.includes(searchTerm), + ); + + for (const info of bound) { + for (const variant of this.createMatchingBlockVariants(info, options)) { + results.set(JSON.stringify(variant), variant); + } + } + } + return [...results.values()]; } /** @@ -92,6 +246,9 @@ export class BlockSearcher { * @param block The block to associate the trigrams with. */ private indexBlockText(text: string, block: Blockly.utils.toolbox.BlockInfo) { + const texts = this.blockText.get(block) ?? []; + texts.push(text.toLowerCase()); + this.blockText.set(block, texts); this.generateTrigrams(text).forEach((trigram) => { const blockSet = this.trigramsToBlocks.get(trigram) ?? @@ -116,7 +273,6 @@ export class BlockSearcher { for (let start = 0; start <= normalizedInput.length - 3; start++) { trigrams.push(normalizedInput.substring(start, start + 3)); } - return trigrams; } diff --git a/packages/plugins/toolbox-search/src/toolbox_search.ts b/packages/plugins/toolbox-search/src/toolbox_search.ts index 25e4ba43fe0..c64dd8a8e71 100644 --- a/packages/plugins/toolbox-search/src/toolbox_search.ts +++ b/packages/plugins/toolbox-search/src/toolbox_search.ts @@ -23,6 +23,9 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { private readonly SEARCH_INPUT_ID = 'toolbox-search-input'; private searchField?: HTMLInputElement; private blockSearcher = new BlockSearcher(); + private onChangeWrapper?: (event: Blockly.Events.Abstract) => void; + private indexedBlocks = ''; + private boundEvents: Blockly.browserEvents.Data[] = []; /** * Initializes a ToolboxSearchCategory. @@ -41,6 +44,8 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { super(categoryDef, parentToolbox, opt_parent); this.initBlockSearcher(); this.registerShortcut(); + this.onChangeWrapper = this.handleWorkspaceChange.bind(this); + this.workspace_.addChangeListener(this.onChangeWrapper); } /** @@ -53,44 +58,63 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { this.searchField = document.createElement('input'); this.searchField.id = this.SEARCH_INPUT_ID; this.searchField.type = 'search'; - this.searchField.placeholder = 'Search for blocks'; + this.searchField.placeholder = Blockly.Msg['TOOLBOX_SEARCH_PLACEHOLDER']; this.workspace_.RTL ? (this.searchField.style.marginRight = '8px') : (this.searchField.style.marginLeft = '8px'); - this.searchField.addEventListener('keydown', (event) => { - if (event.key === 'ArrowUp' && this.searchField?.selectionStart === 0) { - const previous = this.parentToolbox_.getNavigator().getPreviousNode(); - if (previous) { - Blockly.getFocusManager().focusNode(previous); - } - return; - } else if ( - event.key === 'ArrowRight' && - this.searchField?.selectionStart === this.searchField?.value.length - ) { - const previous = this.parentToolbox_.getNavigator().getInNode(); - if (previous) { - Blockly.getFocusManager().focusNode(previous); - } - return; - } else if ( - event.key === 'ArrowDown' && - this.searchField?.selectionStart === this.searchField?.value.length - ) { - const next = this.parentToolbox_.getNavigator().getNextNode(); - if (next) { - Blockly.getFocusManager().focusNode(next); - } - return; - } else if (event.key === 'Escape' && this.searchField) { - if (this.searchField.value !== '') { - this.searchField.value = ''; - event.stopPropagation(); - } - } - - this.matchBlocks(); - }); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + this.searchField, + 'keydown', + this, + (event: KeyboardEvent) => { + if ( + event.key === 'ArrowUp' && + this.searchField?.selectionStart === 0 + ) { + const previous = this.parentToolbox_ + .getNavigator() + .getPreviousNode(); + if (previous) { + Blockly.getFocusManager().focusNode(previous); + } + return; + } else if ( + event.key === 'ArrowRight' && + this.searchField?.selectionStart === this.searchField?.value.length + ) { + const previous = this.parentToolbox_.getNavigator().getInNode(); + if (previous) { + Blockly.getFocusManager().focusNode(previous); + } + return; + } else if ( + event.key === 'ArrowDown' && + this.searchField?.selectionStart === this.searchField?.value.length + ) { + const next = this.parentToolbox_.getNavigator().getNextNode(); + if (next) { + Blockly.getFocusManager().focusNode(next); + } + return; + } else if (event.key === 'Escape' && this.searchField) { + if (this.searchField.value !== '') { + this.searchField.value = ''; + event.stopPropagation(); + // Removes matches from the flyout after programmatically clearing the search field. + this.matchBlocks(); + } + } + }, + ), + // When the user types in the search field, update the flyout to show matching blocks. + Blockly.browserEvents.conditionalBind( + this.searchField, + 'input', + this, + () => this.matchBlocks(), + ), + ); this.rowContents_?.replaceChildren(this.searchField); return dom; } @@ -106,7 +130,7 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { private registerShortcut() { const shortcut = Blockly.ShortcutRegistry.registry.createSerializedKey( Blockly.utils.KeyCodes.B, - [Blockly.utils.KeyCodes.CTRL], + [Blockly.utils.KeyCodes.CTRL_CMD], ); Blockly.ShortcutRegistry.registry.register({ name: ToolboxSearchCategory.START_SEARCH_SHORTCUT, @@ -129,7 +153,20 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { schema: Blockly.utils.toolbox.ToolboxItemInfo, allBlocks: Set, ) { - if ('contents' in schema) { + if ('custom' in schema && schema.custom) { + const flyoutCallback = this.workspace_.getToolboxCategoryCallback( + schema.custom, + ); + if (!flyoutCallback) { + return; + } + const flyoutDef = flyoutCallback(this.workspace_); + Blockly.utils.toolbox + .convertFlyoutDefToJsonArray(flyoutDef) + .forEach((item) => { + this.getAvailableBlocks(item, allBlocks); + }); + } else if ('contents' in schema) { schema.contents.forEach((contents) => { this.getAvailableBlocks(contents, allBlocks); }); @@ -148,7 +185,7 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { this.workspace_.options.languageTree?.contents?.forEach((item) => this.getAvailableBlocks(item, availableBlocks), ); - this.blockSearcher.indexBlocks([...availableBlocks]); + this.blockSearcher.indexBlocks([...availableBlocks], this.workspace_); } /** See IFocusableNode.getFocusableElement. */ @@ -181,21 +218,32 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { : []; const newCount = this.flyoutItems_.length; - if (oldCount !== newCount) { - Blockly.utils.aria.announceDynamicAriaState( - `${newCount} matching blocks`, - ); - } if (!this.flyoutItems_.length) { this.flyoutItems_.push({ kind: 'label', text: query.length < 3 - ? 'Type to search for blocks' - : 'No matching blocks found', + ? Blockly.Msg['TOOLBOX_SEARCH_PROMPT'] + : Blockly.Msg['TOOLBOX_SEARCH_NO_RESULTS'], }); } + + if (this.parentToolbox_.getSelectedItem() !== this) { + return; + } + + if (oldCount !== newCount) { + Blockly.utils.aria.announceDynamicAriaState( + newCount === 1 + ? Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT_ONE'] + : Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT'].replace( + '%1', + String(newCount), + ), + ); + } + this.parentToolbox_.refreshSelection(); } @@ -204,10 +252,65 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { */ override dispose() { super.dispose(); + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + if (this.onChangeWrapper) { + this.workspace_.removeChangeListener(this.onChangeWrapper); + this.onChangeWrapper = undefined; + } Blockly.ShortcutRegistry.registry.unregister( ToolboxSearchCategory.START_SEARCH_SHORTCUT, ); } + + /** + * Rebuilds the search index when the workspace changes in a way that alters + * what a dynamic toolbox category offers, such as creating or renaming a + * variable or a procedure. + * + * @param event The change that occurred on the workspace. + */ + private handleWorkspaceChange(event: Blockly.Events.Abstract) { + if (event.isUiEvent) return; + // This works off of the assumption that these events don't typically change + // what a dynamic category contains. Apps that need to rebuild the index + // manually can do so by firing a different workspace event. + if ( + event.type === Blockly.Events.BLOCK_MOVE || + event.type === Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE || + event.type.startsWith('comment_') + ) { + return; + } + if (this.refreshBlockSearcher()) this.matchBlocks(); + } + + /** + * Rebuilds the BlockSearcher index if the available blocks have changed. + * + * @returns True if the index was rebuilt. + */ + private refreshBlockSearcher(): boolean { + const availableBlocks = new Set(); + this.workspace_.options.languageTree?.contents?.forEach((item) => + this.getAvailableBlocks(item, availableBlocks), + ); + + const blocks = [...availableBlocks]; + const snapshot = JSON.stringify([ + blocks, + this.workspace_ + .getVariableMap() + .getAllVariables() + .map((v) => v.getName()), + ]); + if (snapshot === this.indexedBlocks) return false; + this.indexedBlocks = snapshot; + this.blockSearcher.indexBlocks(blocks, this.workspace_); + return true; + } } // Make the clear button clickable in Safari. diff --git a/packages/plugins/toolbox-search/test/index.ts b/packages/plugins/toolbox-search/test/index.ts index 2d3068ad514..9519ddeb145 100644 --- a/packages/plugins/toolbox-search/test/index.ts +++ b/packages/plugins/toolbox-search/test/index.ts @@ -9,7 +9,7 @@ */ import * as Blockly from 'blockly'; -import {toolboxCategories, createPlayground} from '@blockly/dev-tools'; +import {createPlayground} from '@blockly/dev-tools'; import '../src/toolbox_search'; /** @@ -23,16 +23,185 @@ function createWorkspace( blocklyDiv: HTMLElement, options: Blockly.BlocklyOptions, ): Blockly.WorkspaceSvg { - return Blockly.inject(blocklyDiv, options); + const workspace = Blockly.inject(blocklyDiv, options); + workspace.getVariableMap().createVariable('alpha'); + workspace.getVariableMap().createVariable('beta'); + return workspace; } +const toolbox = { + kind: 'categoryToolbox', + contents: [ + { + kind: 'category', + name: 'Logic', + categorystyle: 'logic_category', + contents: [ + {kind: 'block', type: 'controls_if'}, + {kind: 'block', type: 'logic_compare', fields: {OP: 'EQ'}}, + {kind: 'block', type: 'logic_operation', fields: {OP: 'AND'}}, + {kind: 'block', type: 'logic_negate'}, + {kind: 'block', type: 'logic_boolean', fields: {BOOL: 'TRUE'}}, + ], + }, + { + kind: 'category', + name: 'Loops', + categorystyle: 'loop_category', + contents: [ + { + kind: 'block', + type: 'controls_repeat_ext', + inputs: {TIMES: {shadow: {type: 'math_number', fields: {NUM: 250}}}}, + }, + {kind: 'block', type: 'controls_whileUntil', fields: {MODE: 'WHILE'}}, + {kind: 'block', type: 'controls_forEach'}, + { + kind: 'block', + type: 'controls_flow_statements', + fields: {FLOW: 'BREAK'}, + }, + ], + }, + { + kind: 'category', + name: 'Math', + categorystyle: 'math_category', + contents: [ + {kind: 'block', type: 'math_number', fields: {NUM: 42}}, + { + kind: 'block', + type: 'math_arithmetic', + fields: {OP: 'ADD'}, + inputs: { + A: {shadow: {type: 'math_number', fields: {NUM: 1}}}, + B: {shadow: {type: 'math_number', fields: {NUM: 1}}}, + }, + }, + { + kind: 'block', + type: 'math_round', + fields: {OP: 'ROUND'}, + inputs: {NUM: {shadow: {type: 'math_number', fields: {NUM: 3.1}}}}, + }, + { + kind: 'block', + type: 'math_modulo', + inputs: { + DIVIDEND: {shadow: {type: 'math_number', fields: {NUM: 64}}}, + DIVISOR: {shadow: {type: 'math_number', fields: {NUM: 10}}}, + }, + }, + ], + }, + { + kind: 'category', + name: 'Text', + categorystyle: 'text_category', + contents: [ + {kind: 'block', type: 'text', fields: {TEXT: 'abracadabra'}}, + { + kind: 'block', + type: 'text_print', + inputs: { + TEXT: {shadow: {type: 'text', fields: {TEXT: 'hello world'}}}, + }, + }, + {kind: 'block', type: 'text_join'}, + { + kind: 'block', + type: 'text_length', + inputs: {VALUE: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }, + { + kind: 'block', + type: 'text_changeCase', + fields: {CASE: 'UPPERCASE'}, + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }, + { + kind: 'block', + type: 'text_append', + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: '!'}}}}, + }, + ], + }, + { + kind: 'category', + name: 'Lists', + categorystyle: 'list_category', + contents: [ + {kind: 'block', type: 'lists_create_with'}, + { + kind: 'block', + type: 'lists_sort', + fields: {TYPE: 'NUMERIC', DIRECTION: '1'}, + }, + { + kind: 'block', + type: 'lists_split', + fields: {MODE: 'SPLIT'}, + inputs: {DELIM: {shadow: {type: 'text', fields: {TEXT: ','}}}}, + }, + { + kind: 'block', + type: 'lists_getIndex', + fields: {MODE: 'GET', WHERE: 'FROM_START'}, + }, + { + kind: 'block', + type: 'lists_getSublist', + fields: {WHERE1: 'FROM_START', WHERE2: 'FROM_START'}, + }, + ], + }, + {kind: 'sep'}, + { + kind: 'category', + name: 'Variables', + categorystyle: 'variable_category', + custom: 'VARIABLE', + }, + { + kind: 'category', + name: 'Functions', + categorystyle: 'procedure_category', + custom: 'PROCEDURE', + }, + {kind: 'sep'}, + { + kind: 'category', + name: 'Snippets', + categorystyle: 'logic_category', + contents: [ + { + kind: 'block', + type: 'text_print', + inputs: {TEXT: {block: {type: 'variables_get'}}}, + }, + { + kind: 'block', + type: 'controls_if', + inputs: { + IF0: { + block: { + type: 'logic_compare', + fields: {OP: 'GT'}, + inputs: { + A: {block: {type: 'variables_get'}}, + B: {shadow: {type: 'math_number', fields: {NUM: 100}}}, + }, + }, + }, + }, + }, + ], + }, + {kind: 'search', name: 'Search', contents: []}, + ], +}; + document.addEventListener('DOMContentLoaded', function () { - const toolbox = {...toolboxCategories}; - toolbox['contents'].push({ - kind: 'search', - name: 'Search', - contents: [], - }); const defaultOptions: Blockly.BlocklyOptions = { toolbox, }; diff --git a/packages/plugins/toolbox-search/test/tests.mocha.js b/packages/plugins/toolbox-search/test/tests.mocha.js index 950eaa12fba..1a6a0d2cf76 100644 --- a/packages/plugins/toolbox-search/test/tests.mocha.js +++ b/packages/plugins/toolbox-search/test/tests.mocha.js @@ -1,5 +1,6 @@ import {assert} from 'chai'; import * as Blockly from 'blockly'; +import * as sinon from 'sinon'; import {ToolboxSearchCategory} from '../src/toolbox_search'; import {BlockSearcher} from '../src/block_searcher'; @@ -14,7 +15,132 @@ suite('Toolbox search', () => { }); }); +suite('ToolboxSearchCategory', () => { + /** + * @param {!Blockly.WorkspaceSvg} workspace The workspace to inspect. + * @returns {!Array} The types of the blocks now in the flyout. + */ + function flyoutBlockTypes(workspace) { + return workspace + .getFlyout() + .getWorkspace() + .getTopBlocks(false) + .map((block) => block.type); + } + + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + this.clock = sinon.useFakeTimers(); + this.workspace = Blockly.inject('blocklyDiv', { + media: 'media/', + toolbox: { + kind: 'categoryToolbox', + contents: [ + { + kind: 'category', + name: 'Logic', + contents: [{kind: 'block', type: 'controls_if'}], + }, + {kind: 'category', name: 'Variables', custom: 'VARIABLE'}, + {kind: 'search', name: 'Search', contents: []}, + ], + }, + }); + // See https://github.com/RaspberryPiFoundation/blockly-samples/issues/2528. + global.SVGElement = window.SVGElement; + global.requestAnimationFrame = (callback) => setTimeout(callback, 0); + this.searchCategory = this.workspace + .getToolbox() + .getToolboxItems() + .find((item) => item instanceof ToolboxSearchCategory); + + /** + * Types into the search field the way a user would, so the category's own + * input listener drives the flyout. + * @param {string} query The text to search for. + */ + this.search = (query) => { + const field = this.searchCategory.searchField; + this.searchCategory + .getParentToolbox() + .setSelectedItem(this.searchCategory); + field.value = query; + field.dispatchEvent(new window.Event('input')); + this.clock.runAll(); + }; + }); + + teardown(function () { + this.workspace.dispose(); + this.clock.runAll(); + this.clock.restore(); + this.jsdomCleanup(); + }); + + test('shows matching blocks in the flyout', function () { + this.search('controls if'); + + assert.deepEqual(flyoutBlockTypes(this.workspace), ['controls_if']); + }); + + test('updates flyout blocks to the variable that was searched for', function () { + this.workspace.getVariableMap().createVariable('score'); + this.clock.runAll(); + + this.search('score'); + + const blocks = this.workspace + .getFlyout() + .getWorkspace() + .getTopBlocks(false); + assert.deepEqual( + blocks.map((block) => block.type), + ['variables_set', 'math_change', 'variables_get'], + ); + blocks.forEach((block) => { + assert.equal(block.getVarModels()[0].getName(), 'score'); + }); + }); + + test('keeps the flyout current as variables are created', function () { + this.search('score'); + assert.isEmpty(flyoutBlockTypes(this.workspace)); + + this.workspace.getVariableMap().createVariable('score'); + this.clock.runAll(); + + assert.include(flyoutBlockTypes(this.workspace), 'variables_get'); + }); +}); + suite('BlockSearcher', () => { + let workspace; + + setup(() => { + workspace = new Blockly.Workspace(); + }); + + teardown(() => { + workspace.dispose(); + }); + + /** + * Creates the named variables on the workspace and returns the block + * definitions the VARIABLE category would create for them. + * @param {!Array} names The variables to create. + * @returns {!Array} A setter, a change block and one getter each. + */ + function createVariableBlocks(names) { + names.forEach((name) => workspace.getVariableMap().createVariable(name)); + return Blockly.Variables.jsonFlyoutCategoryBlocks( + workspace, + workspace.getVariableMap().getVariablesOfType(''), + true, + ); + } + test('generateTrigrams handles empty and short input', () => { const searcher = new BlockSearcher(); const generateTrigrams = searcher.generateTrigrams.bind(searcher); @@ -39,7 +165,7 @@ suite('BlockSearcher', () => { // Text on these: // lists_sort: sort // lists_split: make with delimiter , - searcher.indexBlocks(blocks); + searcher.indexBlocks(blocks, workspace); const numericMatches = searcher.blockTypesMatching('numeric'); assert.sameMembers(numericMatches, [blocks[0]]); @@ -54,7 +180,7 @@ suite('BlockSearcher', () => { kind: 'block', type: 'lists_create_with', }; - searcher.indexBlocks([listCreateWithBlock]); + searcher.indexBlocks([listCreateWithBlock], workspace); const lowercaseMatches = searcher.blockTypesMatching('create list'); assert.sameMembers(lowercaseMatches, [listCreateWithBlock]); @@ -72,7 +198,7 @@ suite('BlockSearcher', () => { kind: 'block', type: 'math_constrain', }; - searcher.indexBlocks([mathConstrainBlock]); + searcher.indexBlocks([mathConstrainBlock], workspace); const matches = searcher.blockTypesMatching('conso'); @@ -98,7 +224,7 @@ suite('BlockSearcher', () => { kind: 'block', type: 'searcher_underscore_block', }; - searcher.indexBlocks([blockInfo]); + searcher.indexBlocks([blockInfo], workspace); assert.sameMembers( searcher.blockTypesMatching('searcher underscore block'), @@ -125,7 +251,7 @@ suite('BlockSearcher', () => { const blockA = {kind: 'block', type: 'searcher_charlie'}; const blockB = {kind: 'block', type: 'searcher_delta'}; - searcher.indexBlocks([blockA, blockB]); + searcher.indexBlocks([blockA, blockB], workspace); const broadQueryMatches = searcher.blockTypesMatching('alpha bravo'); assert.sameMembers(broadQueryMatches, [blockA, blockB]); @@ -174,10 +300,16 @@ suite('BlockSearcher', () => { const searcher = new BlockSearcher(); const blockInfo = {kind: 'block', type: 'searcher_dropdown_alt'}; - searcher.indexBlocks([blockInfo]); + searcher.indexBlocks([blockInfo], workspace); assert.sameMembers(searcher.blockTypesMatching('sunny'), [blockInfo]); - assert.sameMembers(searcher.blockTypesMatching('cloudy'), [blockInfo]); + // 'cloudy' wasn't the selected option, but it should be set with the matching option if found. + const cloudyMatches = searcher.blockTypesMatching('cloudy'); + assert.lengthOf(cloudyMatches, 1); + assert.deepEqual(cloudyMatches[0], { + ...blockInfo, + fields: {WEATHER: 'CLOUD'}, + }); }); test('returns an empty list when no matches are found', () => { @@ -211,9 +343,131 @@ suite('BlockSearcher', () => { }, ]; - searcher.indexBlocks(blocks); + searcher.indexBlocks(blocks, workspace); const matches = searcher.blockTypesMatching('replace'); assert.sameMembers(matches, [blocks[0]]); }); + + test('indexes field values from the block definition', () => { + const searcher = new BlockSearcher(); + const numberBlock = { + kind: 'block', + type: 'math_number', + fields: {NUM: 250}, + }; + const printBlock = { + kind: 'block', + type: 'text_print', + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }; + searcher.indexBlocks([numberBlock, printBlock], workspace); + + assert.sameMembers(searcher.blockTypesMatching('250'), [numberBlock]); + // The value lives on a shadow block, not on the block itself. + assert.sameMembers(searcher.blockTypesMatching('abc'), [printBlock]); + }); + + test('binds variable blocks to every matching variable', () => { + const searcher = new BlockSearcher(); + searcher.indexBlocks( + createVariableBlocks(['alpha', 'alphabet', 'beta']), + workspace, + ); + + const matches = searcher.blockTypesMatching('alpha'); + assert.sameMembers( + matches.map((match) => `${match.type}(${match.fields.VAR.name})`), + [ + 'variables_set(alpha)', + 'variables_set(alphabet)', + 'math_change(alpha)', + 'math_change(alphabet)', + 'variables_get(alpha)', + 'variables_get(alphabet)', + ], + ); + }); + + test("leaves matches unchanged when the query doesn't name a variable", () => { + const searcher = new BlockSearcher(); + const blocks = createVariableBlocks(['alpha']); + searcher.indexBlocks(blocks, workspace); + + const setter = blocks.find((block) => block.type === 'variables_set'); + assert.include(searcher.blockTypesMatching('set'), setter); + }); + + test('does not index variable rename and delete options', () => { + const searcher = new BlockSearcher(); + searcher.indexBlocks(createVariableBlocks(['alpha']), workspace); + + assert.isEmpty(searcher.blockTypesMatching('rename')); + assert.isEmpty(searcher.blockTypesMatching('delete the')); + }); + + test('sets dropdowns to the option that matched', () => { + const searcher = new BlockSearcher(); + const sortBlock = {kind: 'block', type: 'lists_sort'}; + searcher.indexBlocks([sortBlock], workspace); + + // 'numeric' is already selected, so the indexed block is returned as-is. + assert.sameMembers(searcher.blockTypesMatching('numeric'), [sortBlock]); + assert.sameDeepMembers( + searcher.blockTypesMatching('alphabetic').map((match) => match.fields), + [{TYPE: 'TEXT'}, {TYPE: 'IGNORE_CASE'}], + ); + }); + + test('varies one dropdown at a time', () => { + const searcher = new BlockSearcher(); + // Both WHERE1 and WHERE2 offer '# from end'. + searcher.indexBlocks( + [{kind: 'block', type: 'lists_getSublist'}], + workspace, + ); + + assert.sameDeepMembers( + searcher.blockTypesMatching('from end').map((match) => match.fields), + [{WHERE1: 'FROM_END'}, {WHERE2: 'FROM_END'}], + ); + }); + + test('indexes procedure names from extra state', () => { + const searcher = new BlockSearcher(); + const callBlock = { + kind: 'block', + type: 'procedures_callnoreturn', + extraState: {name: 'draw sprites', params: []}, + }; + searcher.indexBlocks([callBlock], workspace); + + assert.sameMembers(searcher.blockTypesMatching('draw sprites'), [ + callBlock, + ]); + }); + + test('does not match trigrams pooled from different strings', () => { + const searcher = new BlockSearcher(); + // 'controls flow statements' supplies 'tem' and 'next iteration' supplies + // 'ite', but the block never says 'item'. + searcher.indexBlocks( + [{kind: 'block', type: 'controls_flow_statements'}], + workspace, + ); + + assert.isEmpty(searcher.blockTypesMatching('item')); + }); + + test('replaces the previous index when reindexing', () => { + const searcher = new BlockSearcher(); + searcher.indexBlocks([{kind: 'block', type: 'text_print'}], workspace); + + // Reindexing should forget the previous pass entirely, not add to it. + const negate = {kind: 'block', type: 'logic_negate'}; + searcher.indexBlocks([negate], workspace); + + assert.isEmpty(searcher.blockTypesMatching('print')); + assert.sameMembers(searcher.blockTypesMatching('not'), [negate]); + }); });