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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user