From 7a0212d217ebb64767caa9c01f1684a3b8605184 Mon Sep 17 00:00:00 2001 From: Rassl Date: Thu, 23 Jul 2026 10:57:36 +0000 Subject: [PATCH] Generated with Hive: Add node type filter chip row with counts to admin reviews panel --- src/app/admin/reviews/page.tsx | 93 +++++++++++- src/lib/__tests__/reviews.test.tsx | 234 ++++++++++++++++++++++++++++- src/lib/graph-api.ts | 56 ++++++- 3 files changed, 377 insertions(+), 6 deletions(-) diff --git a/src/app/admin/reviews/page.tsx b/src/app/admin/reviews/page.tsx index 351efe9..129bb2a 100644 --- a/src/app/admin/reviews/page.tsx +++ b/src/app/admin/reviews/page.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input" import type { LucideIcon } from "lucide-react" import { useReviewStore } from "@/stores/review-store" import { useSchemaStore } from "@/stores/schema-store" -import { approveReview, dismissReview, listReviews } from "@/lib/graph-api" +import { approveReview, dismissReview, listReviews, getReviewNodeTypeCounts } from "@/lib/graph-api" import type { Review, ReviewStatus } from "@/lib/graph-api" import { ReviewRow, getApproveVerb } from "@/components/admin/review-row" import { Button } from "@/components/ui/button" @@ -86,7 +86,12 @@ export default function ReviewsPage() { const [bulkRunning, setBulkRunning] = useState(null) const [bulkError, setBulkError] = useState(null) + const [nodeTypeFilter, setNodeTypeFilter] = useState("") + const [nodeTypeCounts, setNodeTypeCounts] = useState>({}) + const [truncatedCounts, setTruncatedCounts] = useState(false) + const abortRef = useRef(null) + const nodeTypeCountsTimerRef = useRef | null>(null) const fetchReviews = useCallback( async (currentSkip = 0, options?: { silent?: boolean }) => { @@ -105,6 +110,7 @@ export default function ReviewsPage() { skip: currentSkip, limit: PAGE_SIZE, search: debouncedSearch || undefined, + node_type: nodeTypeFilter || undefined, }, ctrl.signal ) @@ -129,13 +135,33 @@ export default function ReviewsPage() { if (!options?.silent) setLoading(false) } }, - [statusFilter, actionFilter, sort, debouncedSearch] + [statusFilter, actionFilter, sort, debouncedSearch, nodeTypeFilter] ) useEffect(() => { fetchReviews(0) }, [fetchReviews]) + // Node type counts for filter chip row + const fetchNodeTypeCounts = useCallback(() => { + if (nodeTypeCountsTimerRef.current) clearTimeout(nodeTypeCountsTimerRef.current) + nodeTypeCountsTimerRef.current = setTimeout(async () => { + try { + const res = await getReviewNodeTypeCounts({ + status: statusFilter || undefined, + action_name: actionFilter || undefined, + search: debouncedSearch || undefined, + }) + setNodeTypeCounts(res.counts) + setTruncatedCounts(res.truncated) + } catch {} + }, 300) + }, [statusFilter, actionFilter, debouncedSearch]) + + useEffect(() => { + fetchNodeTypeCounts() + }, [fetchNodeTypeCounts]) + // Pending count for the tab badge — refreshed independently of the active query const refreshPendingCount = useCallback(async () => { try { @@ -250,7 +276,7 @@ export default function ReviewsPage() { + {/* Typed chips sorted by count desc, Unknown pinned last */} + {Object.entries(nodeTypeCounts) + .filter(([key]) => key !== "Unknown") + .sort((a, b) => b[1] - a[1]) + .concat( + "Unknown" in nodeTypeCounts ? [["Unknown", nodeTypeCounts["Unknown"]]] : [] + ) + .map(([type, count]) => ( + + ))} + + {truncatedCounts && ( +

+ Counts reflect the first 5,000 reviews in this view +

+ )} + + )} + {/* Selection bar (only on Pending tab when there's something to select) */} {!loading && !error && statusFilter === "pending" && selectableReviews.length > 0 && (
({ +const { mockApproveReview, mockDismissReview, mockListReviews, mockGetReviewNodeTypeCounts } = vi.hoisted(() => ({ mockApproveReview: vi.fn(), mockDismissReview: vi.fn(), mockListReviews: vi.fn(), + mockGetReviewNodeTypeCounts: vi.fn(), })) vi.mock("@/lib/graph-api", async (importOriginal) => { @@ -18,6 +19,7 @@ vi.mock("@/lib/graph-api", async (importOriginal) => { approveReview: (...args: unknown[]) => mockApproveReview(...args), dismissReview: (...args: unknown[]) => mockDismissReview(...args), listReviews: (...args: unknown[]) => mockListReviews(...args), + getReviewNodeTypeCounts: (...args: unknown[]) => mockGetReviewNodeTypeCounts(...args), } }) @@ -1421,6 +1423,236 @@ describe("ReviewsPage search UI", () => { }) }) +// ── Node type filter chip row ───────────────────────────────────────────────── + +describe("Node type filter chip row", () => { + beforeEach(async () => { + vi.resetModules() + mockListReviews.mockResolvedValue({ reviews: [], total: 0, skip: 0, limit: 20 }) + mockGetReviewNodeTypeCounts.mockResolvedValue({ counts: {}, truncated: false }) + }) + + async function renderPage() { + vi.doMock("@/stores/review-store", () => ({ + useReviewStore: () => ({ pendingCount: 0, setPendingCount: vi.fn() }), + })) + vi.doMock("@/stores/schema-store", () => ({ + useSchemaStore: (sel: (s: { schemas: never[] }) => unknown) => + sel({ schemas: [] }), + })) + vi.doMock("@/components/admin/review-row", () => ({ + ReviewRow: ({ review }: { review: Review }) => ( +
{review.rationale}
+ ), + getApproveVerb: (action: string) => action, + })) + vi.doMock("@/components/ui/select-custom", () => ({ + SelectCustom: () =>
, + })) + vi.doMock("@/components/ui/checkbox", () => ({ + Checkbox: () => , + })) + const { default: ReviewsPage } = await import("@/app/admin/reviews/page") + return render() + } + + it("does not render filter row when nodeTypeCounts has 0 keys", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ counts: {}, truncated: false }) + const { queryByText } = await renderPage() + // Wait for initial load + await waitFor(() => expect(mockListReviews).toHaveBeenCalled()) + // "All" chip from node type row should not be present (distinct from action chip "All") + // The node type row is hidden entirely; we check no count-badged chips + expect(queryByText("Topic")).toBeNull() + expect(queryByText("Person")).toBeNull() + }) + + it("does not render filter row when nodeTypeCounts has exactly 1 key", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ counts: { Topic: 3 }, truncated: false }) + const { queryByText } = await renderPage() + await waitFor(() => expect(mockGetReviewNodeTypeCounts).toHaveBeenCalled()) + // Single type — row should be hidden + expect(queryByText("Topic")).toBeNull() + }) + + it("renders filter row with chips when nodeTypeCounts has more than 1 key", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Person: 3 }, + truncated: false, + }) + const { findByText } = await renderPage() + // Both type chips should appear + await findByText("Topic") + await findByText("Person") + }) + + it("pins Unknown chip last regardless of count rank", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Unknown: 10, Topic: 5, Person: 3 }, + truncated: false, + }) + const { findAllByRole } = await renderPage() + await waitFor(() => expect(mockGetReviewNodeTypeCounts).toHaveBeenCalled()) + // Wait for chips to appear + await waitFor(async () => { + const buttons = await findAllByRole("button") + const chipLabels = buttons + .map((b: HTMLElement) => b.textContent ?? "") + .filter((t: string) => ["Topic", "Person", "Unknown"].some((k) => t.startsWith(k))) + // Unknown should appear after Topic and Person + const topicIdx = chipLabels.findIndex((t: string) => t.startsWith("Topic")) + const personIdx = chipLabels.findIndex((t: string) => t.startsWith("Person")) + const unknownIdx = chipLabels.findIndex((t: string) => t.startsWith("Unknown")) + expect(topicIdx).toBeGreaterThanOrEqual(0) + expect(unknownIdx).toBeGreaterThan(topicIdx) + expect(unknownIdx).toBeGreaterThan(personIdx) + }) + }) + + it("clicking a node type chip calls listReviews with node_type and skip=0", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Person: 3 }, + truncated: false, + }) + const user = userEvent.setup() + const { findByText } = await renderPage() + const topicChip = await findByText("Topic") + mockListReviews.mockClear() + await user.click(topicChip) + await waitFor(() => { + expect(mockListReviews).toHaveBeenCalledWith( + expect.objectContaining({ node_type: "Topic", skip: 0 }), + expect.anything() + ) + }) + }) + + it("clicking status tab resets nodeTypeFilter and calls listReviews exactly once", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Person: 3 }, + truncated: false, + }) + const user = userEvent.setup() + const { findByText } = await renderPage() + + // First select a node type chip + const topicChip = await findByText("Topic") + await user.click(topicChip) + await waitFor(() => { + expect(mockListReviews).toHaveBeenCalledWith( + expect.objectContaining({ node_type: "Topic" }), + expect.anything() + ) + }) + + // Now click a different status tab + mockListReviews.mockClear() + const approvedTab = await findByText("Approved") + await user.click(approvedTab) + + await waitFor(() => { + // Should have been called, and node_type should NOT be set (reset to "") + const calls = mockListReviews.mock.calls + const lastCall = calls[calls.length - 1][0] + expect(lastCall.node_type).toBeUndefined() + }) + }) + + it("clicking action chip resets nodeTypeFilter", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Person: 3 }, + truncated: false, + }) + const user = userEvent.setup() + const { findByText } = await renderPage() + + // Select a node type chip + const topicChip = await findByText("Topic") + await user.click(topicChip) + await waitFor(() => { + expect(mockListReviews).toHaveBeenCalledWith( + expect.objectContaining({ node_type: "Topic" }), + expect.anything() + ) + }) + + // Click an action chip (Merge) + mockListReviews.mockClear() + const mergeChip = await findByText("Merge") + await user.click(mergeChip) + + await waitFor(() => { + const calls = mockListReviews.mock.calls + const lastCall = calls[calls.length - 1][0] + expect(lastCall.node_type).toBeUndefined() + }) + }) + + it("Unknown chip appears for reviews with empty subject_nodes and filters correctly", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Unknown: 2 }, + truncated: false, + }) + const unknownReview = makeReview({ + ref_id: "rv-unknown", + subject_ids: [], + subject_nodes: [], + action_name: "add_node", + }) + const user = userEvent.setup() + const { findByText } = await renderPage() + + const unknownChip = await findByText("Unknown") + expect(unknownChip).toBeTruthy() + + // Click Unknown chip + mockListReviews.mockResolvedValueOnce({ reviews: [unknownReview], total: 1, skip: 0, limit: 20 }) + await user.click(unknownChip) + + await waitFor(() => { + expect(mockListReviews).toHaveBeenCalledWith( + expect.objectContaining({ node_type: "Unknown", skip: 0 }), + expect.anything() + ) + }) + }) + + it("getReviewNodeTypeCounts is called with debouncedSearch when search term is entered", async () => { + const user = userEvent.setup() + mockGetReviewNodeTypeCounts.mockResolvedValue({ counts: {}, truncated: false }) + const { getByPlaceholderText } = await renderPage() + + await waitFor(() => expect(getByPlaceholderText("Search reviews…")).toBeTruthy()) + mockGetReviewNodeTypeCounts.mockClear() + + const input = getByPlaceholderText("Search reviews…") + await user.type(input, "bitcoin") + + await waitFor( + () => { + const calls = mockGetReviewNodeTypeCounts.mock.calls + const withSearch = calls.find( + (c: unknown[]) => + typeof c[0] === "object" && + c[0] !== null && + (c[0] as Record).search === "bitcoin" + ) + expect(withSearch).toBeTruthy() + }, + { timeout: 2000 } + ) + }) + + it("renders truncation warning when truncated is true", async () => { + mockGetReviewNodeTypeCounts.mockResolvedValue({ + counts: { Topic: 5, Person: 3 }, + truncated: true, + }) + const { findByText } = await renderPage() + await findByText("Counts reflect the first 5,000 reviews in this view") + }) +}) + // ── Non-admin: toolkit hides Reviews nav item ──────────────────────────────── describe("Toolkit non-admin", () => { diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 036a03b..dd613b1 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -778,7 +778,7 @@ function getMockReviewsStore(): Review[] { } export async function listReviews( - params?: { status?: ReviewStatus; type?: string; action_name?: string; sort?: string; skip?: number; limit?: number; search?: string }, + params?: { status?: ReviewStatus; type?: string; action_name?: string; sort?: string; skip?: number; limit?: number; search?: string; node_type?: string }, signal?: AbortSignal ): Promise { if (isMocksEnabled()) { @@ -795,6 +795,13 @@ export async function listReviews( r.display_label?.toLowerCase().includes(q) ) } + if (params?.node_type) { + filtered = filtered.filter((r) => + params.node_type === "Unknown" + ? !r.subject_nodes || r.subject_nodes.length === 0 + : r.subject_nodes?.some((sn) => sn.node_type === params.node_type) + ) + } const sort = params?.sort ?? "created_at" if (sort === "priority") { filtered.sort((a, b) => b.priority - a.priority) @@ -819,9 +826,56 @@ export async function listReviews( if (params?.skip !== undefined) qs.set("skip", String(params.skip)) if (params?.limit !== undefined) qs.set("limit", String(params.limit)) if (params?.search) qs.set("search", params.search) + if (params?.node_type) qs.set("node_type", params.node_type) return api.get(`/v2/reviews?${qs}`, undefined, signal) } +export async function getReviewNodeTypeCounts( + params?: { status?: ReviewStatus; action_name?: string; search?: string }, + signal?: AbortSignal +): Promise<{ counts: Record; truncated: boolean }> { + if (isMocksEnabled()) { + const store = getMockReviewsStore() + let filtered = [...store] + if (params?.status) filtered = filtered.filter((r) => r.status === params.status) + if (params?.action_name) filtered = filtered.filter((r) => r.action_name === params.action_name) + if (params?.search) { + const q = params.search.toLowerCase() + filtered = filtered.filter( + (r) => + r.rationale?.toLowerCase().includes(q) || + r.display_label?.toLowerCase().includes(q) + ) + } + const counts: Record = {} + for (const review of filtered) { + if (!review.subject_nodes || review.subject_nodes.length === 0) { + counts["Unknown"] = (counts["Unknown"] ?? 0) + 1 + } else { + const seen = new Set() + for (const sn of review.subject_nodes) { + const nt = sn.node_type ?? "Unknown" + if (!seen.has(nt)) { + seen.add(nt) + counts[nt] = (counts[nt] ?? 0) + 1 + } + } + } + } + return { counts, truncated: false } + } + + const qs = new URLSearchParams() + if (params?.status) qs.set("status", params.status) + if (params?.action_name) qs.set("action_name", params.action_name) + if (params?.search) qs.set("search", params.search) + return api.get<{ counts: Record; truncated: boolean }>( + `/v2/reviews/node_type_counts?${qs}`, + undefined, + signal + ) +} + export async function approveReview( refId: string, overridePayload?: { from: string[]; to: string },