Feature: sportarr service widget (#6906)

Co-authored-by: Sportarr <sportarr@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Sportarr
2026-07-28 18:42:35 +00:00
committed by GitHub
co-authored by Sportarr shamoon
parent 70f3b1165a
commit d01422324b
11 changed files with 181 additions and 1 deletions
+1
View File
@@ -133,6 +133,7 @@ const components = {
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
speedtest: dynamic(() => import("./speedtest/component")),
spoolman: dynamic(() => import("./spoolman/component")),
sportarr: dynamic(() => import("./sportarr/component")),
stash: dynamic(() => import("./stash/component")),
stocks: dynamic(() => import("./stocks/component")),
strelaysrv: dynamic(() => import("./strelaysrv/component")),
+37
View File
@@ -0,0 +1,37 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next/pages";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: wantedData, error: wantedError } = useWidgetAPI(widget, "wanted/missing", { page: 1, pageSize: 1 });
const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue");
const { data: leaguesData, error: leaguesError } = useWidgetAPI(widget, "leagues");
if (wantedError || queueError || leaguesError) {
const finalError = wantedError ?? queueError ?? leaguesError;
return <Container service={service} error={finalError} />;
}
if (!wantedData || !queueData || !leaguesData) {
return (
<Container service={service}>
<Block label="sportarr.wanted" />
<Block label="sportarr.queued" />
<Block label="sportarr.leagues" />
</Container>
);
}
return (
<Container service={service}>
<Block label="sportarr.wanted" value={t("common.number", { value: wantedData.totalRecords })} />
<Block label="sportarr.queued" value={t("common.number", { value: queueData.total })} />
<Block label="sportarr.leagues" value={t("common.number", { value: leaguesData.total })} />
</Container>
);
}
+61
View File
@@ -0,0 +1,61 @@
// @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";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
describe("widgets/sportarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "sportarr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("sportarr.wanted")).toBeInTheDocument();
expect(screen.getByText("sportarr.queued")).toBeInTheDocument();
expect(screen.getByText("sportarr.leagues")).toBeInTheDocument();
});
it("renders counts when all endpoints resolve", () => {
useWidgetAPI.mockImplementation((_widget, endpoint) => {
if (endpoint === "wanted/missing") return { data: { totalRecords: 4 }, error: undefined };
if (endpoint === "queue") return { data: { total: 2 }, error: undefined };
if (endpoint === "leagues") return { data: { total: 7 }, error: undefined };
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "sportarr" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "sportarr.wanted", 4);
expectBlockValue(container, "sportarr.queued", 2);
expectBlockValue(container, "sportarr.leagues", 7);
});
it("renders the error state when an endpoint fails", () => {
useWidgetAPI.mockImplementation((_widget, endpoint) => {
if (endpoint === "queue") return { data: undefined, error: { message: "unreachable" } };
return { data: undefined, error: undefined };
});
renderWithProviders(<Component service={{ widget: { type: "sportarr" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { asJson } from "utils/proxy/api-helpers";
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/{endpoint}?apikey={key}",
proxyHandler: genericProxyHandler,
mappings: {
"wanted/missing": {
endpoint: "wanted/missing",
params: ["page", "pageSize"],
validate: ["totalRecords"],
},
queue: {
endpoint: "queue",
map: (data) => ({ total: asJson(data).length }),
},
leagues: {
endpoint: "leagues",
map: (data) => ({ total: asJson(data).length }),
},
},
};
export default widget;
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("sportarr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
it("maps the queue response to a total count", () => {
const result = widget.mappings.queue.map(Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }, { id: 3 }])));
expect(result).toEqual({ total: 3 });
});
it("maps the leagues response to a total count", () => {
const result = widget.mappings.leagues.map(Buffer.from(JSON.stringify([{ id: 1, name: "Formula 1" }])));
expect(result).toEqual({ total: 1 });
});
it("validates totalRecords on the wanted endpoint", () => {
expect(widget.mappings["wanted/missing"].validate).toEqual(["totalRecords"]);
});
});
+2
View File
@@ -123,6 +123,7 @@ import sonarr from "./sonarr/widget";
import sparkyfitness from "./sparkyfitness/widget";
import speedtest from "./speedtest/widget";
import spoolman from "./spoolman/widget";
import sportarr from "./sportarr/widget";
import stash from "./stash/widget";
import stocks from "./stocks/widget";
import strelaysrv from "./strelaysrv/widget";
@@ -286,6 +287,7 @@ const widgets = {
sparkyfitness,
speedtest,
spoolman,
sportarr,
stash,
stocks,
strelaysrv,