Finally the widget

This commit is contained in:
shamoon
2026-09-22 20:06:16 -07:00
parent 44d8699519
commit 7c3774ee5a
3 changed files with 139 additions and 0 deletions
@@ -0,0 +1,51 @@
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { TbApi } from "react-icons/tb";
import useSWR from "swr";
import Container from "../widget/container";
import Error from "../widget/error";
import Raw from "../widget/raw";
import ResolvedIcon from "components/resolvedicon";
import { formatValue, getValue } from "widgets/customapi/utils";
export default function CustomApi({ options }) {
const { t } = useTranslation();
const { index, icon, mappings = [], refreshInterval = 10000 } = options;
const { data, error } = useSWR(`/api/widgets/customapi?${new URLSearchParams({ index }).toString()}`, {
refreshInterval: Math.max(1000, refreshInterval),
});
if (error || (data?.error && !mappings.some((mapping) => mapping.field === "error"))) {
return <Error options={options} />;
}
return (
<Container options={options} additionalClassNames="information-widget-customapi">
<Raw>
<div className="flex flex-row items-center">
<div className="flex-none mr-2">
{icon ? (
<ResolvedIcon icon={icon} width={24} height={24} />
) : (
<TbApi className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className={classNames("flex flex-wrap items-center gap-0.5", !data && "animate-pulse")}>
{mappings.map((mapping) => (
<div key={mapping.label} className="flex flex-col items-center justify-center px-1 text-xs">
<span className="text-theme-800 dark:text-theme-200">{mapping.label}</span>
<span className="text-theme-800/70 dark:text-theme-200/50">
{data ? formatValue(t, mapping, getValue(mapping.field, data)) : "-"}
</span>
</div>
))}
</div>
</div>
</Raw>
</Container>
);
}
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("components/resolvedicon", () => ({
default: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
}));
import CustomApi from "./customapi";
const mappings = [
{ field: "count", label: "Count" },
{ field: "nested.name", label: "Name" },
];
describe("components/widgets/customapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("requests the route by widget index with a minimum refresh interval", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<CustomApi options={{ index: 2, mappings, refreshInterval: 10 }} />, {
settings: { target: "_self" },
});
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=2", { refreshInterval: 1000 });
});
it("renders labels with placeholders while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, { settings: { target: "_self" } });
expect(screen.getByText("Count")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(2);
});
it("renders mapped values and the configured icon", () => {
useSWR.mockReturnValue({ data: { count: 5, nested: { name: "foo" } }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, icon: "mdi-api", mappings }} />, {
settings: { target: "_self" },
});
expect(screen.getByTestId("resolved-icon")).toHaveAttribute("data-icon", "mdi-api");
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText("foo")).toBeInTheDocument();
});
it("falls back to a default icon when none is configured", () => {
useSWR.mockReturnValue({ data: { count: 5 }, error: undefined });
const { container } = renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, {
settings: { target: "_self" },
});
expect(screen.queryByTestId("resolved-icon")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("renders an error widget when the route returns an error", () => {
useSWR.mockReturnValue({ data: { error: { message: "HTTP Error" } }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("treats an error key as data when a mapping reads it", () => {
useSWR.mockReturnValue({ data: { error: "none" }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings: [{ field: "error", label: "Error" }] }} />, {
settings: { target: "_self" },
});
expect(screen.queryByText("widget.api_error")).toBeNull();
expect(screen.getByText("none")).toBeInTheDocument();
});
});
+1
View File
@@ -16,6 +16,7 @@ const widgetMappings = {
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
stocks: dynamic(() => import("components/widgets/stocks/stocks")),
customapi: dynamic(() => import("components/widgets/customapi/customapi")),
};
export default function Widget({ widget, style }) {