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 09:20:42

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2024 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          339 : import cockpit from "cockpit";
       7              : 
       8          339 : 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          339 : const _ = cockpit.gettext;
      23              : 
      24          336 : export const SidebarToggle = () => {
      25          336 :     const [active, setActive] = useState(false);
      26              : 
      27          336 :     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          293 :         const handleClickOutside = (ev: Event) => {
      34          293 :             if ((ev.target as Element).id == "nav-system-item")
      35          293 :                 return;
      36              : 
      37          293 :             setActive(false);
      38          293 :         };
      39              : 
      40          336 :         ["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          336 :     }, []);
      46              : 
      47          336 :     useEffect(() => {
      48          336 :         document.getElementById("nav-system")!.classList.toggle("interact", active);
      49          336 :     }, [active]);
      50              : 
      51          336 :     return (
      52          336 :         <Button icon={
      53          336 :             <Icon size="xl">
      54          336 :                 <ContainerNodeIcon />
      55          336 :             </Icon>}
      56           63 :             className={"pf-v6-c-select__toggle ct-nav-toggle " + (active ? "active" : "")}
      57          336 :                 id="nav-system-item" variant="plain"
      58            1 :                 onClick={() => setActive(!active)}>{_("System")}</Button>
      59              :     );
      60          336 : };
      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          339 : export class CockpitNav<T, X extends T> extends React.Component {
      87              :     props: CockpitNavProps<T, X>;
      88              :     state: CockpitNavState;
      89              : 
      90          336 :     constructor(props : CockpitNavProps<T, X>) {
      91          336 :         super(props);
      92              : 
      93          336 :         this.state = {
      94          336 :             search: "",
      95          336 :             current: props.current,
      96          336 :         };
      97              : 
      98          336 :         this.clearSearch = this.clearSearch.bind(this);
      99          336 :         this.props = props;
     100          336 :     }
     101              : 
     102          336 :     componentDidMount() {
     103          336 :         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          336 :         document.getElementById(sel)?.addEventListener("keyup", navigate_apps);
     151          336 :     }
     152              : 
     153          336 :     static getDerivedStateFromProps(nextProps: CockpitNavProps<void, void>, prevState: CockpitNavState) {
     154          336 :         if (nextProps.current !== prevState.current)
     155           89 :             return {
     156           89 :                 search: "",
     157           89 :                 current: nextProps.current,
     158           89 :             };
     159          336 :         return null;
     160          336 :     }
     161              : 
     162            0 :     clearSearch() {
     163            0 :         this.setState({ search: "" });
     164            0 :     }
     165              : 
     166          336 :     render() {
     167          336 :         const groups: ItemGroup<X>[] = [];
     168          336 :         const term = this.state.search.toLowerCase();
     169          336 :         this.props.groups.forEach(g => {
     170          336 :             const new_items = g.items.map(i => this.props.filtering(i, term)).filter(i => i != null);
     171          336 :             new_items.sort(this.props.sorting);
     172          336 :             if (new_items.length > 0)
     173          336 :                 groups.push({ name: g.name, items: new_items, action: g.action });
     174          336 :         });
     175              : 
     176          336 :         return (
     177          336 :             <>
     178            0 :                 <SearchInput placeholder={_("Search")} value={this.state.search} onChange={(_, search) => this.setState({ search })} onClear={() => this.setState({ search: "" })} className="search" />
     179          336 :                 <Nav>
     180          336 :                     { groups.map(g =>
     181          336 :                         <section className="pf-v6-c-nav__section" aria-labelledby={"section-title-" + g.name} key={g.name}>
     182          336 :                             <div className="nav-group-heading">
     183          336 :                                 <h2 className="pf-v6-c-nav__section-title" id={"section-title-" + g.name}>{g.name}</h2>
     184          336 :                                 { g.action &&
     185           62 :                                     <a className="pf-v6-c-nav__section-title nav-item"
     186           62 :                                         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           62 :                                         {g.action.label}
     193           62 :                                     </a>
     194              :                                 }
     195          336 :                             </div>
     196          336 :                             <ul className="pf-v6-c-nav__list">
     197          336 :                                 {g.items.map(i => this.props.item_render(i, this.state.search.toLowerCase()))}
     198          336 :                             </ul>
     199          336 :                         </section>
     200          336 :                     )}
     201           62 :                     { groups.length < 1 && <span className="non-menu-item no-results">{_("No results found")}</span> }
     202           62 :                     { this.state.search !== "" && <span className="non-menu-item"><Button variant="link" onClick={this.clearSearch} className="nav-item-hint">{_("Clear search")}</Button></span> }
     203          336 :                 </Nav>
     204          336 :             </>
     205              :         );
     206          336 :     }
     207          339 : }
     208              : 
     209           27 : function PageStatus({ status, name } : { status: Status, name: string }) {
     210              :     // Generate name for the status
     211           27 :     const desc_parts = name.toLowerCase().split(" ");
     212            2 :     desc_parts.push(status.type || "");
     213           27 :     const desc = desc_parts.join("-");
     214              : 
     215           27 :     let statusIcon = <Icon status="info"><InfoCircleIcon /></Icon>;
     216           27 :     switch (status.type) {
     217           16 :     case "error":
     218           16 :         statusIcon = <Icon status="danger"><ExclamationCircleIcon /></Icon>;
     219           16 :         break;
     220            6 :     case "warning":
     221            6 :         statusIcon = <Icon status="warning"><ExclamationTriangleIcon /></Icon>;
     222            6 :         break;
     223           27 :     }
     224              : 
     225           27 :     return (
     226           27 :         <Tooltip id={desc + "-tooltip"} content={status.title}
     227           27 :                  position={TooltipPosition.right}>
     228           27 :             <span id={desc} className="nav-item-status">
     229           27 :                 {statusIcon}
     230           27 :             </span>
     231           27 :         </Tooltip>
     232              :     );
     233           27 : }
     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          336 : 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          336 : }) {
     260          336 :     const s = props.status;
     261          336 :     const name_matches = props.keyword === props.name.toLowerCase();
     262          336 :     let header_matches = false;
     263          336 :     if (props.header)
     264           69 :         header_matches = props.keyword === props.header.toLowerCase();
     265              : 
     266           69 :     const classes = props.className ? [props.className] : [];
     267          336 :     classes.push("pf-v6-c-nav__item", "nav-item");
     268              : 
     269          336 :     return (
     270          336 :         <li className={classes.join(" ")}>
     271          336 :             <a className={"pf-v6-c-nav__link" + (props.active ? " pf-m-current" : "")}
     272          336 :                 aria-current={props.active && "page"}
     273          336 :                 href={props.href}
     274           10 :                 onClick={ev => {
     275           10 :                     props.onClick();
     276           10 :                     ev.preventDefault();
     277           10 :                 }}>
     278          336 :                 <span className="pf-v6-c-nav__link-text">
     279           62 :                     { props.header && <span className="nav-item-hint">{header_matches ? <FormattedText keyword={props.header} term={props.term} /> : props.header}</span> }
     280          336 :                     <span className="nav-item-name">
     281           62 :                         { name_matches ? <FormattedText keyword={props.name} term={props.term} /> : props.name }
     282          336 :                     </span>
     283          336 :                 </span>
     284          336 :                 <span className="pf-v6-c-nav__link-icon">
     285           87 :                     {s && s.type && <PageStatus status={s} name={props.name} />}
     286          336 :                 </span>
     287           62 :                 { !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          336 :             </a>
     289          336 :             <span className="nav-item-actions nav-host-action-buttons">
     290          336 :                 {props.actions}
     291          336 :             </span>
     292          336 :         </li>
     293              :     );
     294          336 : }
     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          336 : export const PageNav = ({ state } : { state: ShellState }) => {
     307          336 :     const {
     308          336 :         current_machine,
     309          336 :         current_manifest_item,
     310          336 :         current_machine_manifest_items,
     311          336 :         page_status,
     312          336 :     } = state;
     313              : 
     314          336 :     if (!current_machine || current_machine.state != "connected")
     315           72 :         return null;
     316              : 
     317          336 :     cockpit.assert(current_machine_manifest_items && current_manifest_item);
     318              : 
     319              :     // Filtering of navigation by term
     320          336 :     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          336 :         const new_item: PageItem = Object.assign({ keyword: { keyword: "", score: -1, goto: null } }, item);
     355          336 :         if (!term)
     356          336 :             return new_item;
     357           62 :         const best_keyword = new_item.keywords.reduce(keyword_relevance, { keyword: "", score: -1, goto: null });
     358           62 :         if (best_keyword.score > -1) {
     359           62 :             new_item.keyword = best_keyword;
     360           62 :             return new_item;
     361           62 :         }
     362           62 :         return null;
     363          336 :     }
     364              : 
     365              :     // Rendering of separate navigation menu items
     366          336 :     function nav_item(item: PageItem, term: string) {
     367          336 :         const active = current_manifest_item?.path === item.path;
     368              : 
     369              :         // Parse path
     370          336 :         let path = item.path;
     371          336 :         let hash = item.hash;
     372           62 :         if (item.keyword.goto) {
     373           62 :             if (item.keyword.goto[0] === "/")
     374           62 :                 path = item.keyword.goto.substring(1);
     375              :             else
     376           62 :                 hash = item.keyword.goto;
     377           62 :         }
     378              : 
     379              :         // Parse page status
     380          336 :         let status = null;
     381          336 :         if (page_status[current_machine!.key])
     382           89 :             status = page_status[current_machine!.key][item.path];
     383              : 
     384          336 :         const target_location = { host: current_machine!.address, path, hash };
     385              : 
     386          336 :         return (
     387          336 :             <CockpitNavItem key={item.label}
     388          336 :                             name={item.label}
     389          336 :                             active={active}
     390          336 :                             status={status}
     391          336 :                             keyword={item.keyword.keyword}
     392          336 :                             term={term}
     393          336 :                             href={encode_location(target_location)}
     394            6 :                             onClick={() => state.jump(target_location)} />
     395              :         );
     396          336 :     }
     397              : 
     398          336 :     const groups: ItemGroup<ManifestItem>[] = [
     399          336 :         {
     400          336 :             name: _("Apps"),
     401          336 :             items: current_machine_manifest_items.ordered("dashboard"),
     402          336 :         }, {
     403          336 :             name: _("System"),
     404          336 :             items: current_machine_manifest_items.ordered("menu"),
     405          336 :         }, {
     406          336 :             name: _("Tools"),
     407          336 :             items: current_machine_manifest_items.ordered("tools"),
     408          336 :         }
     409          336 :     ].filter(i => i.items.length > 0);
     410              : 
     411          335 :     if (current_machine_manifest_items.items.apps && groups.length === 3)
     412           62 :         groups[0].action = {
     413           62 :             label: _("Edit"),
     414           62 :             target: {
     415           62 :                 host: current_machine.address,
     416           62 :                 path: current_machine_manifest_items.items.apps.path,
     417           62 :             }
     418           62 :         };
     419              : 
     420          336 :     return <CockpitNav groups={groups}
     421          336 :                        selector="host-apps"
     422          336 :                        item_render={nav_item}
     423          336 :                        filtering={keyword_filter}
     424          336 :                        sorting={(a, b) => { return b.keyword.score - a.keyword.score }}
     425          336 :                        current={current_manifest_item.path}
     426          336 :                        jump={state.jump} />;
     427          336 : };
        

Generated by: LCOV version 2.0-1