diff --git a/app/components/component-tree-item.hbs b/app/components/component-tree-item.hbs index 450b7ce0b4..8ebaf663d5 100644 --- a/app/components/component-tree-item.hbs +++ b/app/components/component-tree-item.hbs @@ -1,6 +1,7 @@ {{!-- template-lint-disable no-invalid-interactive --}}
- {{@item.name}} + {{mark-match @item.name @searchTerm}} {{#each @item.args.positional as |value|}} @@ -63,7 +64,7 @@ {{/each-in}} {{else}} - {{classify @item.name}} + {{mark-match (classify @item.name) @searchTerm}} {{#each-in @item.args.named as |name value|}} @@ -78,11 +79,11 @@ {{/each-in}} {{/if}} {{else if @item.isOutlet}} - \{{outlet "{{@item.name}}"}} + \{{outlet "{{mark-match @item.name @searchTerm}}"}} {{else if @item.isEngine}} - \{{mount "{{@item.name}}"}} + \{{mount "{{mark-match @item.name @searchTerm}}"}} {{else if @item.isRouteTemplate}} - {{@item.name}} route + {{mark-match @item.name @searchTerm}} route {{/if}}
diff --git a/app/controllers/component-tree.js b/app/controllers/component-tree.js index 927beaae61..6a53287a19 100644 --- a/app/controllers/component-tree.js +++ b/app/controllers/component-tree.js @@ -19,10 +19,15 @@ export default class ComponentTreeController extends Controller { @tracked query = ''; @tracked isInspecting = false; + /** + * + * @type {RenderItem[]} + */ @tracked renderItems = []; @tracked _pinned = undefined; @tracked _previewing = undefined; + @tracked searchSelect = undefined; _store = Object.create(null); @@ -30,6 +35,10 @@ export default class ComponentTreeController extends Controller { let { _store } = this; let store = Object.create(null); + /** + * + * @type {RenderItem[]} + */ let renderItems = []; let flatten = (parent, renderNode) => { @@ -80,6 +89,14 @@ export default class ComponentTreeController extends Controller { } get nextItem() { + if (this.searchSelect) { + const items = this.matchingItems; + const index = items.indexOf(this.findItem(this.pinned)) + 1; + return ( + items.slice(index).find((i) => searchMatch(i.name, this.searchSelect)) || + this.currentItem + ); + } const items = this.visibleItems; return ( items[items.indexOf(this.findItem(this.pinned)) + 1] || @@ -88,6 +105,16 @@ export default class ComponentTreeController extends Controller { } get previousItem() { + if (this.searchSelect) { + const items = this.matchingItems; + const index = items.indexOf(this.findItem(this.pinned)); + return ( + items + .slice(0, index) + .reverse() + .find((i) => searchMatch(i.name, this.searchSelect)) || this.currentItem + ); + } const items = this.visibleItems; return items[items.indexOf(this.findItem(this.pinned)) - 1] || items[0]; } @@ -193,6 +220,10 @@ export default class ComponentTreeController extends Controller { } } + @action handleClick() { + this.searchSelect = false; + } + @action handleKeyDown(event) { if (focusedInInput()) { return; @@ -204,13 +235,16 @@ export default class ComponentTreeController extends Controller { switch (event.keyCode) { case KEYS.up: - this.pinned = this.previousItem.id; + this.pinned = this.previousItem?.id; break; case KEYS.right: { + if (this.searchSelect) { + break; + } const pinnedItem = this.findItem(this.pinned); if (pinnedItem.isExpanded) { - this.pinned = this.nextItem.id; + this.pinned = this.nextItem?.id; } else { pinnedItem.expand(); } @@ -218,9 +252,12 @@ export default class ComponentTreeController extends Controller { break; } case KEYS.down: - this.pinned = this.nextItem.id; + this.pinned = this.nextItem?.id; break; case KEYS.left: { + if (this.searchSelect) { + break; + } const pinnedItem = this.findItem(this.pinned); if (pinnedItem.isExpanded) { @@ -231,6 +268,22 @@ export default class ComponentTreeController extends Controller { break; } + case KEYS.escape: + this.searchSelect = undefined; + break; + case KEYS.backspace: + this.searchSelect = (this.searchSelect || '').slice(0, -1); + break; + default: + if (event.key.length === 1) { + this.searchSelect = (this.searchSelect || '') + event.key; + if ( + !this.currentItem || + !searchMatch(this.currentItem.name, this.searchSelect) + ) { + this.pinned = this.nextItem?.id; + } + } } } @@ -248,10 +301,12 @@ export default class ComponentTreeController extends Controller { @action arrowKeysSetup() { document.addEventListener('keydown', this.handleKeyDown); + document.addEventListener('click', this.handleClick); } @action arrowKeysTeardown() { document.removeEventListener('keydown', this.handleKeyDown); + document.removeEventListener('click', this.handleClick); } } diff --git a/app/helpers/mark-match.js b/app/helpers/mark-match.js new file mode 100644 index 0000000000..8ffe908df9 --- /dev/null +++ b/app/helpers/mark-match.js @@ -0,0 +1,33 @@ +import { helper } from '@ember/component/helper'; +import { htmlSafe } from '@ember/template'; +import searchMatch from 'ember-inspector/utils/search-match'; + + +function replaceRange(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); +} +/** + * + * @param str {string} + * @param regex {RegExp} + * @return {SafeString} + */ +export function markMatch([str, regex]) { + if (!regex) { + return str; + } + const match = searchMatch(str, regex); + if (!match) { + return str; + } + const matchedText = str.slice(match.index, match.index + match[0].length); + str = replaceRange( + str, + match.index, + match.index + match[0].length, + `${matchedText}` + ); + return htmlSafe(str); +} + +export default helper(markMatch); diff --git a/app/styles/component_tree.scss b/app/styles/component_tree.scss index bb399c8e1f..f607380f2f 100644 --- a/app/styles/component_tree.scss +++ b/app/styles/component_tree.scss @@ -210,3 +210,14 @@ .component-tree-item--component { color: var(--base09); } + +.component-tree-search { + background-color: var(--base00); + border: 1px solid var(--base10); + box-shadow: 0 0 3px var(--base15); + padding: 3px; + position: absolute; + right: 20px; + top: 3px; + z-index: 1; +} diff --git a/app/templates/component-tree.hbs b/app/templates/component-tree.hbs index a0944481d9..54cbe08194 100644 --- a/app/templates/component-tree.hbs +++ b/app/templates/component-tree.hbs @@ -10,6 +10,10 @@ {{/in-element}} {{/if}} +{{#if this.searchSelect}} + +{{/if}} + - + \ No newline at end of file diff --git a/app/utils/key-codes.js b/app/utils/key-codes.js index 4776758dc7..f664fd95a2 100644 --- a/app/utils/key-codes.js +++ b/app/utils/key-codes.js @@ -5,6 +5,7 @@ const KEYS = { right: 39, down: 40, left: 37, + backspace: 8, }; export { KEYS }; diff --git a/app/utils/search-match.js b/app/utils/search-match.js index d578459548..407f10614f 100644 --- a/app/utils/search-match.js +++ b/app/utils/search-match.js @@ -10,5 +10,5 @@ export default function (text, searchQuery) { return true; } let regExp = new RegExp(escapeRegExp(sanitize(searchQuery))); - return !!sanitize(text).match(regExp); + return sanitize(text).match(regExp); } diff --git a/tests/acceptance/component-tree-test.js b/tests/acceptance/component-tree-test.js index 80cbf1f3b6..66422d816c 100644 --- a/tests/acceptance/component-tree-test.js +++ b/tests/acceptance/component-tree-test.js @@ -13,6 +13,7 @@ import { import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupTestAdapter, respondWith, sendMessage } from '../test-adapter'; +import { KEYS } from 'ember-inspector/utils/key-codes'; function textFor(selector, context) { return context.querySelector(selector).textContent.trim(); @@ -108,8 +109,8 @@ function getRenderTree({ withChildren, withManyChildren } = {}) { const children = []; if (withChildren) { children.push( - Component({ id: 5, name: 'sub-task' }), - Component({ id: 6, name: 'sub-task' }) + Component({ id: 5, name: 'sub-task-' + 5 }), + Component({ id: 6, name: 'sub-task-' + 6 }) ); } if (withManyChildren) { @@ -354,6 +355,97 @@ module('Component Tab', function (hooks) { assert.strictEqual(treeNodes.length, 4, 'expected all tree nodes'); }); + test('It should search select when typing characters', async function (assert) { + assert.expect(26); + await visit('/component-tree'); + + respondWith('view:showInspection', false, { count: 12 }); + + await sendMessage({ + type: 'view:renderTree', + tree: getRenderTree({ withManyChildren: true }), + }); + + let expanders = findAll('.component-tree-item__expand'); + let expanderEl = expanders[expanders.length - 1]; + await click(expanderEl); + + async function triggerCharCodes(text) { + for (let t of text) { + await triggerKeyEvent(document, 'keydown', t.toUpperCase()); + } + } + + let treeNodes = findAll('.component-tree-item'); + assert.strictEqual(treeNodes.length, 32, 'expected all tree nodes'); + + respondWith('view:showInspection', false); + respondWith('objectInspector:inspectById', ({ objectId }) => { + const result = findAll( + `[data-test-id=${objectId}] .component-tree-item__tag` + )[0]; + // matching initial character s + assert.strictEqual( + result.textContent.trim(), + 'todos route', + 'should first select todos route' + ); + return false; + }); + + await triggerCharCodes('sub-task-1'); + await rerender(); + + treeNodes = findAll('mark'); + treeNodes.forEach((node) => { + assert.strictEqual( + node.textContent.trim(), + 'SubTask1', + 'SubTask1 text part should be marked' + ); + }); + assert.strictEqual( + treeNodes.length, + 10, + 'expected nodes with sub-task-1 name to be marked' + ); + + treeNodes = findAll('.component-tree-item'); + assert.strictEqual(treeNodes.length, 32, 'expected all tree nodes'); + + respondWith('view:showInspection', false); + + let subTaskNumber = 11; + for (let i = 0; i < 10; i++) { + await triggerKeyEvent(document, 'keydown', KEYS.down); + const node = findAll( + '.component-tree-item--pinned .component-tree-item__tag' + )[0]; + assert.strictEqual( + node.textContent.trim(), + 'SubTask' + subTaskNumber, + 'should include SubTask1' + ); + subTaskNumber++; + if (subTaskNumber === 20) { + subTaskNumber = 100; + } + } + + await triggerKeyEvent(document, 'keydown', KEYS.up); + const node = findAll( + '.component-tree-item--pinned .component-tree-item__tag' + )[0]; + assert.strictEqual( + node.textContent.trim(), + 'SubTask19', + 'should include SubTask1' + ); + + treeNodes = findAll('.component-tree-item'); + assert.strictEqual(treeNodes.length, 33, 'expected all tree nodes'); + }); + test('It should update the view tree when the port triggers a change, preserving the expanded state of existing nodes', async function (assert) { await visit('/component-tree');