feat(jellyseer): sortable/filterable requests table on the Requests tab
Replace the static "recent requests" list with a proper table of all Jellyseerr requests, sorted by date added (newest first by default) with standard sorting and filtering. Backend: - JellyseerrClient.requests(max_count=500): paginated GET /api/v1/request (sort=added), mapped with type (movie/tv), status, media_status, and created_at labels. Returns up to 500 so the table can sort/filter client-side. - fetch_jellyseer_requests(service) reuses the per-service cached client (shared with the stats widgets). - new GET /api/jellyseerr/requests endpoint. Frontend: - JellyseerRequestsTable: TanStack Table (sorting via getSortedRowModel, pagination via getPaginationRowModel) reusing the Table primitives + TablePagination. Columns: Name / Type / Status / Media / Requested, all sortable; default sort Requested desc. A search box filters by name and a status dropdown defaults to "Open" (pending+approved+processing) with All/Pending/Approved/Declined options. (The shared DataTable is deliberately visibility-only, so this is a dedicated sortable table.) - RequestsTab renders the stats grid + the new table (the compact recent list stays on the Requests overview widget). - useJellyseerRequests hook + fetchJellyseerRequests API client. Tests: client requests() mapping + single-page stop; fetch helper not-configured; RequestsTab test mocks both hooks. 404/404 backend + 184/184 frontend pass; build (tsc -b && vite build) + ESLint clean.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: frontend/src/pages/service-tabs
|
||||
|
||||
## role
|
||||
Provides service-specific tabbed UI content components rendered within service detail pages.
|
||||
Provides service-specific tabbed UI components for managing and monitoring individual service instances across various integrations (SSH, Alertmanager, Jellyfin, Authentik, Prometheus, etc.).
|
||||
## parent
|
||||
index: frontend/src/pages/.pi-map.index.md
|
||||
map: frontend/src/pages/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: frontend/src/pages/service-tabs
|
||||
index: frontend/src/pages/service-tabs/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides service-specific tabbed UI content components rendered within service detail pages.
|
||||
Provides service-specific tabbed UI components for managing and monitoring individual service instances across various integrations (SSH, Alertmanager, Jellyfin, Authentik, Prometheus, etc.).
|
||||
## files
|
||||
- ActionsTab.tsx | Provides a UI tab for managing, editing, and running saved SSH tasks (shell or Python) within a service page. | exp: func:ActionsTab({ instance }: { instance: ServiceInstance }), call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:saveTask.mutateAsync, call:setTab, call:String, call:openEdit, call:tasks.map, call:initialFromTask, call:runTask.mutateAsync, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../../types, ../../hooks/useSettings, ../../components/DialogFooter, ../../components/HoverEditButton, ../../components/SectionCard, ../../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
|
||||
- AlertsTab.tsx | Renders an Alertmanager alerts tab showing alert summaries and an expandable list of active alerts scoped by instance ID. | exp: func:AlertsTab({ instance }: { instance: ServiceInstance }), call:useAlertmanagerAlerts, call:useAlertmanagerStatus, call:alertsSummary.alerts.map | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, @/components/ui/collapsible, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, ui/collapsible, types
|
||||
@@ -14,11 +14,11 @@ Provides service-specific tabbed UI content components rendered within service d
|
||||
- MessagingTab.tsx | Provides a UI for composing and sending HTML email messages to Authentik users via a mail queue system. | exp: func:MessagingTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:useSendAuthentikMessage, call:(data?.items ?? []).filter, call:setSelectedEmails, call:next.has, call:next.delete, call:next.add, call:subject.trim, call:sendMessage.mutate, call:Array.from, call:sendMessage.data.request_id?.slice, call:setSearch, call:users.slice(0, 20).map, call:selectedEmails.has, call:toggleEmail, call:setSubject, call:setHtmlBody | dep: react, @/components/ui/alert, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/textarea, ../../hooks/useAuthentik, ../../types
|
||||
- MetricsTab.tsx | Renders a Prometheus metrics monitoring tab showing service health status and Node Exporter scrape targets for a given service instance. | exp: func:MetricsTab({ instance }: { instance: ServiceInstance }), call:usePrometheusStatus, call:usePrometheusTargets | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, types
|
||||
- OverviewTab.tsx | Renders a configurable per-service overview tab that displays and manages service-specific widgets in a responsive grid. | exp: func:OverviewTab({ instance }: { instance: ServiceInstance }), call:useWidgetInstances, call:useState, call:useMemo, call:widgets .filter((w) => w.enabled) .sort, call:setConfigOpen, call:visibleWidgets.map, call:setEditWidgetId | dep: react, @/components/ui/alert, @/components/ui/button, lucide-react, ../../hooks/useWidgets, ../../components/WidgetInstance, ../../components/WidgetConfigDialog, ../../types, ui/alert, ui/button, useWidgets hook, WidgetInstance component, WidgetConfigDialog component, types
|
||||
- RequestsTab.tsx | Displays Jellyseerr request statistics and recent requests for a Jellyfin service instance. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record<string, unknown>).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map, call:data.recent.slice(0, 12).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../../components/MetricCard, lucide-react, ServiceInstance, useJellyseerrStats, Alert, Badge, Skeleton, MetricCard
|
||||
- RequestsTab.tsx | Displays Jellyseerr request statistics and a requests table for a Jellyfin service instance, with configuration validation and loading/error states. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record<string, unknown>).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/skeleton, ../../components/MetricCard, ../../components/JellyseerRequestsTable, lucide-react, ServiceInstance type, useJellyseerrStats hook, Alert, Skeleton, MetricCard, JellyseerRequestsTable
|
||||
- UsersTab.tsx | Displays a searchable, paginated table of Authentik users for a given service instance. | exp: func:UsersTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:Math.max, call:Math.ceil, call:setPage, call:setCommittedSearch, call:setSearch, call:handleSearch, call:users.map, call:Math.min | dep: react, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/table, ../../types, ../../hooks/useAuthentik
|
||||
- index.ts | Maps service types to their corresponding content tab components for rendering a service page. | exp: ServiceTabComponent, ContentTab, OVERVIEW_TAB, func:serviceContentTabs(serviceType: string) → ContentTab[] | dep: react, ../../types, ./OverviewTab, ./AlertsTab, ./MetricsTab, ./MediaTab, ./RequestsTab, ./FilesTab, ./ActionsTab, ./JobsTab, ./UsersTab, ./MessagingTab, OverviewTab, AlertsTab, MetricsTab, MediaTab, RequestsTab, FilesTab, ActionsTab, JobsTab, UsersTab, MessagingTab
|
||||
## arch
|
||||
Component-per-tab pattern with a central registry (index.ts) mapping service types to their respective tab components.
|
||||
Tab-based component architecture with a central type-to-component mapping registry, each tab being a self-contained React component scoped by service instance ID with shared patterns for pagination, loading/error states, and data tables.
|
||||
## tags
|
||||
call:use, components, ui, tab, state, call:set, locale, string
|
||||
## symbols
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* RequestsTab — Jellyseerr request stats surface on the Jellyfin page.
|
||||
*
|
||||
* Reads the Jellyfin service's jellyseerr_url (config) + jellyseerr_api_key
|
||||
* (secret). When configured, polls /api/jellyseerr/stats and renders the
|
||||
* request-count grid + a recent-requests list. Individual stats can be pinned
|
||||
* to dashboards via the "Request stat" widget.
|
||||
* (secret). When configured, polls /api/jellyseerr/stats for the count grid and
|
||||
* /api/jellyseerr/requests for a sortable/filterable requests table. Individual
|
||||
* stats can be pinned to dashboards via the "Request stat" widget.
|
||||
*/
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { useJellyseerrStats } from "../../hooks/useJellyseer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MetricCard } from "../../components/MetricCard";
|
||||
import { JellyseerRequestsTable } from "../../components/JellyseerRequestsTable";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
|
||||
@@ -75,36 +75,16 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.recent && data.recent.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Recent requests
|
||||
</span>
|
||||
{data.recent.slice(0, 12).map((r, i) => (
|
||||
<div
|
||||
key={String(r.id ?? i)}
|
||||
className="flex items-center justify-between gap-2 rounded border p-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{r.name ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{r.type ? String(r.type) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{r.media_status ? (
|
||||
<Badge variant="outline">{r.media_status}</Badge>
|
||||
) : null}
|
||||
{r.status ? <Badge variant="secondary">{r.status}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Requests
|
||||
</span>
|
||||
<JellyseerRequestsTable serviceId={instance.id} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pin individual stats to a dashboard with the “Request stat”
|
||||
widget.
|
||||
Pin individual stats to a dashboard with the “Request
|
||||
stat” widget.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RequestsTab } from "../RequestsTab";
|
||||
import { useJellyseerrStats } from "../../../hooks/useJellyseer";
|
||||
import {
|
||||
useJellyseerrStats,
|
||||
useJellyseerRequests,
|
||||
} from "../../../hooks/useJellyseer";
|
||||
import type { JellyseerStatsResponse } from "../../../api/jellyseerr";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
// Mock the stats hook so the tab renders without a QueryClientProvider and we
|
||||
// can drive the rendered state directly.
|
||||
// Mock both hooks so the tab + its table render without a QueryClientProvider.
|
||||
vi.mock("../../../hooks/useJellyseer", () => ({
|
||||
useJellyseerrStats: vi.fn(),
|
||||
useJellyseerRequests: vi.fn(),
|
||||
}));
|
||||
const mockUseJellyseerrStats = vi.mocked(useJellyseerrStats);
|
||||
const mockUseJellyseerRequests = vi.mocked(useJellyseerRequests);
|
||||
type StatsResult = ReturnType<typeof useJellyseerrStats>;
|
||||
type RequestsResult = ReturnType<typeof useJellyseerRequests>;
|
||||
|
||||
function mockStats(result: {
|
||||
data: JellyseerStatsResponse | undefined;
|
||||
@@ -20,6 +25,11 @@ function mockStats(result: {
|
||||
}) {
|
||||
// UseQueryResult has many fields; cast the partial we care about.
|
||||
mockUseJellyseerrStats.mockReturnValue(result as unknown as StatsResult);
|
||||
mockUseJellyseerRequests.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as unknown as RequestsResult);
|
||||
}
|
||||
|
||||
function makeInstance(
|
||||
@@ -57,7 +67,9 @@ describe("RequestsTab", () => {
|
||||
mockStats({ data: undefined, isLoading: false, error: null });
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({ jellyseerr_url: "https://requests.example.com" })}
|
||||
instance={makeInstance({
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
@@ -91,11 +103,12 @@ describe("RequestsTab", () => {
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("https://requests.example.com")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("https://requests.example.com"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a fetch error", () => {
|
||||
|
||||
Reference in New Issue
Block a user