LCOV - code coverage report
Current view: top level - pkg/shell - nav.tsx Coverage Total Hit
Test: cockpit Lines: 86.9 % 297 258
Test Date: 2026-06-25 11:17:56

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2024 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          341 : import cockpit from "cockpit";
       7              : 
       8          341 : import React, { useEffect, useState } from 'react';
       9              : 
      10              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      11              : import { Nav } from "@patternfly/react-core/dist/esm/components/Nav/index.js";
      12              : import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
      13              : import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
      14              : import { ContainerNodeIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from '@patternfly/react-icons';
      15              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
      16              : 
      17              : import { Status } from "notifications";
      18              : import { Location, encode_location, ManifestItem } from "./util.jsx";
      19              : import { ShellState } from "./state";
      20              : import { ManifestKeyword } from "./manifests";
      21              : 
      22          341 : const _ = cockpit.gettext;
      23              : 
      24          338 : export const SidebarToggle = () => {
      25          338 :     const [active, setActive] = useState(false);
      26              : 
      27          338 :     useEffect(() => {
      28              :         /* This is a HACK for catching lost clicks on the pages which live in iframes so as to close dropdown menus on the shell.
      29              :          * Note: Clicks on an <iframe> element won't trigger document.documentElement listeners, because it's literally different page with different security domain.
      30              :          * However, when clicking on an iframe moves focus to its content's window that triggers the main window.blur event.
      31              :          * Additionally, when clicking on an element in the same iframe make sure to unset the 'active' state of the 'System' dropdown selector.
      32              :          */
      33          295 :         const handleClickOutside = (ev: Event) => {
      34          295 :             if ((ev.target as Element).id == "nav-system-item")
      35          295 :                 return;
      36              : 
      37          295 :             setActive(false);
      38          295 :         };
      39              : 
      40          338 :         ["blur", "click"].map(ev_type => window.addEventListener(ev_type, handleClickOutside));
      41              : 
      42            0 :         return () => {
      43            0 :             ["blur", "click"].map(ev_type => window.removeEventListener(ev_type, handleClickOutside));
      44            0 :         };
      45          338 :     }, []);
      46              : 
      47          338 :     useEffect(() => {
      48          338 :         document.getElementById("nav-system")!.classList.toggle("interact", active);
      49          338 :     }, [active]);
      50              : 
      51          338 :     return (
      52          338 :         <Button icon={
      53          338 :             <Icon size="xl">
      54          338 :                 <ContainerNodeIcon />
      55          338 :             </Icon>}
      56           64 :             className={"pf-v6-c-select__toggle ct-nav-toggle " + (active ? "active" : "")}
      57          338 :                 id="nav-system-item" variant="plain"
      58            1 :                 onClick={() => setActive(!active)}>{_("System")}</Button>
      59              :     );
      60          338 : };
      61              : 
      62              : interface ItemGroup<T> {
      63              :     name: string;
      64              :     items: T[];
      65              :     action?: {
      66              :         label: string;
      67              :         target: Partial<Location>;
      68              :     } | undefined;
      69              : }
      70              : 
      71              : interface CockpitNavProps<T, X extends T> {
      72              :     groups: ItemGroup<T>[];
      73              :     selector: string;
      74              :     current: string;
      75              :     filtering: (item: T, term: string) => X | null;
      76              :     sorting: (a: X, b: X) => number;
      77              :     item_render: (item: X, term: string) => React.ReactNode;
      78              :     jump: (loc: Partial<Location>) => void;
      79              : }
      80              : 
      81              : interface CockpitNavState {
      82              :     search: string;
      83              :     current: string;
      84              : }
      85              : 
      86          341 : export class CockpitNav<T, X extends T> extends React.Component {
      87              :     props: CockpitNavProps<T, X>;
      88              :     state: CockpitNavState;
      89              : 
      90          338 :     constructor(props : CockpitNavProps<T, X>) {
      91          338 :         super(props);
      92              : 
      93          338 :         this.state = {
      94          338 :             search: "",
      95          338 :             current: props.current,
      96          338 :         };
      97              : 
      98          338 :         this.clearSearch = this.clearSearch.bind(this);
      99          338 :         this.props = props;
     100          338 :     }
     101              : 
     102          338 :     componentDidMount() {
     103          338 :         const sel = this.props.selector;
     104              :         // Click on active menu item (when using arrows to navigate through menu)
     105            0 :         function clickActiveItem() {
     106            0 :             const cur = document.activeElement;
     107            0 :             if (cur instanceof HTMLInputElement) {
     108            0 :                 const el = document.querySelector<HTMLElement>("#" + sel + " li:first-of-type a");
     109            0 :                 if (el)
     110            0 :                     el.click();
     111            0 :             } else if (cur instanceof HTMLElement) {
     112            0 :                 cur.click();
     113            0 :             } else {
     114            0 :                 console.error("Active element not a HTMLElement");
     115            0 :             }
     116            0 :         }
     117              : 
     118              :         // Move focus to next item in menu (when using arrows to navigate through menu)
     119              :         // With arguments it is possible to change direction
     120            0 :         function focusNextItem(begin: number, step: number) {
     121            0 :             const cur = document.activeElement;
     122            0 :             const all = Array.from(document.querySelectorAll<HTMLElement>("#" + sel + " li a"));
     123            0 :             if (cur instanceof HTMLInputElement && all.length > 0) {
     124            0 :                 if (begin < 0)
     125            0 :                     begin = all.length - 1;
     126            0 :                 all[begin].focus();
     127            0 :             } else {
     128            0 :                 let i = all.findIndex(item => item === cur);
     129            0 :                 i += step;
     130            0 :                 if (i < 0 || i >= all.length)
     131            0 :                     document.querySelector<HTMLElement>("#" + sel + " .pf-v6-c-text-input-group__text-input")?.focus();
     132              :                 else
     133            0 :                     all[i].focus();
     134            0 :             }
     135            0 :         }
     136              : 
     137            2 :         const navigate_apps = (ev: KeyboardEvent) => {
     138            2 :             if (ev.key == "Enter")
     139            1 :                 clickActiveItem();
     140            2 :             else if (ev.key == "ArrowDown")
     141            1 :                 focusNextItem(0, 1);
     142            2 :             else if (ev.key == "ArrowUp")
     143            1 :                 focusNextItem(-1, -1);
     144            1 :             else if (ev.key == "Escape") {
     145            1 :                 this.setState({ search: "" });
     146            1 :                 document.querySelector<HTMLElement>("#" + sel + " .pf-v6-c-text-input-group__text-input")?.focus();
     147            1 :             }
     148            2 :         };
     149              : 
     150          338 :         document.getElementById(sel)?.addEventListener("keyup", navigate_apps);
     151          338 :     }
     152              : 
     153          338 :     static getDerivedStateFromProps(nextProps: CockpitNavProps<void, void>, prevState: CockpitNavState) {
     154          338 :         if (nextProps.current !== prevState.current)
     155           90 :             return {
     156           90 :                 search: "",
     157           90 :                 current: nextProps.current,
     158           90 :             };
     159          338 :         return null;
     160          338 :     }
     161              : 
     162            0 :     clearSearch() {
     163            0 :         this.setState({ search: "" });
     164            0 :     }
     165              : 
     166          338 :     render() {
     167          338 :         const groups: ItemGroup<X>[] = [];
     168          338 :         const term = this.state.search.toLowerCase();
     169          338 :         this.props.groups.forEach(g => {
     170          338 :             const new_items = g.items.map(i => this.props.filtering(i, term)).filter(i => i != null);
     171          338 :             new_items.sort(this.props.sorting);
     172          338 :             if (new_items.length > 0)
     173          338 :                 groups.push({ name: g.name, items: new_items, action: g.action });
     174          338 :         });
     175              : 
     176          338 :         return (
     177          338 :             <>
     178            0 :                 <SearchInput placeholder={_("Search")} value={this.state.search} onChange={(_, search) => this.setState({ search })} onClear={() => this.setState({ search: "" })} className="search" />
     179          338 :                 <Nav>
     180          338 :                     { groups.map(g =>
     181          338 :                         <section className="pf-v6-c-nav__section" aria-labelledby={"section-title-" + g.name} key={g.name}>
     182          338 :                             <div className="nav-group-heading">
     183          338 :                                 <h2 className="pf-v6-c-nav__section-title" id={"section-title-" + g.name}>{g.name}</h2>
     184          338 :                                 { g.action &&
     185           63 :                                     <a className="pf-v6-c-nav__section-title nav-item"
     186           63 :                                         href={encode_location(g.action.target)}
     187            0 :                                         onClick={ ev => {
     188            0 :                                             if (g.action)
     189            0 :                                                 this.props.jump(g.action.target);
     190            0 :                                             ev.preventDefault();
     191            0 :                                         }}>
     192           63 :                                         {g.action.label}
     193           63 :                                     </a>
     194              :                                 }
     195          338 :                             </div>
     196          338 :                             <ul className="pf-v6-c-nav__list">
     197          338 :                                 {g.items.map(i => this.props.item_render(i, this.state.search.toLowerCase()))}
     198          338 :                             </ul>
     199          338 :                         </section>
     200          338 :                     )}
     201           63 :                     { groups.length < 1 && <span className="non-menu-item no-results">{_("No results found")}</span> }
     202           63 :                     { this.state.search !== "" && <span className="non-menu-item"><Button variant="link" onClick={this.clearSearch} className="nav-item-hint">{_("Clear search")}</Button></span> }
     203          338 :                 </Nav>
     204          338 :             </>
     205              :         );
     206          338 :     }
     207          341 : }
     208              : 
     209           26 : function PageStatus({ status, name } : { status: Status, name: string }) {
     210              :     // Generate name for the status
     211           26 :     const desc_parts = name.toLowerCase().split(" ");
     212            2 :     desc_parts.push(status.type || "");
     213           26 :     const desc = desc_parts.join("-");
     214              : 
     215           26 :     let statusIcon = <Icon status="info"><InfoCircleIcon /></Icon>;
     216           26 :     switch (status.type) {
     217           15 :     case "error":
     218           15 :         statusIcon = <Icon status="danger"><ExclamationCircleIcon /></Icon>;
     219           15 :         break;
     220            6 :     case "warning":
     221            6 :         statusIcon = <Icon status="warning"><ExclamationTriangleIcon /></Icon>;
     222            6 :         break;
     223           26 :     }
     224              : 
     225           26 :     return (
     226           26 :         <Tooltip id={desc + "-tooltip"} content={status.title}
     227           26 :                  position={TooltipPosition.right}>
     228           26 :             <span id={desc} className="nav-item-status">
     229           26 :                 {statusIcon}
     230           26 :             </span>
     231           26 :         </Tooltip>
     232              :     );
     233           26 : }
     234              : 
     235            1 : function FormattedText({ keyword, term } : { keyword: string, term: string }) {
     236            1 :     function split_text(text: string, term: string) {
     237            1 :         const b = text.toLowerCase().indexOf(term);
     238            1 :         const e = b + term.length;
     239            1 :         return [text.substring(0, b), text.substring(b, e), text.substring(e, text.length)];
     240            1 :     }
     241              : 
     242            1 :     const s = split_text(keyword, term);
     243            1 :     return (
     244            1 :         <>{s[0]}<mark>{s[1]}</mark>{s[2]}</>
     245              :     );
     246            1 : }
     247              : 
     248          338 : export function CockpitNavItem(props : {
     249              :     name: string;
     250              :     header?: string;
     251              :     className?: string;
     252              :     active: boolean;
     253              :     status: Status | null;
     254              :     keyword: string;
     255              :     term: string;
     256              :     href: string;
     257              :     onClick: () => void;
     258              :     actions?: React.ReactNode;
     259          338 : }) {
     260          338 :     const s = props.status;
     261          338 :     const name_matches = props.keyword === props.name.toLowerCase();
     262          338 :     let header_matches = false;
     263          338 :     if (props.header)
     264           70 :         header_matches = props.keyword === props.header.toLowerCase();
     265              : 
     266           70 :     const classes = props.className ? [props.className] : [];
     267          338 :     classes.push("pf-v6-c-nav__item", "nav-item");
     268              : 
     269          338 :     return (
     270          338 :         <li className={classes.join(" ")}>
     271          338 :             <a className={"pf-v6-c-nav__link" + (props.active ? " pf-m-current" : "")}
     272          338 :                 aria-current={props.active && "page"}
     273          338 :                 href={props.href}
     274           10 :                 onClick={ev => {
     275           10 :                     props.onClick();
     276           10 :                     ev.preventDefault();
     277           10 :                 }}>
     278          338 :                 <span className="pf-v6-c-nav__link-text">
     279           63 :                     { props.header && <span className="nav-item-hint">{header_matches ? <FormattedText keyword={props.header} term={props.term} /> : props.header}</span> }
     280          338 :                     <span className="nav-item-name">
     281           63 :                         { name_matches ? <FormattedText keyword={props.name} term={props.term} /> : props.name }
     282          338 :                     </span>
     283          338 :                 </span>
     284          338 :                 <span className="pf-v6-c-nav__link-icon">
     285           87 :                     {s && s.type && <PageStatus status={s} name={props.name} />}
     286          338 :                 </span>
     287           63 :                 { !name_matches && !header_matches && props.keyword && <span className="nav-item-hint nav-item-hint-contains">{_("Contains:")} <FormattedText keyword={props.keyword} term={props.term} /></span> }
     288          338 :             </a>
     289          338 :             <span className="nav-item-actions nav-host-action-buttons">
     290          338 :                 {props.actions}
     291          338 :             </span>
     292          338 :         </li>
     293              :     );
     294          338 : }
     295              : 
     296              : interface PageKeyword {
     297              :     keyword: string;
     298              :     score: number;
     299              :     goto: string | null;
     300              : }
     301              : 
     302              : interface PageItem extends ManifestItem {
     303              :     keyword: PageKeyword;
     304              : }
     305              : 
     306          338 : export const PageNav = ({ state } : { state: ShellState }) => {
     307          338 :     const {
     308          338 :         current_machine,
     309          338 :         current_manifest_item,
     310          338 :         current_machine_manifest_items,
     311          338 :         page_status,
     312          338 :     } = state;
     313              : 
     314          338 :     if (!current_machine || current_machine.state != "connected")
     315           73 :         return null;
     316              : 
     317          338 :     cockpit.assert(current_machine_manifest_items && current_manifest_item);
     318              : 
     319              :     // Filtering of navigation by term
     320          338 :     function keyword_filter(item: ManifestItem, term: string): PageItem | null {
     321            1 :         function keyword_relevance(current_best: PageKeyword, item: ManifestKeyword) {
     322            1 :             const translate = item.translate || false;
     323            1 :             const weight = item.weight || 0;
     324            1 :             let score;
     325            1 :             let _m = "";
     326            1 :             let best: PageKeyword = { keyword: "", score: -1, goto: null };
     327            1 :             item.matches.forEach(m => {
     328            1 :                 if (translate)
     329            1 :                     _m = _(m);
     330            1 :                 score = -1;
     331              :                 // Best score when starts in translate language
     332            1 :                 if (translate && _m.indexOf(term) == 0)
     333            1 :                     score = 4 + weight;
     334              :                 // Second best score when starts in English
     335            1 :                 else if (m.indexOf(term) == 0)
     336            1 :                     score = 3 + weight;
     337              :                 // Substring consider only when at least 3 letters were used
     338            1 :                 else if (term.length >= 3) {
     339            1 :                     if (translate && _m.indexOf(term) >= 0)
     340            1 :                         score = 2 + weight;
     341            1 :                     else if (m.indexOf(term) >= 0)
     342            1 :                         score = 1 + weight;
     343            1 :                 }
     344            1 :                 if (score > best.score) {
     345            1 :                     best = { keyword: m, score, goto: item.goto || null };
     346            1 :                 }
     347            1 :             });
     348            1 :             if (best.score > current_best.score) {
     349            1 :                 current_best = best;
     350            1 :             }
     351            1 :             return current_best;
     352            1 :         }
     353              : 
     354          338 :         const new_item: PageItem = Object.assign({ keyword: { keyword: "", score: -1, goto: null } }, item);
     355          338 :         if (!term)
     356          338 :             return new_item;
     357           63 :         const best_keyword = new_item.keywords.reduce(keyword_relevance, { keyword: "", score: -1, goto: null });
     358           63 :         if (best_keyword.score > -1) {
     359           63 :             new_item.keyword = best_keyword;
     360           63 :             return new_item;
     361           63 :         }
     362           63 :         return null;
     363          338 :     }
     364              : 
     365              :     // Rendering of separate navigation menu items
     366          338 :     function nav_item(item: PageItem, term: string) {
     367          338 :         const active = current_manifest_item?.path === item.path;
     368              : 
     369              :         // Parse path
     370          338 :         let path = item.path;
     371          338 :         let hash = item.hash;
     372           63 :         if (item.keyword.goto) {
     373           63 :             if (item.keyword.goto[0] === "/")
     374           63 :                 path = item.keyword.goto.substring(1);
     375              :             else
     376           63 :                 hash = item.keyword.goto;
     377           63 :         }
     378              : 
     379              :         // Parse page status
     380          338 :         let status = null;
     381          338 :         if (page_status[current_machine!.key])
     382           89 :             status = page_status[current_machine!.key][item.path];
     383              : 
     384          338 :         const target_location = { host: current_machine!.address, path, hash };
     385              : 
     386          338 :         return (
     387          338 :             <CockpitNavItem key={item.label}
     388          338 :                             name={item.label}
     389          338 :                             active={active}
     390          338 :                             status={status}
     391          338 :                             keyword={item.keyword.keyword}
     392          338 :                             term={term}
     393          338 :                             href={encode_location(target_location)}
     394            6 :                             onClick={() => state.jump(target_location)} />
     395              :         );
     396          338 :     }
     397              : 
     398          338 :     const groups: ItemGroup<ManifestItem>[] = [
     399          338 :         {
     400          338 :             name: _("Apps"),
     401          338 :             items: current_machine_manifest_items.ordered("dashboard"),
     402          338 :         }, {
     403          338 :             name: _("System"),
     404          338 :             items: current_machine_manifest_items.ordered("menu"),
     405          338 :         }, {
     406          338 :             name: _("Tools"),
     407          338 :             items: current_machine_manifest_items.ordered("tools"),
     408          338 :         }
     409          338 :     ].filter(i => i.items.length > 0);
     410              : 
     411          337 :     if (current_machine_manifest_items.items.apps && groups.length === 3)
     412           63 :         groups[0].action = {
     413           63 :             label: _("Edit"),
     414           63 :             target: {
     415           63 :                 host: current_machine.address,
     416           63 :                 path: current_machine_manifest_items.items.apps.path,
     417           63 :             }
     418           63 :         };
     419              : 
     420          338 :     return <CockpitNav groups={groups}
     421          338 :                        selector="host-apps"
     422          338 :                        item_render={nav_item}
     423          338 :                        filtering={keyword_filter}
     424          338 :                        sorting={(a, b) => { return b.keyword.score - a.keyword.score }}
     425          338 :                        current={current_manifest_item.path}
     426          338 :                        jump={state.jump} />;
     427          338 : };
        

Generated by: LCOV version 2.0-1