@rxtx4816/cockpit-plugin-base-react 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.base.js +3 -0
- package/package.json +2 -1
- package/src/components/CollapsibleSearch.tsx +95 -0
- package/src/components/LayoutSelector.css +34 -0
- package/src/components/LayoutSelector.tsx +70 -0
- package/src/components/PluginFooter.tsx +61 -0
- package/src/components/index.ts +4 -0
- package/src/hooks/useLayout.ts +22 -0
- package/src/index.ts +1 -0
- package/src/systemd/ServiceControl.tsx +8 -1
package/eslint.config.base.js
CHANGED
|
@@ -39,6 +39,9 @@ const BASE_GLOBALS = {
|
|
|
39
39
|
HTMLPreElement: "readonly",
|
|
40
40
|
HTMLTableSectionElement: "readonly",
|
|
41
41
|
requestAnimationFrame: "readonly",
|
|
42
|
+
Node: "readonly",
|
|
43
|
+
PointerEvent: "readonly",
|
|
44
|
+
MouseEvent: "readonly",
|
|
42
45
|
cockpit: "readonly",
|
|
43
46
|
CockpitProcess: "readonly",
|
|
44
47
|
CockpitChannel: "readonly",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxtx4816/cockpit-plugin-base-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Shared infrastructure for Cockpit plugins: i18n, dark theme, test setup, config presets, CI/CD workflows, and QEMU VM harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "RXTX4816",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"typecheck": "tsc --noEmit",
|
|
82
82
|
"test": "vitest run",
|
|
83
83
|
"test:watch": "vitest",
|
|
84
|
+
"yalc": "yalc push",
|
|
84
85
|
"docs": "typedoc",
|
|
85
86
|
"docs:watch": "typedoc --watch"
|
|
86
87
|
},
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type CSSProperties, useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { Button, SearchInput } from "@patternfly/react-core";
|
|
3
|
+
import { SearchIcon } from "@patternfly/react-icons";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
onClear: () => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
"aria-label"?: string;
|
|
11
|
+
/** Width of the expanded input in pixels. Defaults to 220. */
|
|
12
|
+
expandedWidth?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CollapsibleSearch({
|
|
16
|
+
value,
|
|
17
|
+
onChange,
|
|
18
|
+
onClear,
|
|
19
|
+
placeholder = "Search…",
|
|
20
|
+
"aria-label": ariaLabel = "Search",
|
|
21
|
+
expandedWidth = 220,
|
|
22
|
+
}: Props) {
|
|
23
|
+
const [expanded, setExpanded] = useState(value.length > 0);
|
|
24
|
+
const inputRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
// Stay expanded when there's an active search value
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (value.length > 0) setExpanded(true);
|
|
29
|
+
}, [value]);
|
|
30
|
+
|
|
31
|
+
const collapse = useCallback(() => {
|
|
32
|
+
if (value.length === 0) setExpanded(false);
|
|
33
|
+
}, [value]);
|
|
34
|
+
|
|
35
|
+
// Collapse on outside click when empty
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!expanded) return;
|
|
38
|
+
function onPointerDown(e: PointerEvent) {
|
|
39
|
+
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
|
|
40
|
+
collapse();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
44
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
45
|
+
}, [expanded, collapse]);
|
|
46
|
+
|
|
47
|
+
function handleClear() {
|
|
48
|
+
onClear();
|
|
49
|
+
setExpanded(false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function handleExpand() {
|
|
53
|
+
setExpanded(true);
|
|
54
|
+
// Focus the inner input after the animation frame
|
|
55
|
+
requestAnimationFrame(() => {
|
|
56
|
+
inputRef.current?.querySelector("input")?.focus();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const containerStyle: CSSProperties = {
|
|
61
|
+
display: "flex",
|
|
62
|
+
alignItems: "center",
|
|
63
|
+
overflow: "hidden",
|
|
64
|
+
transition: "width 200ms ease, opacity 200ms ease",
|
|
65
|
+
width: expanded ? expandedWidth : 32,
|
|
66
|
+
opacity: expanded ? 1 : 0.6,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!expanded) {
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
variant="plain"
|
|
73
|
+
size="sm"
|
|
74
|
+
aria-label={ariaLabel}
|
|
75
|
+
title={ariaLabel}
|
|
76
|
+
onClick={handleExpand}
|
|
77
|
+
>
|
|
78
|
+
<SearchIcon />
|
|
79
|
+
</Button>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div ref={inputRef} style={containerStyle}>
|
|
85
|
+
<SearchInput
|
|
86
|
+
placeholder={placeholder}
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={(_e, v) => onChange(v)}
|
|
89
|
+
onClear={handleClear}
|
|
90
|
+
style={{ width: expandedWidth }}
|
|
91
|
+
aria-label={ariaLabel}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
.ls-wrap {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 0.25rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.ls-trigger.pf-v6-c-button {
|
|
8
|
+
padding: 0.25rem;
|
|
9
|
+
width: 1.875rem;
|
|
10
|
+
height: 1.875rem;
|
|
11
|
+
min-width: unset;
|
|
12
|
+
border-radius: var(--pf-t--global--border--radius--base, 4px);
|
|
13
|
+
color: var(--pf-t--global--text--color--subtle, #6a6e73);
|
|
14
|
+
transition: color 0.15s ease, background 0.15s ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.ls-trigger.pf-v6-c-button:hover {
|
|
18
|
+
color: var(--pf-t--global--text--color--regular, #151515);
|
|
19
|
+
background: rgba(0, 0, 0, 0.06);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ls-trigger--active.pf-v6-c-button {
|
|
23
|
+
color: var(--pf-t--global--color--brand--default, #0066cc);
|
|
24
|
+
background: rgba(0, 102, 204, 0.08);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ls-toggle {
|
|
28
|
+
animation: ls-expand 0.12s ease-out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@keyframes ls-expand {
|
|
32
|
+
from { opacity: 0; transform: scale(0.92); }
|
|
33
|
+
to { opacity: 1; transform: scale(1); }
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Button, Tooltip, ToggleGroup, ToggleGroupItem } from "@patternfly/react-core";
|
|
3
|
+
import { SlidersHIcon } from "@patternfly/react-icons";
|
|
4
|
+
import "./LayoutSelector.css";
|
|
5
|
+
|
|
6
|
+
export interface LayoutOption<T extends string = string> {
|
|
7
|
+
key: T;
|
|
8
|
+
icon: ReactNode;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props<T extends string = string> {
|
|
13
|
+
layout: T;
|
|
14
|
+
onLayoutChange: (layout: T) => void;
|
|
15
|
+
layouts: LayoutOption<T>[];
|
|
16
|
+
ariaLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function LayoutSelector<T extends string>({
|
|
20
|
+
layout,
|
|
21
|
+
onLayoutChange,
|
|
22
|
+
layouts,
|
|
23
|
+
ariaLabel = "Change layout",
|
|
24
|
+
}: Props<T>) {
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!open) return;
|
|
30
|
+
const handler = (e: MouseEvent) => {
|
|
31
|
+
if (containerRef.current && !containerRef.current.contains(e.target as HTMLElement)) {
|
|
32
|
+
setOpen(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener("mousedown", handler);
|
|
36
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
37
|
+
}, [open]);
|
|
38
|
+
|
|
39
|
+
const current = layouts.find(l => l.key === layout);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div ref={containerRef} className={`ls-wrap${open ? " ls-wrap--open" : ""}`}>
|
|
43
|
+
<Tooltip content={`Layout: ${current?.label ?? layout}`}>
|
|
44
|
+
<Button
|
|
45
|
+
variant="plain"
|
|
46
|
+
size="sm"
|
|
47
|
+
onClick={() => setOpen(o => !o)}
|
|
48
|
+
aria-label={ariaLabel}
|
|
49
|
+
className={`ls-trigger${open ? " ls-trigger--active" : ""}`}
|
|
50
|
+
>
|
|
51
|
+
{current?.icon ?? <SlidersHIcon />}
|
|
52
|
+
</Button>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
{open && (
|
|
55
|
+
<ToggleGroup aria-label="Layout" isCompact className="ls-toggle">
|
|
56
|
+
{layouts.map(({ key, icon, label }) => (
|
|
57
|
+
<Tooltip key={key} content={label}>
|
|
58
|
+
<ToggleGroupItem
|
|
59
|
+
icon={icon}
|
|
60
|
+
isSelected={layout === key}
|
|
61
|
+
onChange={() => { onLayoutChange(key); setOpen(false); }}
|
|
62
|
+
aria-label={label}
|
|
63
|
+
/>
|
|
64
|
+
</Tooltip>
|
|
65
|
+
))}
|
|
66
|
+
</ToggleGroup>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { PageSection, Label } from "@patternfly/react-core";
|
|
3
|
+
|
|
4
|
+
interface FooterLink {
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Plugin version string (e.g. from package.json). */
|
|
11
|
+
version?: string;
|
|
12
|
+
/** Links rendered in the bottom row. */
|
|
13
|
+
links?: FooterLink[];
|
|
14
|
+
/** Extra labels or badges rendered next to the version in the top row. */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PluginFooter({ version, links, children, className }: Props) {
|
|
20
|
+
return (
|
|
21
|
+
<PageSection
|
|
22
|
+
className={className}
|
|
23
|
+
style={{
|
|
24
|
+
position: "sticky",
|
|
25
|
+
bottom: 0,
|
|
26
|
+
zIndex: 1,
|
|
27
|
+
borderTop: "1px solid var(--pf-t--global--border--color--default)",
|
|
28
|
+
padding: "0.5rem 1.5rem",
|
|
29
|
+
fontSize: 13,
|
|
30
|
+
textAlign: "center",
|
|
31
|
+
backgroundColor: "var(--pf-t--global--background--color--primary--default)",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
|
|
35
|
+
{(version !== undefined || children) && (
|
|
36
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, justifyContent: "center", alignItems: "center" }}>
|
|
37
|
+
{version !== undefined && (
|
|
38
|
+
<Label isCompact color="grey">v{version}</Label>
|
|
39
|
+
)}
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
{links && links.length > 0 && (
|
|
44
|
+
<div style={{ display: "flex", flexDirection: "row", gap: 16, justifyContent: "center" }}>
|
|
45
|
+
{links.map(({ label, href }) => (
|
|
46
|
+
<a
|
|
47
|
+
key={href}
|
|
48
|
+
href={href}
|
|
49
|
+
target="_blank"
|
|
50
|
+
rel="noopener noreferrer"
|
|
51
|
+
style={{ color: "var(--pf-t--global--color--brand--default, #06c)", textDecoration: "none" }}
|
|
52
|
+
>
|
|
53
|
+
{label}
|
|
54
|
+
</a>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</PageSection>
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -6,3 +6,7 @@ export type { StatusBadgeConfig } from "./StatusBadge";
|
|
|
6
6
|
export { ConfirmDialog } from "./ConfirmDialog";
|
|
7
7
|
export { LogViewer } from "./LogViewer";
|
|
8
8
|
export { HelpPopover } from "./HelpPopover";
|
|
9
|
+
export { PluginFooter } from "./PluginFooter";
|
|
10
|
+
export { CollapsibleSearch } from "./CollapsibleSearch";
|
|
11
|
+
export { LayoutSelector } from "./LayoutSelector";
|
|
12
|
+
export type { LayoutOption } from "./LayoutSelector";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export function useLayout<T extends string>(
|
|
4
|
+
storageKey: string,
|
|
5
|
+
defaultLayout: T,
|
|
6
|
+
validLayouts: T[],
|
|
7
|
+
): [T, (layout: T) => void] {
|
|
8
|
+
const [layout, setLayoutState] = useState<T>(() => {
|
|
9
|
+
try {
|
|
10
|
+
const stored = localStorage.getItem(storageKey);
|
|
11
|
+
if (stored && (validLayouts as string[]).includes(stored)) return stored as T;
|
|
12
|
+
} catch { /* ignore */ }
|
|
13
|
+
return defaultLayout;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const setLayout = useCallback((next: T) => {
|
|
17
|
+
try { localStorage.setItem(storageKey, next); } catch { /* ignore */ }
|
|
18
|
+
setLayoutState(next);
|
|
19
|
+
}, [storageKey]);
|
|
20
|
+
|
|
21
|
+
return [layout, setLayout];
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,3 +9,4 @@ export { useConfirmAction } from "./hooks/useConfirmAction";
|
|
|
9
9
|
export type { ConfirmStep, ConfirmActionState } from "./hooks/useConfirmAction";
|
|
10
10
|
export { usePollingFetch } from "./hooks/usePollingFetch";
|
|
11
11
|
export type { PollingFetchResult } from "./hooks/usePollingFetch";
|
|
12
|
+
export { useLayout } from "./hooks/useLayout";
|
|
@@ -72,6 +72,8 @@ interface Props {
|
|
|
72
72
|
statusBadge?: ReactNode;
|
|
73
73
|
/** Override any user-visible string. See {@link ServiceControlLabels}. */
|
|
74
74
|
labels?: ServiceControlLabels;
|
|
75
|
+
/** Extra content rendered at the far right of the button row (e.g. Backup / Restore buttons). */
|
|
76
|
+
extraActions?: ReactNode;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/**
|
|
@@ -82,7 +84,7 @@ interface Props {
|
|
|
82
84
|
*
|
|
83
85
|
* Pair with `useServiceStatus` for reactive status updates.
|
|
84
86
|
*/
|
|
85
|
-
export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels }: Props) {
|
|
87
|
+
export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels, extraActions }: Props) {
|
|
86
88
|
const toast = useToast();
|
|
87
89
|
const l = { ...DEFAULTS, ...labels };
|
|
88
90
|
const [busy, setBusy] = useState(false);
|
|
@@ -194,6 +196,11 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
|
|
|
194
196
|
{l.reload}
|
|
195
197
|
</Button>
|
|
196
198
|
</FlexItem>
|
|
199
|
+
{extraActions && (
|
|
200
|
+
<FlexItem align={{ default: "alignRight" }}>
|
|
201
|
+
{extraActions}
|
|
202
|
+
</FlexItem>
|
|
203
|
+
)}
|
|
197
204
|
</Flex>
|
|
198
205
|
|
|
199
206
|
<ConfirmDialog
|