Odoo18-Base/addons/web/static/lib/hoot/ui/hoot_search.js
2025-01-06 10:57:38 +07:00

783 lines
25 KiB
JavaScript

/** @odoo-module */
import { Component, useRef, useState, xml } from "@odoo/owl";
import { getActiveElement } from "@web/../lib/hoot-dom/helpers/dom";
import { isRegExpFilter, parseRegExp } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { Suite } from "../core/suite";
import { Tag } from "../core/tag";
import { Test } from "../core/test";
import { EXCLUDE_PREFIX, refresh } from "../core/url";
import {
INCLUDE_LEVEL,
STORAGE,
debounce,
lookup,
normalize,
storageGet,
storageSet,
stringify,
title,
useWindowListener,
} from "../hoot_utils";
import { HootTagButton } from "./hoot_tag_button";
/**
* @typedef {{
* }} HootSearchProps
*
* @typedef {"suites" | "tags" | "tests"} SearchCategory
*
* @typedef {import("../core/tag").Tag} Tag
*
* @typedef {import("../core/test").Test} Test
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Boolean,
Object: { entries: $entries, values: $values },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
*
* @param {Record<string, number>} values
*/
const formatIncludes = (values) =>
$entries(values)
.filter(([id, value]) => Math.abs(value) === INCLUDE_LEVEL.url)
.map(([id, value]) => (value >= 0 ? id : `${EXCLUDE_PREFIX}${id}`));
/**
* @param {string} query
*/
const getPattern = (query) => {
query = query.match(R_QUERY_CONTENT)[1];
return parseRegExp(normalize(query), { safe: true });
};
/**
* /!\ Requires "job" and "category" to be in scope
*
* @param {string} tagName
*/
const templateIncludeWidget = (tagName) => /* xml */ `
<t t-set="includeStatus" t-value="runnerState.includeSpecs[category][job.id] or 0" />
<t t-set="readonly" t-value="isReadonly(includeStatus)" />
<${tagName}
class="flex items-center gap-1 cursor-pointer select-none"
t-on-click.stop="() => this.toggleInclude(category, job.id)"
>
<div
class="hoot-include-widget h-5 p-px flex items-center relative border rounded-full"
t-att-class="{
'border-muted': readonly,
'border-primary': !readonly,
'opacity-50': readonly,
}"
t-att-title="readonly and 'Cannot change because it depends on a tag modifier in the code'"
t-on-pointerup="focusSearchInput"
t-on-change="(ev) => this.onIncludeChange(category, job.id, ev.target.value)"
>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-title="!readonly and 'Exclude'"
t-att-disabled="readonly"
t-att-name="job.id" value="exclude"
t-att-checked="includeStatus lt 0"
/>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-disabled="readonly"
t-att-name="job.id" value="null"
t-att-checked="!includeStatus"
/>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-title="!readonly and 'Include'"
t-att-disabled="readonly"
t-att-name="job.id" value="include"
t-att-checked="includeStatus gt 0"
/>
</div>
<t t-if="isTag(job)">
<HootTagButton tag="job" inert="true" />
</t>
<t t-else="">
<span
class="flex items-center font-bold whitespace-nowrap overflow-hidden"
t-att-title="job.fullName"
>
<t t-foreach="getShortPath(job.path)" t-as="suite" t-key="suite.id">
<span class="text-muted px-1" t-esc="suite.name" />
<span class="font-normal">/</span>
</t>
<t t-set="isSet" t-value="job.id in runnerState.includeSpecs[category]" />
<span
class="truncate px-1"
t-att-class="{
'font-extrabold': isSet,
'text-pass': includeStatus gt 0,
'text-fail': includeStatus lt 0,
'text-muted': !isSet and hasIncludeValue,
'text-primary': !isSet and !hasIncludeValue,
'italic': hasIncludeValue ? includeStatus lte 0 : includeStatus lt 0,
}"
t-esc="job.name"
/>
</span>
</t>
</${tagName}>
`;
const EMPTY_SUITE = new Suite(null, "…", []);
const SECRET_SEQUENCE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
const R_QUERY_CONTENT = new RegExp(`^\\s*${EXCLUDE_PREFIX}?\\s*(.*)\\s*$`);
const RESULT_LIMIT = 5;
// Template parts, because 16 levels of indent is a bit much
const TEMPLATE_FILTERS_AND_CATEGORIES = /* xml */ `
<div class="flex mb-2">
<t t-if="state.query.trim()">
<button
class="flex items-center gap-1"
type="submit"
title="Run this filter"
t-on-pointerdown="() => this.updateParams(true)"
>
<h4 class="text-primary m-0">
Filter using
<t t-if="useRegExp">
regular expression
</t>
<t t-else="">
text
</t>
</h4>
<t t-esc="wrappedQuery" />
</button>
</t>
<t t-else="">
<em class="text-muted ms-1">
Start typing to show filters...
</em>
</t>
</div>
<t t-foreach="categories" t-as="category" t-key="category">
<t t-set="jobs" t-value="state.categories[category][0]" />
<t t-set="checkedCount" t-value="state.categories[category][1]" />
<t t-if="jobs?.length">
<div class="flex flex-col mb-2 max-h-48 overflow-hidden">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
<t t-esc="title(category)" />
(<t t-esc="checkedCount" />)
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-set="remainingCount" t-value="state.categories[category][2]" />
<t t-foreach="jobs" t-as="job" t-key="job.id">
${templateIncludeWidget("li")}
</t>
<t t-if="remainingCount > 0">
<div class="italic">
<t t-esc="remainingCount" /> more items ...
</div>
</t>
</ul>
</div>
</t>
</t>
`;
const TEMPLATE_SEARCH_DASHBOARD = /* xml */ `
<div class="flex flex-col gap-4 sm:grid sm:grid-cols-3 sm:gap-0">
<div class="flex flex-col sm:px-4">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Recent searches
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getLatestSearches()" t-as="text" t-key="text_index">
<li>
<button
class="w-full px-2 hover:bg-gray-300 dark:hover:bg-gray-700"
type="button"
t-on-click.stop="() => this.setQuery(text)"
t-esc="text"
/>
</li>
</t>
</ul>
</div>
<div class="flex flex-col sm:px-4 border-muted sm:border-x">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Available suites
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getTop(env.runner.rootSuites)" t-as="job" t-key="job.id">
<t t-set="category" t-value="'suites'" />
${templateIncludeWidget("li")}
</t>
</ul>
</div>
<div class="flex flex-col sm:px-4">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Available tags
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getTop(env.runner.tags.values())" t-as="job" t-key="job.id">
<t t-set="category" t-value="'tags'" />
${templateIncludeWidget("li")}
</t>
</ul>
</div>
</div>
`;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootSearchProps, import("../hoot").Environment>} */
export class HootSearch extends Component {
static components = { HootTagButton };
static props = {};
static template = xml`
<t t-set="hasIncludeValue" t-value="getHasIncludeValue()" />
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
<search class="HootSearch flex-1" t-ref="root" t-on-keydown="onKeyDown">
<form class="relative" t-on-submit.prevent="refresh">
<div class="hoot-search-bar flex border rounded items-center bg-base px-1 gap-1 w-full transition-colors">
<t t-foreach="getCategoryCounts()" t-as="count" t-key="count.category">
<button
type="button"
class="flex border border-primary rounded"
t-att-title="count.tip"
>
<span class="bg-btn px-1 transition-colors" t-esc="count.category" />
<span class="mx-1 flex gap-1">
<t t-if="count.include">
<span class="text-pass" t-esc="count.include" />
</t>
<t t-if="count.exclude">
<span class="text-fail" t-esc="count.exclude" />
</t>
</span>
</button>
</t>
<input
type="search"
class="w-full rounded p-1 outline-none"
autofocus="autofocus"
placeholder="Filter suites, tests or tags"
t-ref="search-input"
t-att-disabled="isRunning"
t-att-value="state.query"
t-on-change="onSearchInputChange"
t-on-input="onSearchInputInput"
t-on-keydown="onSearchInputKeyDown"
/>
<label
class="hoot-search-icon cursor-pointer p-1"
title="Use regular expression (Alt + R)"
tabindex="0"
t-on-keydown="onRegExpKeyDown"
>
<input
type="checkbox"
class="hidden"
t-att-checked="useRegExp"
t-att-disabled="isRunning"
t-on-change="toggleRegExp"
/>
<i class="fa fa-asterisk text-muted transition-colors" />
</label>
<label
class="hoot-search-icon p-1"
title="Debug mode (Alt + D)"
t-on-keydown="onDebugKeyDown"
>
<input
type="checkbox"
class="hidden"
t-att-checked="config.debugTest"
t-att-disabled="isRunning"
t-on-change="toggleDebug"
/>
<i class="fa fa-bug text-muted transition-colors" />
</label>
</div>
<t t-if="state.showDropdown">
<div class="hoot-dropdown-lg flex flex-col animate-slide-down bg-base text-base absolute mt-1 p-3 shadow rounded shadow z-2">
<t t-if="state.empty">
${TEMPLATE_SEARCH_DASHBOARD}
</t>
<t t-else="">
${TEMPLATE_FILTERS_AND_CATEGORIES}
</t>
</div>
</t>
</form>
</search>
`;
categories = ["suites", "tests", "tags"];
useTextFilter = false;
refresh = refresh;
title = title;
get useRegExp() {
return isRegExpFilter(this.state.query.trim());
}
get wrappedQuery() {
const query = this.state.query.trim();
return this.useRegExp ? query : stringify(query);
}
updateSuggestions = debounce(() => {
this.state.categories = this.findSuggestions();
this.state.showDropdown = true;
}, 16);
setup() {
const { runner } = this.env;
runner.beforeAll(() => {
this.state.categories = this.findSuggestions();
this.state.empty &&= !this.hasFilters();
});
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[]} */
suites: [],
/** @type {Tag[]} */
tags: [],
/** @type {Test[]} */
tests: [],
},
disabled: false,
empty: !query.trim(),
query,
showDropdown: false,
});
this.runnerState = useState(runner.state);
useWindowListener(
"click",
(ev) => {
if (this.runnerState.status !== "running") {
this.state.showDropdown = ev.composedPath().includes(this.rootRef.el);
}
},
{ capture: true }
);
}
/**
* @param {string} query
* @param {Map<string, Suite | Tag | Test>} items
* @param {SearchCategory} category
*/
filterItems(query, items, category) {
const checked = this.runnerState.includeSpecs[category];
const result = [];
const remaining = [];
let checkedCount = 0;
for (const item of items.values()) {
const value = Math.abs(checked[item.id]);
if (value === INCLUDE_LEVEL.url) {
result.push(item);
checkedCount++;
} else {
remaining.push(item);
}
}
const matching = lookup(query, remaining);
result.push(...matching.slice(0, RESULT_LIMIT));
return [result, checkedCount, matching.length - RESULT_LIMIT];
}
findSuggestions() {
const { suites, tags, tests } = this.env.runner;
const pattern = getPattern(this.state.query);
return {
suites: this.filterItems(pattern, suites, "suites"),
tags: this.filterItems(pattern, tags, "tags"),
tests: this.filterItems(pattern, tests, "tests"),
};
}
focusSearchInput() {
this.searchInputRef.el?.focus();
}
getCategoryCounts() {
const includeSpecs = this.runnerState.includeSpecs;
const counts = [];
for (const category of this.categories) {
let include = 0;
let exclude = 0;
for (const value of $values(includeSpecs[category])) {
switch (value) {
case 1:
case 2: {
include++;
break;
}
case -1:
case -2: {
exclude++;
break;
}
}
}
if (include + exclude) {
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<Suite | Tag>} items
*/
getTop(items) {
return [...items].sort((a, b) => b.weight - a.weight).slice(0, 5);
}
hasFilters() {
return Boolean(
this.state.query.trim() ||
$values(this.runnerState.includeSpecs).some((values) =>
$values(values).some((value) => Math.abs(value) === INCLUDE_LEVEL.url)
)
);
}
/**
* @param {number} value
*/
isReadonly(value) {
return Math.abs(value) > 1;
}
/**
* @param {unknown} item
*/
isTag(item) {
return item instanceof Tag;
}
/**
* @param {SearchCategory} categoryId
* @param {string} id
* @param {"exclude" | "include"} value
*/
onIncludeChange(categoryId, id, value) {
if (value === "include" || value === "exclude") {
this.setInclude(categoryId, id, value === "include" ? +1 : -1);
} else {
this.setInclude(categoryId, id, 0);
}
}
/**
* @param {KeyboardEvent} ev
*/
onKeyDown(ev) {
/**
* @param {number} inc
*/
const navigate = (inc) => {
ev.preventDefault();
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();
};
switch (ev.key) {
case "Escape": {
if (this.state.showDropdown) {
ev.preventDefault();
this.state.showDropdown = false;
}
return;
}
case "ArrowDown": {
return navigate(+1);
}
case "ArrowUp": {
return navigate(-1);
}
}
}
/**
* @param {KeyboardEvent} ev
*/
onRegExpKeyDown(ev) {
switch (ev.key) {
case "Enter":
case " ": {
ev.preventDefault();
this.toggleRegExp();
break;
}
}
}
onSearchInputChange() {
if (!this.state.query) {
return;
}
const latestSearches = this.getLatestSearches();
latestSearches.unshift(this.state.query);
storageSet(STORAGE.searches, [...new Set(latestSearches)].slice(0, 5));
}
/**
* @param {InputEvent & { currentTarget: HTMLInputElement }} ev
*/
onSearchInputInput(ev) {
this.state.query = ev.currentTarget.value;
this.state.empty = !this.hasFilters();
this.env.ui.resultsPage = 0;
this.updateParams(true);
this.updateSuggestions();
}
/**
* @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev
*/
onSearchInputKeyDown(ev) {
switch (ev.key) {
case "Backspace": {
if (ev.currentTarget.selectionStart === 0 && ev.currentTarget.selectionEnd === 0) {
this.uncheckLastCategory();
this.state.empty = !this.hasFilters();
}
break;
}
case "r": {
if (ev.altKey) {
this.toggleRegExp();
}
break;
}
}
if (this.config.fun) {
this.verifySecretSequenceStep(ev);
}
}
/**
* @param {SearchCategory} categoryId
* @param {string} id
* @param {number} [value]
*/
setInclude(categoryId, id, value) {
if (value) {
this.runnerState.includeSpecs[categoryId][id] = value;
} else {
delete this.runnerState.includeSpecs[categoryId][id];
if (!this.hasFilters()) {
this.state.empty = true;
}
}
this.updateParams(false);
}
/**
* @param {string} query
*/
setQuery(query) {
this.state.query = query;
this.state.empty = false;
this.updateParams(true);
this.updateSuggestions();
this.focusSearchInput();
}
toggleDebug() {
this.config.debugTest = !this.config.debugTest;
}
toggleRegExp() {
const query = this.state.query.trim();
if (this.useRegExp) {
this.state.query = query.slice(1, -1);
} else {
this.state.query = `/${query}/`;
}
this.updateParams(true);
this.updateSuggestions();
}
uncheckLastCategory() {
const checked = this.runnerState.includeSpecs;
for (const category of [...this.categories].reverse()) {
let foundItemToUncheck = false;
for (const [key, value] of $entries(checked[category])) {
if (this.isReadonly(value)) {
continue;
}
foundItemToUncheck = true;
delete checked[category][key];
}
if (foundItemToUncheck) {
this.updateParams();
return true;
}
}
return false;
}
/**
* @param {boolean} [setUseTextFilter]
*/
updateParams(setUseTextFilter) {
if (typeof setUseTextFilter === "boolean") {
this.useTextFilter = setUseTextFilter;
}
if (this.useTextFilter) {
this.config.filter = this.state.query.trim();
this.config.suite = [];
this.config.tag = [];
this.config.test = [];
} else {
this.config.filter = "";
this.config.suite = formatIncludes(this.runnerState.includeSpecs.suites);
this.config.tag = formatIncludes(this.runnerState.includeSpecs.tags);
this.config.test = formatIncludes(this.runnerState.includeSpecs.tests);
}
}
/**
* @param {SearchCategory} categoryId
* @param {string} id
*/
toggleInclude(categoryId, id) {
const currentValue = this.runnerState.includeSpecs[categoryId][id];
if (currentValue > 1 || currentValue < -1) {
return; // readonly
}
if (currentValue > 0) {
this.setInclude(categoryId, id, -1);
} else if (currentValue < 0) {
this.setInclude(categoryId, id, 0);
} else {
this.setInclude(categoryId, id, +1);
}
}
/**
* @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.errors = [];
for (const assertion of result.assertions) {
assertion.pass = true;
}
}
}
this.__owl__.app.root.render(true);
console.warn("Secret sequence activated: all tests pass!");
}
}
}