@rxtx4816/cockpit-plugin-base-react 1.0.0

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.
Files changed (41) hide show
  1. package/eslint.config.base.js +85 -0
  2. package/package.json +116 -0
  3. package/scripts/test-vm.sh +608 -0
  4. package/src/bootstrap.tsx +7 -0
  5. package/src/cockpit.d.ts +63 -0
  6. package/src/components/ConfirmDialog.test.tsx +61 -0
  7. package/src/components/ConfirmDialog.tsx +65 -0
  8. package/src/components/ErrorBoundary.test.tsx +50 -0
  9. package/src/components/ErrorBoundary.tsx +30 -0
  10. package/src/components/HelpPopover.tsx +30 -0
  11. package/src/components/LogViewer.tsx +108 -0
  12. package/src/components/StatusBadge.test.tsx +32 -0
  13. package/src/components/StatusBadge.tsx +18 -0
  14. package/src/components/ToastProvider.css +20 -0
  15. package/src/components/ToastProvider.test.tsx +61 -0
  16. package/src/components/ToastProvider.tsx +76 -0
  17. package/src/components/index.ts +8 -0
  18. package/src/css.d.ts +4 -0
  19. package/src/dark-theme.ts +30 -0
  20. package/src/hooks/useAsyncAction.test.ts +59 -0
  21. package/src/hooks/useAsyncAction.ts +31 -0
  22. package/src/hooks/useAsyncStream.test.ts +122 -0
  23. package/src/hooks/useAsyncStream.ts +94 -0
  24. package/src/hooks/useAutoRefresh.test.ts +106 -0
  25. package/src/hooks/useAutoRefresh.ts +23 -0
  26. package/src/hooks/useConfirmAction.test.ts +68 -0
  27. package/src/hooks/useConfirmAction.ts +35 -0
  28. package/src/hooks/usePollingFetch.ts +41 -0
  29. package/src/i18n.ts +51 -0
  30. package/src/index.ts +11 -0
  31. package/src/systemd/ServiceControl.tsx +172 -0
  32. package/src/systemd/api.test.ts +83 -0
  33. package/src/systemd/api.ts +36 -0
  34. package/src/systemd/index.ts +5 -0
  35. package/src/systemd/types.ts +1 -0
  36. package/src/systemd/useServiceStatus.test.ts +96 -0
  37. package/src/systemd/useServiceStatus.ts +29 -0
  38. package/src/testing/helpers.ts +30 -0
  39. package/src/testing/setup.ts +57 -0
  40. package/tsconfig.base.json +17 -0
  41. package/vitest.config.base.ts +47 -0
@@ -0,0 +1,61 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { ConfirmDialog } from "./ConfirmDialog";
4
+
5
+ function setup(overrides: Partial<Parameters<typeof ConfirmDialog>[0]> = {}) {
6
+ const onConfirm = vi.fn();
7
+ const onClose = vi.fn();
8
+ render(
9
+ <ConfirmDialog
10
+ isOpen={true}
11
+ title="Delete item?"
12
+ confirmLabel="Delete"
13
+ onConfirm={onConfirm}
14
+ onClose={onClose}
15
+ {...overrides}
16
+ />,
17
+ );
18
+ return { onConfirm, onClose };
19
+ }
20
+
21
+ describe("ConfirmDialog", () => {
22
+ it("renders title and buttons when open", () => {
23
+ setup();
24
+ expect(screen.getByText("Delete item?")).toBeInTheDocument();
25
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
26
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
27
+ });
28
+
29
+ it("calls onConfirm when confirm button is clicked", () => {
30
+ const { onConfirm } = setup();
31
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
32
+ expect(onConfirm).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it("calls onClose when cancel button is clicked", () => {
36
+ const { onClose } = setup();
37
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
38
+ expect(onClose).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it("uses custom cancelLabel", () => {
42
+ setup({ cancelLabel: "Go back" });
43
+ expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument();
44
+ });
45
+
46
+ it("disables buttons when loading", () => {
47
+ setup({ loading: true });
48
+ expect(screen.getByRole("button", { name: /Delete/i })).toBeDisabled();
49
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
50
+ });
51
+
52
+ it("shows error alert when error prop is set", () => {
53
+ setup({ error: "Something went wrong" });
54
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
55
+ });
56
+
57
+ it("renders body content", () => {
58
+ setup({ body: <p>Are you sure you want to delete this?</p> });
59
+ expect(screen.getByText("Are you sure you want to delete this?")).toBeInTheDocument();
60
+ });
61
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ Alert,
3
+ Button,
4
+ Modal,
5
+ ModalBody,
6
+ ModalFooter,
7
+ ModalHeader,
8
+ } from "@patternfly/react-core";
9
+ import type { ReactNode } from "react";
10
+
11
+ interface Props {
12
+ isOpen: boolean;
13
+ title: string;
14
+ body?: ReactNode;
15
+ confirmLabel: string;
16
+ cancelLabel?: string;
17
+ variant?: "primary" | "danger";
18
+ loading?: boolean;
19
+ error?: string | null;
20
+ onConfirm: () => void;
21
+ onClose: () => void;
22
+ }
23
+
24
+ export function ConfirmDialog({
25
+ isOpen,
26
+ title,
27
+ body,
28
+ confirmLabel,
29
+ cancelLabel = "Cancel",
30
+ variant = "primary",
31
+ loading = false,
32
+ error,
33
+ onConfirm,
34
+ onClose,
35
+ }: Props) {
36
+ return (
37
+ <Modal isOpen={isOpen} variant="small" onClose={onClose} aria-labelledby="cpb-confirm-dialog-title">
38
+ <ModalHeader title={title} labelId="cpb-confirm-dialog-title" />
39
+ <ModalBody>
40
+ {body}
41
+ {error && (
42
+ <Alert
43
+ variant="danger"
44
+ isInline
45
+ title={error}
46
+ style={{ marginTop: body ? "var(--pf-v6-global--spacer--md)" : undefined }}
47
+ />
48
+ )}
49
+ </ModalBody>
50
+ <ModalFooter>
51
+ <Button
52
+ variant={variant}
53
+ isDisabled={loading}
54
+ isLoading={loading}
55
+ onClick={onConfirm}
56
+ >
57
+ {confirmLabel}
58
+ </Button>
59
+ <Button variant="link" isDisabled={loading} onClick={onClose}>
60
+ {cancelLabel}
61
+ </Button>
62
+ </ModalFooter>
63
+ </Modal>
64
+ );
65
+ }
@@ -0,0 +1,50 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { ErrorBoundary } from "./ErrorBoundary";
4
+
5
+ function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
6
+ if (shouldThrow) throw new Error("kaboom");
7
+ return <div>all good</div>;
8
+ }
9
+
10
+ describe("ErrorBoundary", () => {
11
+ it("renders children when no error", () => {
12
+ render(
13
+ <ErrorBoundary>
14
+ <Bomb shouldThrow={false} />
15
+ </ErrorBoundary>,
16
+ );
17
+ expect(screen.getByText("all good")).toBeInTheDocument();
18
+ });
19
+
20
+ it("renders fallback UI when child throws", () => {
21
+ vi.spyOn(console, "error").mockImplementation(() => {});
22
+ render(
23
+ <ErrorBoundary>
24
+ <Bomb shouldThrow={true} />
25
+ </ErrorBoundary>,
26
+ );
27
+ expect(screen.queryByText("all good")).not.toBeInTheDocument();
28
+ expect(screen.getByText("kaboom")).toBeInTheDocument();
29
+ });
30
+
31
+ it("uses fallbackTitle prop in the heading", () => {
32
+ vi.spyOn(console, "error").mockImplementation(() => {});
33
+ render(
34
+ <ErrorBoundary fallbackTitle="Plugin crashed">
35
+ <Bomb shouldThrow={true} />
36
+ </ErrorBoundary>,
37
+ );
38
+ expect(screen.getByText("Plugin crashed")).toBeInTheDocument();
39
+ });
40
+
41
+ it("uses default title when fallbackTitle is omitted", () => {
42
+ vi.spyOn(console, "error").mockImplementation(() => {});
43
+ render(
44
+ <ErrorBoundary>
45
+ <Bomb shouldThrow={true} />
46
+ </ErrorBoundary>,
47
+ );
48
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
49
+ });
50
+ });
@@ -0,0 +1,30 @@
1
+ import { Component, type ReactNode } from "react";
2
+ import { EmptyState, EmptyStateBody } from "@patternfly/react-core";
3
+
4
+ interface Props {
5
+ children: ReactNode;
6
+ fallbackTitle?: string;
7
+ }
8
+
9
+ interface State {
10
+ error: Error | null;
11
+ }
12
+
13
+ export class ErrorBoundary extends Component<Props, State> {
14
+ state: State = { error: null };
15
+
16
+ static getDerivedStateFromError(error: Error): State {
17
+ return { error };
18
+ }
19
+
20
+ render() {
21
+ if (this.state.error) {
22
+ return (
23
+ <EmptyState headingLevel="h2" titleText={this.props.fallbackTitle ?? "Something went wrong"}>
24
+ <EmptyStateBody>{this.state.error.message}</EmptyStateBody>
25
+ </EmptyState>
26
+ );
27
+ }
28
+ return this.props.children;
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ import { useState } from "react";
2
+ import { Popover, Button } from "@patternfly/react-core";
3
+ import { OutlinedQuestionCircleIcon } from "@patternfly/react-icons";
4
+
5
+ interface Props {
6
+ header: string;
7
+ body: string;
8
+ "aria-label"?: string;
9
+ }
10
+
11
+ export function HelpPopover({ header, body, "aria-label": ariaLabel }: Props) {
12
+ const [visible, setVisible] = useState(false);
13
+ return (
14
+ <Popover
15
+ headerContent={header}
16
+ bodyContent={body}
17
+ isVisible={visible}
18
+ shouldOpen={() => setVisible(true)}
19
+ shouldClose={() => setVisible(false)}
20
+ >
21
+ <Button
22
+ variant="plain"
23
+ aria-label={ariaLabel ?? header}
24
+ style={{ padding: "0 0.25rem", minWidth: 0, color: "var(--pf-t--global--text--color--subtle)" }}
25
+ >
26
+ <OutlinedQuestionCircleIcon style={{ fontSize: "0.9rem" }} />
27
+ </Button>
28
+ </Popover>
29
+ );
30
+ }
@@ -0,0 +1,108 @@
1
+ import { useState } from "react";
2
+ import {
3
+ Alert,
4
+ Button,
5
+ SearchInput,
6
+ Spinner,
7
+ Stack,
8
+ StackItem,
9
+ Toolbar,
10
+ ToolbarContent,
11
+ ToolbarItem,
12
+ } from "@patternfly/react-core";
13
+
14
+ interface Props {
15
+ lines: string[];
16
+ loading?: boolean;
17
+ error?: string | null;
18
+ onRefresh?: () => void;
19
+ searchPlaceholder?: string;
20
+ emptyMessage?: string;
21
+ noMatchesMessage?: string;
22
+ errorTitle?: string;
23
+ refreshAriaLabel?: string;
24
+ }
25
+
26
+ export function LogViewer({
27
+ lines,
28
+ loading = false,
29
+ error,
30
+ onRefresh,
31
+ searchPlaceholder = "Search logs…",
32
+ emptyMessage = "No log entries.",
33
+ noMatchesMessage = "No matching entries.",
34
+ errorTitle = "Failed to load logs",
35
+ refreshAriaLabel = "Refresh",
36
+ }: Props) {
37
+ const [search, setSearch] = useState("");
38
+
39
+ const filtered = search
40
+ ? lines.filter(l => l.toLowerCase().includes(search.toLowerCase()))
41
+ : lines;
42
+
43
+ return (
44
+ <Stack hasGutter>
45
+ {error && (
46
+ <StackItem>
47
+ <Alert
48
+ variant="danger"
49
+ title={errorTitle}
50
+ actionLinks={onRefresh && (
51
+ <Button variant="link" onClick={onRefresh}>Retry</Button>
52
+ )}
53
+ >
54
+ {error}
55
+ </Alert>
56
+ </StackItem>
57
+ )}
58
+
59
+ <StackItem>
60
+ <Toolbar>
61
+ <ToolbarContent>
62
+ <ToolbarItem>
63
+ <SearchInput
64
+ placeholder={searchPlaceholder}
65
+ value={search}
66
+ onChange={(_e, v) => setSearch(v)}
67
+ onClear={() => setSearch("")}
68
+ />
69
+ </ToolbarItem>
70
+ {onRefresh && (
71
+ <ToolbarItem align={{ default: "alignEnd" }}>
72
+ <Button variant="plain" onClick={onRefresh} aria-label={refreshAriaLabel}>↺</Button>
73
+ </ToolbarItem>
74
+ )}
75
+ </ToolbarContent>
76
+ </Toolbar>
77
+ </StackItem>
78
+
79
+ <StackItem isFilled>
80
+ {loading ? (
81
+ <Spinner />
82
+ ) : filtered.length === 0 ? (
83
+ <p style={{ color: "var(--pf-v6-global--Color--200)" }}>
84
+ {lines.length === 0 ? emptyMessage : noMatchesMessage}
85
+ </p>
86
+ ) : (
87
+ <pre
88
+ style={{
89
+ fontFamily: "monospace",
90
+ fontSize: "0.8rem",
91
+ overflowX: "auto",
92
+ maxHeight: "60vh",
93
+ overflowY: "auto",
94
+ background: "var(--pf-v6-global--BackgroundColor--dark-100, #1b1d21)",
95
+ color: "var(--pf-v6-global--Color--light-100, #e8e8e8)",
96
+ padding: "1rem",
97
+ borderRadius: "var(--pf-v6-global--BorderRadius--sm, 4px)",
98
+ whiteSpace: "pre-wrap",
99
+ wordBreak: "break-all",
100
+ }}
101
+ >
102
+ {filtered.join("\n")}
103
+ </pre>
104
+ )}
105
+ </StackItem>
106
+ </Stack>
107
+ );
108
+ }
@@ -0,0 +1,32 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, it, expect } from "vitest";
3
+ import { StatusBadge, type StatusBadgeConfig } from "./StatusBadge";
4
+
5
+ const config: Record<string, StatusBadgeConfig> = {
6
+ active: { color: "green", label: "Running" },
7
+ inactive: { color: "grey", label: "Stopped" },
8
+ failed: { color: "red", label: "Failed" },
9
+ };
10
+
11
+ describe("StatusBadge", () => {
12
+ it("renders label from config", () => {
13
+ render(<StatusBadge status="active" config={config} />);
14
+ expect(screen.getByText("Running")).toBeInTheDocument();
15
+ });
16
+
17
+ it("renders different statuses", () => {
18
+ render(<StatusBadge status="failed" config={config} />);
19
+ expect(screen.getByText("Failed")).toBeInTheDocument();
20
+ });
21
+
22
+ it("renders fallback when status is not in config", () => {
23
+ const fallback: StatusBadgeConfig = { color: "orange", label: "Unknown" };
24
+ render(<StatusBadge status="mystery" config={config} fallback={fallback} />);
25
+ expect(screen.getByText("Unknown")).toBeInTheDocument();
26
+ });
27
+
28
+ it("renders raw status string when no fallback is provided", () => {
29
+ render(<StatusBadge status="mystery" config={config} />);
30
+ expect(screen.getByText("mystery")).toBeInTheDocument();
31
+ });
32
+ });
@@ -0,0 +1,18 @@
1
+ import { Label, type LabelProps } from "@patternfly/react-core";
2
+
3
+ export interface StatusBadgeConfig {
4
+ color: LabelProps["color"];
5
+ label: string;
6
+ }
7
+
8
+ interface Props<T extends string> {
9
+ status: T;
10
+ config: Record<string, StatusBadgeConfig>;
11
+ fallback?: StatusBadgeConfig;
12
+ isCompact?: boolean;
13
+ }
14
+
15
+ export function StatusBadge<T extends string>({ status, config, fallback, isCompact }: Props<T>) {
16
+ const entry = config[status] ?? fallback ?? { color: "grey", label: status };
17
+ return <Label color={entry.color} isCompact={isCompact}>{entry.label}</Label>;
18
+ }
@@ -0,0 +1,20 @@
1
+ .cpb-toast-group {
2
+ position: fixed !important;
3
+ bottom: 4rem !important;
4
+ right: 1.5rem !important;
5
+ top: auto !important;
6
+ z-index: 9999;
7
+ max-width: 480px;
8
+ pointer-events: none;
9
+ }
10
+
11
+ .cpb-toast-group .pf-v6-c-alert {
12
+ pointer-events: all;
13
+ animation: cpb-toast-in 200ms ease;
14
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
15
+ }
16
+
17
+ @keyframes cpb-toast-in {
18
+ from { transform: translateX(2rem); opacity: 0; }
19
+ to { transform: translateX(0); opacity: 1; }
20
+ }
@@ -0,0 +1,61 @@
1
+ import { render, screen, act, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { ToastProvider, useToast } from "./ToastProvider";
4
+
5
+ function ToastTrigger({ label, message }: { label: string; message: string }) {
6
+ const toast = useToast();
7
+ return <button onClick={() => toast.success(message)}>{label}</button>;
8
+ }
9
+
10
+ function setup() {
11
+ return render(
12
+ <ToastProvider>
13
+ <ToastTrigger label="add" message="It worked!" />
14
+ </ToastProvider>,
15
+ );
16
+ }
17
+
18
+ describe("ToastProvider", () => {
19
+ beforeEach(() => { vi.useFakeTimers(); });
20
+ afterEach(() => { vi.useRealTimers(); });
21
+
22
+ it("renders children", () => {
23
+ setup();
24
+ expect(screen.getByText("add")).toBeInTheDocument();
25
+ });
26
+
27
+ it("shows a toast when success() is called", () => {
28
+ setup();
29
+ act(() => { fireEvent.click(screen.getByText("add")); });
30
+ expect(screen.getByText("It worked!")).toBeInTheDocument();
31
+ });
32
+
33
+ it("auto-dismisses the toast after 5 seconds", () => {
34
+ setup();
35
+ act(() => { fireEvent.click(screen.getByText("add")); });
36
+ expect(screen.getByText("It worked!")).toBeInTheDocument();
37
+
38
+ act(() => { vi.advanceTimersByTime(5000); });
39
+ expect(screen.queryByText("It worked!")).not.toBeInTheDocument();
40
+ });
41
+
42
+ it("dismisses the toast when close button is clicked", () => {
43
+ setup();
44
+ act(() => { fireEvent.click(screen.getByText("add")); });
45
+ expect(screen.getByText("It worked!")).toBeInTheDocument();
46
+
47
+ const closeBtn = screen.getByRole("button", { name: /close/i });
48
+ act(() => { fireEvent.click(closeBtn); });
49
+ expect(screen.queryByText("It worked!")).not.toBeInTheDocument();
50
+ });
51
+
52
+ it("useToast returns noop functions when used outside ToastProvider", () => {
53
+ function Standalone() {
54
+ const toast = useToast();
55
+ return <button onClick={() => toast.error("oops")}>trigger</button>;
56
+ }
57
+ render(<Standalone />);
58
+ // Should not throw
59
+ expect(() => screen.getByText("trigger")).not.toThrow();
60
+ });
61
+ });
@@ -0,0 +1,76 @@
1
+ import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
2
+ import { Alert, AlertGroup, AlertActionCloseButton } from "@patternfly/react-core";
3
+ import "./ToastProvider.css";
4
+
5
+ export type ToastVariant = "success" | "danger" | "warning" | "info";
6
+
7
+ interface Toast {
8
+ id: number;
9
+ variant: ToastVariant;
10
+ title: string;
11
+ body?: string;
12
+ }
13
+
14
+ export interface ToastContextValue {
15
+ addToast: (variant: ToastVariant, title: string, body?: string) => void;
16
+ success: (title: string, body?: string) => void;
17
+ error: (title: string, body?: string) => void;
18
+ warn: (title: string, body?: string) => void;
19
+ info: (title: string, body?: string) => void;
20
+ }
21
+
22
+ const ToastContext = createContext<ToastContextValue | null>(null);
23
+
24
+ const AUTO_DISMISS_MS = 5000;
25
+
26
+ export function ToastProvider({ children }: { children: ReactNode }) {
27
+ const [toasts, setToasts] = useState<Toast[]>([]);
28
+ const counterRef = useRef(0);
29
+
30
+ const dismiss = useCallback((id: number) => {
31
+ setToasts(prev => prev.filter(t => t.id !== id));
32
+ }, []);
33
+
34
+ const addToast = useCallback((variant: ToastVariant, title: string, body?: string) => {
35
+ const id = ++counterRef.current;
36
+ setToasts(prev => [...prev, { id, variant, title, body }]);
37
+ setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
38
+ }, [dismiss]);
39
+
40
+ const success = useCallback((title: string, body?: string) => addToast("success", title, body), [addToast]);
41
+ const error = useCallback((title: string, body?: string) => addToast("danger", title, body), [addToast]);
42
+ const warn = useCallback((title: string, body?: string) => addToast("warning", title, body), [addToast]);
43
+ const info = useCallback((title: string, body?: string) => addToast("info", title, body), [addToast]);
44
+
45
+ return (
46
+ <ToastContext.Provider value={{ addToast, success, error, warn, info }}>
47
+ {children}
48
+ <AlertGroup isToast isLiveRegion className="cpb-toast-group">
49
+ {toasts.map(t => (
50
+ <Alert
51
+ key={t.id}
52
+ variant={t.variant}
53
+ title={t.title}
54
+ timeout={AUTO_DISMISS_MS}
55
+ onTimeout={() => dismiss(t.id)}
56
+ actionClose={<AlertActionCloseButton onClose={() => dismiss(t.id)} />}
57
+ >
58
+ {t.body}
59
+ </Alert>
60
+ ))}
61
+ </AlertGroup>
62
+ </ToastContext.Provider>
63
+ );
64
+ }
65
+
66
+ const NOOP_TOAST: ToastContextValue = {
67
+ addToast: () => {},
68
+ success: () => {},
69
+ error: () => {},
70
+ warn: () => {},
71
+ info: () => {},
72
+ };
73
+
74
+ export function useToast(): ToastContextValue {
75
+ return useContext(ToastContext) ?? NOOP_TOAST;
76
+ }
@@ -0,0 +1,8 @@
1
+ export { ErrorBoundary } from "./ErrorBoundary";
2
+ export { ToastProvider, useToast } from "./ToastProvider";
3
+ export type { ToastVariant, ToastContextValue } from "./ToastProvider";
4
+ export { StatusBadge } from "./StatusBadge";
5
+ export type { StatusBadgeConfig } from "./StatusBadge";
6
+ export { ConfirmDialog } from "./ConfirmDialog";
7
+ export { LogViewer } from "./LogViewer";
8
+ export { HelpPopover } from "./HelpPopover";
package/src/css.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "*.css" {
2
+ const content: Record<string, string>;
3
+ export default content;
4
+ }
@@ -0,0 +1,30 @@
1
+ const THEME_KEY = 'shell:style';
2
+ const DARK_CLASS = 'pf-v6-theme-dark';
3
+
4
+ function prefersDark(): boolean {
5
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
6
+ }
7
+
8
+ function applyTheme(override?: string): void {
9
+ const style = override ?? localStorage.getItem(THEME_KEY) ?? 'auto';
10
+ const dark = style === 'dark' || (style === 'auto' && prefersDark());
11
+ document.documentElement.classList.toggle(DARK_CLASS, dark);
12
+ }
13
+
14
+ window.addEventListener('storage', (event: StorageEvent) => {
15
+ if (event.key === THEME_KEY) {
16
+ applyTheme();
17
+ }
18
+ });
19
+
20
+ window.addEventListener('cockpit-style', (event: Event) => {
21
+ if (event instanceof CustomEvent) {
22
+ applyTheme(event.detail?.style);
23
+ }
24
+ });
25
+
26
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
27
+ applyTheme();
28
+ });
29
+
30
+ applyTheme();
@@ -0,0 +1,59 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { useAsyncAction } from "./useAsyncAction";
4
+
5
+ describe("useAsyncAction", () => {
6
+ it("starts with loading=false and no error", () => {
7
+ const { result } = renderHook(() => useAsyncAction(vi.fn().mockResolvedValue("ok")));
8
+ expect(result.current.loading).toBe(false);
9
+ expect(result.current.error).toBeNull();
10
+ });
11
+
12
+ it("returns the action result", async () => {
13
+ const action = vi.fn<() => Promise<string>>().mockResolvedValue("done");
14
+ const { result } = renderHook(() => useAsyncAction(action));
15
+ let value: string | undefined;
16
+ await act(async () => { value = await result.current.execute(); });
17
+ expect(value).toBe("done");
18
+ });
19
+
20
+ it("sets loading true during execution and false after", async () => {
21
+ let resolve!: (v: string) => void;
22
+ const action = vi.fn(() => new Promise<string>(r => { resolve = r; }));
23
+ const { result } = renderHook(() => useAsyncAction(action));
24
+
25
+ act(() => { void result.current.execute(); });
26
+ expect(result.current.loading).toBe(true);
27
+
28
+ await act(async () => { resolve("ok"); });
29
+ expect(result.current.loading).toBe(false);
30
+ });
31
+
32
+ it("captures error message on failure", async () => {
33
+ const action = vi.fn().mockRejectedValue(new Error("boom"));
34
+ const { result } = renderHook(() => useAsyncAction(action));
35
+
36
+ await act(async () => { await result.current.execute(); });
37
+ expect(result.current.error).toBe("boom");
38
+ expect(result.current.loading).toBe(false);
39
+ });
40
+
41
+ it("stringifies non-Error rejections", async () => {
42
+ const action = vi.fn().mockRejectedValue("raw string error");
43
+ const { result } = renderHook(() => useAsyncAction(action));
44
+
45
+ await act(async () => { await result.current.execute(); });
46
+ expect(result.current.error).toBe("raw string error");
47
+ });
48
+
49
+ it("clears error via clearError", async () => {
50
+ const action = vi.fn().mockRejectedValue(new Error("oops"));
51
+ const { result } = renderHook(() => useAsyncAction(action));
52
+
53
+ await act(async () => { await result.current.execute(); });
54
+ expect(result.current.error).toBe("oops");
55
+
56
+ act(() => { result.current.clearError(); });
57
+ expect(result.current.error).toBeNull();
58
+ });
59
+ });