/** @odoo-module */ import { Component, onWillRender, useRef, useState, xml } from "@odoo/owl"; import { Suite } from "../core/suite"; import { createUrlFromId } from "../core/url"; import { lookup, normalize } from "../hoot_utils"; import { HootJobButtons } from "./hoot_job_buttons"; /** * @typedef {{ * multi?: number; * name: string; * hasSuites: boolean; * reporting: import("../hoot_utils").Reporting; * selected: boolean; * unfolded: boolean; * }} HootSideBarSuiteProps * * @typedef {{ * reporting: import("../hoot_utils").Reporting; * statusFilter: import("./setup_hoot_ui").StatusFilter | null; * }} HootSideBarCounterProps * * @typedef {{ * }} HootSideBarProps */ //----------------------------------------------------------------------------- // Global //----------------------------------------------------------------------------- const { Boolean, location: actualLocation, Object, String } = globalThis; //----------------------------------------------------------------------------- // Internal //----------------------------------------------------------------------------- const SUITE_CLASSNAME = "hoot-sidebar-suite"; //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * @extends {Component} */ export class HootSideBarSuite extends Component { static props = { multi: { type: Number, optional: true }, name: String, hasSuites: Boolean, reporting: Object, selected: Boolean, unfolded: Boolean, }; static template = xml` x `; getClassName() { const { reporting, selected } = this.props; let className = "truncate transition"; if (reporting.failed) { className += " text-fail"; } else if (!reporting.tests) { className += " opacity-25"; } if (selected) { className += " font-bold"; } return className; } } /** @extends {Component} */ export class HootSideBarCounter extends Component { static props = { reporting: Object, statusFilter: [String, { value: null }], }; static template = xml` `; getCounterInfo() { const { reporting, statusFilter } = this.props; switch (statusFilter) { case "failed": return ["text-fail", reporting.failed]; case "passed": return ["text-pass", reporting.passed]; case "skipped": return ["text-skip", reporting.skipped]; case "todo": return ["text-todo", reporting.todo]; default: return ["text-primary", reporting.tests]; } } } /** * @extends {Component} */ export class HootSideBar extends Component { static components = { HootJobButtons, HootSideBarSuite, HootSideBarCounter }; static props = {}; static template = xml` `; filteredSuites = []; runningSuites = new Set(); unfoldedIds = new Set(); setup() { const { runner, ui } = this.env; this.searchInputRef = useRef("search-input"); this.suitesListRef = useRef("suites-list"); this.uiState = useState(ui); this.state = useState({ filter: "", suites: [], /** @type {Set} */ unfoldedIds: new Set(), }); runner.beforeAll(() => { const singleRootSuite = runner.rootSuites.filter((suite) => suite.currentJobs.length); if (singleRootSuite.length === 1) { // Unfolds only root suite containing jobs this.unfoldAndSelect(singleRootSuite[0]); } }); onWillRender(() => { [this.filteredSuites, this.unfoldedIds] = this.getFilteredVisibleSuites(); }); } /** * Filters */ getFilteredVisibleSuites() { let allowedIds; let unfoldedIds; let rootSuites; const { runner } = this.env; const allSuites = runner.suites.values(); // Filtering suites const nFilter = normalize(this.state.filter); if (nFilter) { allowedIds = new Set(); unfoldedIds = new Set(this.state.unfoldedIds); rootSuites = new Set(); for (const matchingSuite of lookup(nFilter, allSuites, "name")) { for (const suite of matchingSuite.path) { allowedIds.add(suite.id); unfoldedIds.add(suite.id); if (!suite.parent) { rootSuites.add(suite); } } } } else { unfoldedIds = this.state.unfoldedIds; rootSuites = runner.rootSuites; } // Computing unfolded suites /** * @param {Suite} suite */ const addSuite = (suite) => { if (!(suite instanceof Suite) || (allowedIds && !allowedIds.has(suite.id))) { return; } unfoldedSuites.push(suite); if (!unfoldedIds.has(suite.id)) { return; } for (const child of suite.jobs) { addSuite(child); } }; const unfoldedSuites = []; for (const suite of rootSuites) { addSuite(suite); } return [unfoldedSuites, unfoldedIds]; } getSuiteElements() { return this.suitesListRef.el ? [...this.suitesListRef.el.getElementsByClassName(SUITE_CLASSNAME)] : []; } /** * @param {import("../core/job").Job} job */ hasSuites(job) { return job.jobs.some((subJob) => subJob instanceof Suite); } onClick() { // Unselect suite when clicking outside of a suite & in the side bar this.uiState.selectedSuiteId = null; this.uiState.resultsPage = 0; } /** * @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev */ onSearchInputKeydown(ev) { switch (ev.key) { case "ArrowDown": { if (ev.currentTarget.selectionEnd === ev.currentTarget.value.length) { const suiteElements = this.getSuiteElements(); suiteElements[0]?.focus(); } } } } /** * @param {KeyboardEvent & { currentTarget: HTMLButtonElement }} ev * @param {Suite} suite */ onSuiteKeydown(ev, suite) { /** * @param {number} delta */ const selectElementAt = (delta) => { const suiteElements = this.getSuiteElements(); const nextIndex = suiteElements.indexOf(ev.currentTarget) + delta; if (nextIndex < 0) { this.searchInputRef.el?.focus(); } else if (nextIndex >= suiteElements.length) { suiteElements[0].focus(); } else { suiteElements[nextIndex].focus(); } }; switch (ev.key) { case "ArrowDown": { return selectElementAt(+1); } case "ArrowLeft": { if (this.state.unfoldedIds.has(suite.id)) { return this.toggleItem(suite, false); } else { return selectElementAt(-1); } } case "ArrowRight": { if (this.state.unfoldedIds.has(suite.id)) { return selectElementAt(+1); } else { return this.toggleItem(suite, true); } } case "ArrowUp": { return selectElementAt(-1); } case "Enter": { ev.preventDefault(); actualLocation.href = createUrlFromId(suite.id, "suite"); } } } /** * @param {boolean} expanded */ toggleExpand(expanded) { if (expanded) { this.state.unfoldedIds.clear(); } else { for (const { id } of this.env.runner.suites.values()) { this.state.unfoldedIds.add(id); } } } /** * @param {Suite} suite * @param {boolean} [forceAdd] */ toggleItem(suite, forceAdd) { if (this.uiState.selectedSuiteId !== suite.id) { this.uiState.selectedSuiteId = suite.id; this.uiState.resultsPage = 0; if (this.state.unfoldedIds.has(suite.id)) { return; } } if (forceAdd ?? !this.state.unfoldedIds.has(suite.id)) { this.unfoldAndSelect(suite); } else { this.state.unfoldedIds.delete(suite.id); } } /** * @param {Suite} suite */ unfoldAndSelect(suite) { this.state.unfoldedIds.add(suite.id); while (suite.currentJobs.length === 1) { suite = suite.currentJobs[0]; if (!(suite instanceof Suite)) { break; } this.state.unfoldedIds.add(suite.id); this.uiState.selectedSuiteId = suite.id; this.uiState.resultsPage = 0; } } }