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 : };
|