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:
Developer
2026-07-12 17:18:21 +00:00
parent 54851779fb
commit 7665ef4d10
21 changed files with 447 additions and 77 deletions
@@ -0,0 +1,229 @@
/**
* JellyseerRequestsTable — sortable/filterable table of Jellyseerr requests.
*
* Uses TanStack Table directly (the shared DataTable is deliberately
* visibility-only). Defaults: status filter = "open" (pending/approved/
* processing), sorted by date added (newest first). Client-side sort + filter
* + pagination over the backend's fetched set.
*/
import { useMemo, useState } from "react";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TablePagination } from "@/components/ui/table-pagination";
import { useJellyseerRequests } from "../hooks/useJellyseer";
import type { JellyseerRequest } from "../api/jellyseerr";
const OPEN_STATUSES = new Set(["pending", "approved", "processing"]);
type StatusFilter = "open" | "all" | "pending" | "approved" | "declined";
function formatDate(v?: number | string): string {
if (!v) return "—";
const n = Number(v);
const ms = n > 1e12 ? n : n * 1000; // seconds -> ms
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
const columns: ColumnDef<JellyseerRequest>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="truncate font-medium">{row.original.name ?? "—"}</span>
),
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => (
<span className="capitalize">{row.original.type ?? "—"}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="secondary">{row.original.status ?? "—"}</Badge>
),
},
{
accessorKey: "media_status",
header: "Media",
cell: ({ row }) =>
row.original.media_status ? (
<Badge variant="outline">{row.original.media_status}</Badge>
) : (
"—"
),
},
{
accessorKey: "created_at",
header: "Requested",
cell: ({ row }) => formatDate(row.original.created_at),
sortDescFirst: true,
},
];
export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) {
const { data: requests = [], isLoading, error } = useJellyseerRequests(serviceId);
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return requests.filter((r) => {
const status = String(r.status ?? "");
if (statusFilter === "open") {
if (!OPEN_STATUSES.has(status)) return false;
} else if (statusFilter !== "all" && status !== statusFilter) {
return false;
}
if (q && !String(r.name ?? "").toLowerCase().includes(q)) return false;
return true;
});
}, [requests, statusFilter, search]);
/* eslint-disable react-hooks/incompatible-library -- TanStack's useReactTable
intentionally returns non-memoizable updater fns (controlled state). */
const table = useReactTable({
data: filtered,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
});
if (isLoading) {
return <Skeleton className="h-48 w-full" />;
}
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search requests…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="declined">Declined</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="hover:bg-transparent">
{hg.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<button
type="button"
className="inline-flex items-center gap-1"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{header.column.getIsSorted() === "asc" ? (
<ArrowUp className="size-3" />
) : header.column.getIsSorted() === "desc" ? (
<ArrowDown className="size-3" />
) : (
<ChevronsUpDown className="size-3 opacity-40" />
)}
</button>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={String(row.original.id ?? row.index)}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-16 text-center text-muted-foreground"
>
No requests.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<TablePagination
pageIndex={table.getState().pagination.pageIndex}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={[10, 20, 50]}
totalRows={table.getRowModel().rows.length}
pageCount={table.getPageCount()}
onPaginationChange={table.setPagination}
/>
</div>
);
}