/** @odoo-module */ import { Component, onPatched, onWillPatch, useRef, useState, xml } from "@odoo/owl"; import { getActiveElement } from "@web/../lib/hoot-dom/helpers/dom"; import { R_REGEX, REGEX_MARKER } from "@web/../lib/hoot-dom/hoot_dom_utils"; import { Suite } from "../core/suite"; import { Tag } from "../core/tag"; import { Test } from "../core/test"; import { refresh } from "../core/url"; import { debounce, EXACT_MARKER, INCLUDE_LEVEL, lookup, parseQuery, R_QUERY_EXACT, STORAGE, storageGet, storageSet, stringify, title, useHootKey, useWindowListener, } from "../hoot_utils"; import { HootTagButton } from "./hoot_tag_button"; /** * @typedef {{ * }} HootSearchProps * * @typedef {import("../core/config").SearchFilter} SearchFilter * * @typedef {import("../core/tag").Tag} Tag * * @typedef {import("../core/test").Test} Test */ //----------------------------------------------------------------------------- // Global //----------------------------------------------------------------------------- const { Math: { abs: $abs }, Object: { entries: $entries, values: $values }, } = globalThis; //----------------------------------------------------------------------------- // Internal //----------------------------------------------------------------------------- /** * @param {string} query */ function addExact(query) { return EXACT_MARKER + query + EXACT_MARKER; } /** * @param {string} query */ function addRegExp(query) { return REGEX_MARKER + query + REGEX_MARKER; } /** * @param {"suite" | "tag" | "test"} category */ function categoryToType(category) { return category === "tag" ? category : "id"; } /** * @param {string} query */ function removeExact(query) { return query.replaceAll(EXACT_MARKER, ""); } /** * @param {string} query */ function removeRegExp(query) { return query.slice(1, -1); } /** * /!\ Requires "job" and "category" to be in scope * * @param {string} tagName */ const templateIncludeWidget = (tagName) => /* xml */ ` <${tagName} class="flex items-center gap-1 cursor-pointer select-none" t-on-click.stop="() => this.toggleInclude(type, job.id)" >
/ `; /** * * @param {ReturnType>} ref */ function useKeepSelection(ref) { /** * @param {number} nextOffset */ function keepSelection(nextOffset) { offset = nextOffset || 0; } let offset = null; let start = 0; let end = 0; onWillPatch(() => { if (offset === null || !ref.el) { return; } start = ref.el.selectionStart; end = ref.el.selectionEnd; }); onPatched(() => { if (offset === null || !ref.el) { return; } ref.el.selectionStart = start + offset; ref.el.selectionEnd = end + offset; offset = null; }); return keepSelection; } const EMPTY_SUITE = new Suite(null, "…", []); const SECRET_SEQUENCE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; const RESULT_LIMIT = 5; // Template parts, because 16 levels of indent is a bit much const TEMPLATE_FILTERS_AND_CATEGORIES = /* xml */ `
Start typing to show filters...

    ${templateIncludeWidget("li")}
    more items ...

`; const TEMPLATE_SEARCH_DASHBOARD = /* xml */ `

Recent searches

Available suites

    ${templateIncludeWidget("li")}

Available tags

    ${templateIncludeWidget("li")}
`; //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** @extends {Component} */ export class HootSearch extends Component { static components = { HootTagButton }; static props = {}; static template = xml`
${TEMPLATE_SEARCH_DASHBOARD} ${TEMPLATE_FILTERS_AND_CATEGORIES}
`; categories = ["suite", "test", "tag"]; debouncedUpdateSuggestions = debounce(this.updateSuggestions.bind(this), 16); refresh = refresh; title = title; get trimmedQuery() { return this.state.query.trim(); } setup() { const { runner } = this.env; runner.beforeAll(() => { this.state.categories = this.findSuggestions(); this.state.empty = this.isEmpty(); }); runner.afterAll(() => this.focusSearchInput()); this.rootRef = useRef("root"); this.searchInputRef = useRef("search-input"); this.config = useState(runner.config); const query = this.config.filter || ""; this.state = useState({ categories: { /** @type {Suite[]} */ suite: [], /** @type {Tag[]} */ tag: [], /** @type {Test[]} */ test: [], }, disabled: false, empty: !query.trim(), query, showDropdown: false, }); this.runnerState = useState(runner.state); useHootKey(["Alt", "r"], this.toggleRegExp); useHootKey(["Alt", "x"], this.toggleExact); useHootKey(["Escape"], this.closeDropdown); useWindowListener( "click", (ev) => { if (this.runnerState.status !== "running") { const shouldOpen = ev.composedPath().includes(this.rootRef.el); if (shouldOpen && !this.state.showDropdown) { this.debouncedUpdateSuggestions(); } this.state.showDropdown = shouldOpen; } }, { capture: true } ); this.keepSelection = useKeepSelection(this.searchInputRef); } /** * @param {KeyboardEvent} ev */ closeDropdown(ev) { if (!this.state.showDropdown) { return; } ev.preventDefault(); this.state.showDropdown = false; } /** * @param {string} parsedQuery * @param {Map} items * @param {SearchFilter} category */ filterItems(parsedQuery, items, category) { const checked = this.runnerState.includeSpecs[category]; const result = []; const remaining = []; for (const item of items.values()) { const value = $abs(checked[item.id]); if (value === INCLUDE_LEVEL.url) { result.push(item); } else { remaining.push(item); } } const matching = lookup(parsedQuery, remaining); result.push(...matching.slice(0, RESULT_LIMIT)); return [result, matching.length - RESULT_LIMIT]; } findSuggestions() { const { suites, tags, tests } = this.env.runner; const parsedQuery = parseQuery(this.trimmedQuery); return { suite: this.filterItems(parsedQuery, suites, "id"), tag: this.filterItems(parsedQuery, tags, "tag"), test: this.filterItems(parsedQuery, tests, "id"), }; } focusSearchInput() { this.searchInputRef.el?.focus(); } getCategoryCounts() { const { includeSpecs } = this.runnerState; const { suites, tests } = this.env.runner; const counts = []; for (const category of this.categories) { const include = []; const exclude = []; for (const [id, value] of $entries(includeSpecs[categoryToType(category)])) { if ( (category === "suite" && !suites.has(id)) || (category === "test" && !tests.has(id)) ) { continue; } switch (value) { case +INCLUDE_LEVEL.url: case +INCLUDE_LEVEL.tag: { include.push(id); break; } case -INCLUDE_LEVEL.url: case -INCLUDE_LEVEL.tag: { exclude.push(id); break; } } } if (include.length || exclude.length) { counts.push({ category, tip: `Remove all ${category}`, include, exclude }); } } return counts; } getHasIncludeValue() { return $values(this.runnerState.includeSpecs).some((values) => $values(values).some((value) => value > 0) ); } getLatestSearches() { return storageGet(STORAGE.searches) || []; } /** * * @param {(Suite | Test)[]} path */ getShortPath(path) { if (path.length <= 3) { return path.slice(0, -1); } else { return [path.at(0), EMPTY_SUITE, path.at(-2)]; } } /** * @param {Iterable} items */ getTop(items) { return [...items].sort((a, b) => b.weight - a.weight).slice(0, 5); } hasExactFilter(query = this.trimmedQuery) { R_QUERY_EXACT.lastIndex = 0; return R_QUERY_EXACT.test(query); } hasRegExpFilter(query = this.trimmedQuery) { return R_REGEX.test(query); } isEmpty() { return !( this.trimmedQuery || $values(this.runnerState.includeSpecs).some((values) => $values(values).some((value) => $abs(value) === INCLUDE_LEVEL.url) ) ); } /** * @param {number} value */ isReadonly(value) { return $abs(value) > INCLUDE_LEVEL.url; } /** * @param {unknown} item */ isTag(item) { return item instanceof Tag; } /** * @param {number} inc */ navigate(inc) { const elements = [ this.searchInputRef.el, ...this.rootRef.el.querySelectorAll("input[type=radio]:checked:enabled"), ]; let nextIndex = elements.indexOf(getActiveElement(document)) + inc; if (nextIndex >= elements.length) { nextIndex = 0; } else if (nextIndex < -1) { nextIndex = -1; } elements.at(nextIndex).focus(); } /** * @param {KeyboardEvent} ev */ onExactKeyDown(ev) { switch (ev.key) { case "Enter": case " ": { this.toggleExact(ev); break; } } } /** * @param {SearchFilter} type * @param {string} id * @param {"exclude" | "include"} value */ onIncludeChange(type, id, value) { if (value === "include" || value === "exclude") { this.setInclude( type, id, value === "include" ? +INCLUDE_LEVEL.url : -INCLUDE_LEVEL.url ); } else { this.setInclude(type, id, 0); } } /** * @param {KeyboardEvent} ev */ onKeyDown(ev) { switch (ev.key) { case "ArrowDown": { ev.preventDefault(); return this.navigate(+1); } case "ArrowUp": { ev.preventDefault(); return this.navigate(-1); } case "Enter": { return refresh(); } } } /** * @param {KeyboardEvent} ev */ onRegExpKeyDown(ev) { switch (ev.key) { case "Enter": case " ": { this.toggleRegExp(ev); break; } } } onSearchInputChange() { if (!this.trimmedQuery) { return; } const latestSearches = this.getLatestSearches(); latestSearches.unshift(this.trimmedQuery); storageSet(STORAGE.searches, [...new Set(latestSearches)].slice(0, 5)); } /** * @param {InputEvent & { currentTarget: HTMLInputElement }} ev */ onSearchInputInput(ev) { this.state.query = ev.currentTarget.value; this.env.ui.resultsPage = 0; this.updateFilterParam(); this.debouncedUpdateSuggestions(); } /** * @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev */ onSearchInputKeyDown(ev) { switch (ev.key) { case "Backspace": { if (ev.currentTarget.selectionStart === 0 && ev.currentTarget.selectionEnd === 0) { this.uncheckLastCategory(); } break; } } if (this.config.fun) { this.verifySecretSequenceStep(ev); } } /** * @param {SearchFilter} type * @param {string} id * @param {number} [value] */ setInclude(type, id, value) { this.config.filter = ""; this.env.runner.include(type, id, value); } /** * @param {string} query */ setQuery(query) { this.state.query = query; this.updateFilterParam(); this.updateSuggestions(); this.focusSearchInput(); } toggleDebug() { this.config.debugTest = !this.config.debugTest; } /** * @param {Event} ev */ toggleExact(ev) { ev.preventDefault(); const currentQuery = this.trimmedQuery; let query = currentQuery; if (this.hasRegExpFilter(query)) { query = removeRegExp(query); } if (this.hasExactFilter(query)) { query = removeExact(query); } else { query = addExact(query); } this.keepSelection((query.length - currentQuery.length) / 2); this.setQuery(query); } /** * @param {SearchFilter} type * @param {string} id */ toggleInclude(type, id) { const currentValue = this.runnerState.includeSpecs[type][id]; if (this.isReadonly(currentValue)) { return; // readonly } if (currentValue > 0) { this.setInclude(type, id, -INCLUDE_LEVEL.url); } else if (currentValue < 0) { this.setInclude(type, id, 0); } else { this.setInclude(type, id, +INCLUDE_LEVEL.url); } } /** * @param {Event} ev */ toggleRegExp(ev) { ev.preventDefault(); const currentQuery = this.trimmedQuery; let query = currentQuery; if (this.hasExactFilter(query)) { query = removeExact(query); } if (this.hasRegExpFilter(query)) { query = removeRegExp(query); } else { query = addRegExp(query); } this.keepSelection((query.length - currentQuery.length) / 2); this.setQuery(query); } uncheckLastCategory() { for (const count of this.getCategoryCounts().reverse()) { const type = categoryToType(count.category); const includeSpecs = this.runnerState.includeSpecs[type]; for (const id of [...count.exclude, ...count.include]) { const value = includeSpecs[id]; if (this.isReadonly(value)) { continue; } this.setInclude(type, id, 0); return true; } } return false; } updateFilterParam() { this.config.filter = this.trimmedQuery; } updateSuggestions() { this.state.empty = this.isEmpty(); this.state.categories = this.findSuggestions(); this.state.showDropdown = true; } /** * @param {KeyboardEvent} ev */ verifySecretSequenceStep(ev) { this.secretSequence ||= 0; if (ev.keyCode === SECRET_SEQUENCE[this.secretSequence]) { ev.stopPropagation(); ev.preventDefault(); this.secretSequence++; } else { this.secretSequence = 0; return; } if (this.secretSequence === SECRET_SEQUENCE.length) { this.secretSequence = 0; const { runner } = this.env; runner.stop(); runner.reporting.passed += runner.reporting.failed; runner.reporting.passed += runner.reporting.todo; runner.reporting.failed = 0; runner.reporting.todo = 0; for (const [, suite] of runner.suites) { suite.reporting.passed += suite.reporting.failed; suite.reporting.passed += suite.reporting.todo; suite.reporting.failed = 0; suite.reporting.todo = 0; } for (const [, test] of runner.tests) { test.config.todo = false; test.status = Test.PASSED; for (const result of test.results) { result.pass = true; result.currentErrors = []; for (const assertion of result.getEvents("assertion")) { assertion.pass = true; } } } this.__owl__.app.root.render(true); console.warn("Secret sequence activated: all tests pass!"); } } wrappedQuery(query = this.trimmedQuery) { return this.hasRegExpFilter(query) ? query : stringify(query); } }