diff --git a/docs/configs/services.md b/docs/configs/services.md index 55d4b9217..ead69ea15 100644 --- a/docs/configs/services.md +++ b/docs/configs/services.md @@ -139,7 +139,7 @@ Each widget can optionally provide a list of which fields should be visible via ### Block Highlighting -Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`). +Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`). The custom api widget does not support highlighting. ```yaml - Sonarr: diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md index 37a48fd8c..c820a21bb 100644 --- a/docs/widgets/services/customapi.md +++ b/docs/widgets/services/customapi.md @@ -146,6 +146,10 @@ The widget supports different display modes that can be set using the `display` The default display mode is `block`, which shows fields in a block format. +!!! note + + The Custom API widget does not currently support [block highlighting](../../configs/services.md#block-highlighting) in any display mode. + ### List View You can change the default block view to a list view by setting the `display` option to `list`. diff --git a/docs/widgets/services/sportarr.md b/docs/widgets/services/sportarr.md new file mode 100644 index 000000000..1faee0e83 --- /dev/null +++ b/docs/widgets/services/sportarr.md @@ -0,0 +1,17 @@ +--- +title: Sportarr +description: Sportarr Widget Configuration +--- + +Learn more about [Sportarr](https://github.com/Sportarr/Sportarr). + +Find your API key under `Settings > General`. + +Allowed fields: `["wanted", "queued", "leagues"]`. + +```yaml +widget: + type: sportarr + url: http://sportarr.host.or.ip:1867 + key: sportarrapikeyapikeyapikeyapikeyapikey +``` diff --git a/mkdocs.yml b/mkdocs.yml index 5b4a53686..41543407f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -159,6 +159,7 @@ nav: - widgets/services/sparkyfitness.md - widgets/services/speedtest-tracker.md - widgets/services/spoolman.md + - widgets/services/sportarr.md - widgets/services/stash.md - widgets/services/stocks.md - widgets/services/suwayomi.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 27d0b4c48..dfa2140e8 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1230,5 +1230,10 @@ "synced": "Synced", "errors": "Errors", "storage": "Storage" + }, + "sportarr": { + "wanted": "Wanted", + "queued": "Queued", + "leagues": "Leagues" } } diff --git a/src/widgets/components.js b/src/widgets/components.js index 657077391..2d0dd6bfc 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -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")), diff --git a/src/widgets/sportarr/component.jsx b/src/widgets/sportarr/component.jsx new file mode 100644 index 000000000..83b906ae4 --- /dev/null +++ b/src/widgets/sportarr/component.jsx @@ -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 ; + } + + if (!wantedData || !queueData || !leaguesData) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +} diff --git a/src/widgets/sportarr/component.test.jsx b/src/widgets/sportarr/component.test.jsx new file mode 100644 index 000000000..b88b46bdd --- /dev/null +++ b/src/widgets/sportarr/component.test.jsx @@ -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(, { + 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(, { + 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(, { + settings: { hideErrors: false }, + }); + + expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0); + }); +}); diff --git a/src/widgets/sportarr/widget.js b/src/widgets/sportarr/widget.js new file mode 100644 index 000000000..a31e252a3 --- /dev/null +++ b/src/widgets/sportarr/widget.js @@ -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; diff --git a/src/widgets/sportarr/widget.test.js b/src/widgets/sportarr/widget.test.js new file mode 100644 index 000000000..922ddffb5 --- /dev/null +++ b/src/widgets/sportarr/widget.test.js @@ -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"]); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 3ccaddd25..17be5aedc 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -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,