diff --git a/src/components/widgets/customapi/customapi.jsx b/src/components/widgets/customapi/customapi.jsx new file mode 100644 index 000000000..05d13379e --- /dev/null +++ b/src/components/widgets/customapi/customapi.jsx @@ -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 ; + } + + return ( + + +
+
+ {icon ? ( + + ) : ( + + )} +
+
+ {mappings.map((mapping) => ( +
+ {mapping.label} + + {data ? formatValue(t, mapping, getValue(mapping.field, data)) : "-"} + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/widgets/customapi/customapi.test.jsx b/src/components/widgets/customapi/customapi.test.jsx new file mode 100644 index 000000000..dc9a2232f --- /dev/null +++ b/src/components/widgets/customapi/customapi.test.jsx @@ -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 }) =>
, +})); + +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(, { + 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(, { 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(, { + 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(, { + 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(, { 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(, { + settings: { target: "_self" }, + }); + + expect(screen.queryByText("widget.api_error")).toBeNull(); + expect(screen.getByText("none")).toBeInTheDocument(); + }); +}); diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx index c7f0bf4df..0da481131 100644 --- a/src/components/widgets/widget.jsx +++ b/src/components/widgets/widget.jsx @@ -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 }) {