LCOV - code coverage report
Current view: top level - pkg/shell - nav.tsx Coverage Total Hit
Test: cockpit Lines: 71.4 % 297 212
Test Date: 2026-06-16 14:09:37

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

Generated by: LCOV version 2.0-1