Files
manage/frontend/src/components/JellyseerRequestsTable.tsx
T
Developer 45c295457a fix(jellyseer): resolve request titles via /movie|tv endpoints
The requests table showed all names as "—" because Jellyseerr's /api/v1/request
list does NOT embed titles — they live on the Movie/Series records. Added
JellyseerrClient._resolve_title(media_type, tmdb_id) that fetches
/api/v1/movie/{tmdbId} (→ title) or /api/v1/tv/{tmdbId} (→ name), cached on
the client instance so subsequent polls are instant.

Also scoped the table fetch to open requests only (pending + approved) via
Jellyseerr's filter param, instead of fetching all 800+ historical requests.
open_requests() fetches pending+approved (paginated), resolves their titles
(small set → fast), and returns them sorted by date added desc.

Updated the frontend table's status filter to Open/Pending/Approved (the data
only contains open requests now).

Tests: title resolution end-to-end (movie tmdbId → title), caching across
polls, filter param used. 404/404 backend + 184/184 frontend + build green.
2026-07-13 09:58:25 +00:00

253 lines
6.8 KiB
TypeScript

/**
* 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" | "pending" | "approved";
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 (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="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</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>
);
}