* Copyright (C) 2024 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useEffect, useState } from 'react';
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Nav } from "@patternfly/react-core/dist/esm/components/Nav/index.js";
import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { ContainerNodeIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from '@patternfly/react-icons';
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
import { Status } from "notifications";
import { Location, encode_location, ManifestItem } from "./util.jsx";
import { ShellState } from "./state";
import { ManifestKeyword } from "./manifests";
const _ = cockpit.gettext;
export const SidebarToggle = () => {
const [active, setActive] = useState(false);
useEffect(() => {
* Note: Clicks on an <iframe> element won't trigger document.documentElement listeners, because it's literally different page with different security domain.
* However, when clicking on an iframe moves focus to its content's window that triggers the main window.blur event.
* Additionally, when clicking on an element in the same iframe make sure to unset the 'active' state of the 'System' dropdown selector.
*/
const handleClickOutside = (ev: Event) => {
if ((ev.target as Element).id === "nav-system-item")
return;
setActive(false);
};
["blur", "click"].map(ev_type => window.addEventListener(ev_type, handleClickOutside));
return () => {
["blur", "click"].map(ev_type => window.removeEventListener(ev_type, handleClickOutside));
};
}, []);
useEffect(() => {
document.getElementById("nav-system")!.classList.toggle("interact", active);
}, [active]);
return (
<Button icon={
<Icon size="xl">
<ContainerNodeIcon />
</Icon>}
className={"pf-v6-c-select__toggle ct-nav-toggle " + (active ? "active" : "")}
id="nav-system-item" variant="plain"
onClick={() => setActive(!active)}>{_("System")}</Button>
);
};
interface ItemGroup<T> {
name: string;
items: T[];
action?: {
label: string;
target: Partial<Location>;
} | undefined;
}
interface CockpitNavProps<T, X extends T> {
groups: ItemGroup<T>[];
selector: string;
current: string;
filtering: (item: T, term: string) => X | null;
sorting: (a: X, b: X) => number;
item_render: (item: X, term: string) => React.ReactNode;
jump: (loc: Partial<Location>) => void;
}
interface CockpitNavState {
search: string;
current: string;
}
export class CockpitNav<T, X extends T> extends React.Component {
props: CockpitNavProps<T, X>;
state: CockpitNavState;
constructor(props : CockpitNavProps<T, X>) {
super(props);
this.state = {
search: "",
current: props.current,
};
this.clearSearch = this.clearSearch.bind(this);
this.props = props;
}
componentDidMount() {
const sel = this.props.selector;
function clickActiveItem() {
const cur = document.activeElement;
if (cur instanceof HTMLInputElement) {
const el = document.querySelector<HTMLElement>("#" + sel + " li:first-of-type a");
if (el)
el.click();
} else if (cur instanceof HTMLElement) {
cur.click();
} else {
console.error("Active element not a HTMLElement");
}
}
function focusNextItem(begin: number, step: number) {
const cur = document.activeElement;
const all = Array.from(document.querySelectorAll<HTMLElement>("#" + sel + " li a"));
if (cur instanceof HTMLInputElement && all.length > 0) {
if (begin < 0)
begin = all.length - 1;
all[begin].focus();
} else {
let i = all.findIndex(item => item === cur);
i += step;
if (i < 0 || i >= all.length)
document.querySelector<HTMLElement>("#" + sel + " .pf-v6-c-text-input-group__text-input")?.focus();
else
all[i].focus();
}
}
const navigate_apps = (ev: KeyboardEvent) => {
if (ev.key === "Enter")
clickActiveItem();
else if (ev.key === "ArrowDown")
focusNextItem(0, 1);
else if (ev.key === "ArrowUp")
focusNextItem(-1, -1);
else if (ev.key === "Escape") {
this.setState({ search: "" });
document.querySelector<HTMLElement>("#" + sel + " .pf-v6-c-text-input-group__text-input")?.focus();
}
};
document.getElementById(sel)?.addEventListener("keyup", navigate_apps);
}
static getDerivedStateFromProps(nextProps: CockpitNavProps<void, void>, prevState: CockpitNavState) {
if (nextProps.current !== prevState.current)
return {
search: "",
current: nextProps.current,
};
return null;
}
clearSearch() {
this.setState({ search: "" });
}
render() {
const groups: ItemGroup<X>[] = [];
const term = this.state.search.toLowerCase();
this.props.groups.forEach(g => {
const new_items = g.items.map(i => this.props.filtering(i, term)).filter(i => i != null);
new_items.sort(this.props.sorting);
if (new_items.length > 0)
groups.push({ name: g.name, items: new_items, action: g.action });
});
return (
<>
<SearchInput placeholder={_("Search")} value={this.state.search} onChange={(_, search) => this.setState({ search })} onClear={() => this.setState({ search: "" })} className="search" />
<Nav>
{ groups.map(g =>
<section className="pf-v6-c-nav__section" aria-labelledby={"section-title-" + g.name} key={g.name}>
<div className="nav-group-heading">
<h2 className="pf-v6-c-nav__section-title" id={"section-title-" + g.name}>{g.name}</h2>
{ g.action &&
<a className="pf-v6-c-nav__section-title nav-item"
href={encode_location(g.action.target)}
onClick={ ev => {
if (g.action)
this.props.jump(g.action.target);
ev.preventDefault();
}}>
{g.action.label}
</a>
}
</div>
<ul className="pf-v6-c-nav__list">
{g.items.map(i => this.props.item_render(i, this.state.search.toLowerCase()))}
</ul>
</section>
)}
{ groups.length < 1 && <span className="non-menu-item no-results">{_("No results found")}</span> }
{ this.state.search !== "" && <span className="non-menu-item"><Button variant="link" onClick={this.clearSearch} className="nav-item-hint">{_("Clear search")}</Button></span> }
</Nav>
</>
);
}
}
function PageStatus({ status, name } : { status: Status, name: string }) {
const desc_parts = name.toLowerCase().split(" ");
desc_parts.push(status.type || "");
const desc = desc_parts.join("-");
let statusIcon = <Icon status="info"><InfoCircleIcon /></Icon>;
switch (status.type) {
case "error":
statusIcon = <Icon status="danger"><ExclamationCircleIcon /></Icon>;
break;
case "warning":
statusIcon = <Icon status="warning"><ExclamationTriangleIcon /></Icon>;
break;
}
return (
<Tooltip id={desc + "-tooltip"} content={status.title}
position={TooltipPosition.right}>
<span id={desc} className="nav-item-status">
{statusIcon}
</span>
</Tooltip>
);
}
function FormattedText({ keyword, term } : { keyword: string, term: string }) {
function split_text(text: string, term: string) {
const b = text.toLowerCase().indexOf(term);
const e = b + term.length;
return [text.substring(0, b), text.substring(b, e), text.substring(e, text.length)];
}
const s = split_text(keyword, term);
return (
<>{s[0]}<mark>{s[1]}</mark>{s[2]}</>
);
}
export function CockpitNavItem(props : {
name: string;
header?: string;
className?: string;
active: boolean;
status: Status | null;
keyword: string;
term: string;
href: string;
onClick: () => void;
actions?: React.ReactNode;
}) {
const s = props.status;
const name_matches = props.keyword === props.name.toLowerCase();
let header_matches = false;
if (props.header)
header_matches = props.keyword === props.header.toLowerCase();
const classes = props.className ? [props.className] : [];
classes.push("pf-v6-c-nav__item", "nav-item");
return (
<li className={classes.join(" ")}>
<a className={"pf-v6-c-nav__link" + (props.active ? " pf-m-current" : "")}
aria-current={props.active && "page"}
href={props.href}
onClick={ev => {
props.onClick();
ev.preventDefault();
}}>
<span className="pf-v6-c-nav__link-text">
{ props.header && <span className="nav-item-hint">{header_matches ? <FormattedText keyword={props.header} term={props.term} /> : props.header}</span> }
<span className="nav-item-name">
{ name_matches ? <FormattedText keyword={props.name} term={props.term} /> : props.name }
</span>
</span>
<span className="pf-v6-c-nav__link-icon">
{s && s.type && <PageStatus status={s} name={props.name} />}
</span>
{ !name_matches && !header_matches && props.keyword && <span className="nav-item-hint nav-item-hint-contains">{_("Contains:")} <FormattedText keyword={props.keyword} term={props.term} /></span> }
</a>
<span className="nav-item-actions nav-host-action-buttons">
{props.actions}
</span>
</li>
);
}
interface PageKeyword {
keyword: string;
score: number;
goto: string | null;
}
interface PageItem extends ManifestItem {
keyword: PageKeyword;
}
export const PageNav = ({ state } : { state: ShellState }) => {
const {
current_machine,
current_manifest_item,
current_machine_manifest_items,
page_status,
} = state;
if (!current_machine || current_machine.state !== "connected")
return null;
cockpit.assert(current_machine_manifest_items && current_manifest_item);
function keyword_filter(item: ManifestItem, term: string): PageItem | null {
function keyword_relevance(current_best: PageKeyword, item: ManifestKeyword) {
const translate = item.translate || false;
const weight = item.weight || 0;
let score;
let _m = "";
let best: PageKeyword = { keyword: "", score: -1, goto: null };
item.matches.forEach(m => {
if (translate)
_m = _(m);
score = -1;
if (translate && _m.indexOf(term) === 0)
score = 4 + weight;
else if (m.indexOf(term) === 0)
score = 3 + weight;
else if (term.length >= 3) {
if (translate && _m.indexOf(term) >= 0)
score = 2 + weight;
else if (m.indexOf(term) >= 0)
score = 1 + weight;
}
if (score > best.score) {
best = { keyword: m, score, goto: item.goto || null };
}
});
if (best.score > current_best.score) {
current_best = best;
}
return current_best;
}
const new_item: PageItem = Object.assign({ keyword: { keyword: "", score: -1, goto: null } }, item);
if (!term)
return new_item;
const best_keyword = new_item.keywords.reduce(keyword_relevance, { keyword: "", score: -1, goto: null });
if (best_keyword.score > -1) {
new_item.keyword = best_keyword;
return new_item;
}
return null;
}
function nav_item(item: PageItem, term: string) {
const active = current_manifest_item?.path === item.path;
let path = item.path;
let hash = item.hash;
if (item.keyword.goto) {
if (item.keyword.goto[0] === "/")
path = item.keyword.goto.substring(1);
else
hash = item.keyword.goto;
}
let status = null;
if (page_status[current_machine!.key])
status = page_status[current_machine!.key][item.path];
const target_location = { host: current_machine!.address, path, hash };
return (
<CockpitNavItem key={item.label}
name={item.label}
active={active}
status={status}
keyword={item.keyword.keyword}
term={term}
href={encode_location(target_location)}
onClick={() => state.jump(target_location)} />
);
}
const groups: ItemGroup<ManifestItem>[] = [
{
name: _("Apps"),
items: current_machine_manifest_items.ordered("dashboard"),
}, {
name: _("System"),
items: current_machine_manifest_items.ordered("menu"),
}, {
name: _("Tools"),
items: current_machine_manifest_items.ordered("tools"),
}
].filter(i => i.items.length > 0);
if (current_machine_manifest_items.items.apps && groups.length === 3)
groups[0].action = {
label: _("Edit"),
target: {
host: current_machine.address,
path: current_machine_manifest_items.items.apps.path,
}
};
return <CockpitNav groups={groups}
selector="host-apps"
item_render={nav_item}
filtering={keyword_filter}
sorting={(a, b) => { return b.keyword.score - a.keyword.score }}
current={current_manifest_item.path}
jump={state.jump} />;
};