diff --git a/frontend/src/app/approvals/page.test.tsx b/frontend/src/app/approvals/page.test.tsx new file mode 100644 index 0000000..3f4afe6 --- /dev/null +++ b/frontend/src/app/approvals/page.test.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import GlobalApprovalsPage from "./page"; +import { AuthProvider } from "@/components/providers/AuthProvider"; +import { QueryProvider } from "@/components/providers/QueryProvider"; + +vi.mock("next/link", () => { + type LinkProps = React.PropsWithChildren<{ + href: string | { pathname?: string }; + }> & + Omit, "href">; + + return { + default: ({ href, children, ...props }: LinkProps) => ( + + {children} + + ), + }; +}); + +vi.mock("@clerk/nextjs", () => { + return { + ClerkProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SignedIn: () => { + throw new Error( + "@clerk/nextjs SignedIn rendered (unexpected in secretless mode)", + ); + }, + SignedOut: () => { + throw new Error("@clerk/nextjs SignedOut rendered without ClerkProvider"); + }, + SignInButton: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SignOutButton: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + useAuth: () => ({ isLoaded: true, isSignedIn: false }), + useUser: () => ({ isLoaded: true, isSignedIn: false, user: null }), + }; +}); + +describe("/approvals auth boundary", () => { + it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; + + render( + + + + + , + ); + + expect(screen.getByText(/sign in to view approvals/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/approvals/page.tsx b/frontend/src/app/approvals/page.tsx new file mode 100644 index 0000000..060e8ca --- /dev/null +++ b/frontend/src/app/approvals/page.tsx @@ -0,0 +1,208 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useCallback, useMemo } from "react"; + +import { SignedIn, SignedOut, SignInButton, useAuth } from "@/auth/clerk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { ApiError } from "@/api/mutator"; +import { + listApprovalsApiV1BoardsBoardIdApprovalsGet, + updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, +} from "@/api/generated/approvals/approvals"; +import { useListBoardsApiV1BoardsGet } from "@/api/generated/boards/boards"; +import type { ApprovalRead, BoardRead } from "@/api/generated/model"; +import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; + +type GlobalApprovalsData = { + approvals: ApprovalRead[]; + warnings: string[]; +}; + +function GlobalApprovalsInner() { + const { isSignedIn } = useAuth(); + const queryClient = useQueryClient(); + + const boardsQuery = useListBoardsApiV1BoardsGet(undefined, { + query: { + enabled: Boolean(isSignedIn), + refetchInterval: 30_000, + refetchOnMount: "always", + retry: false, + }, + request: { cache: "no-store" }, + }); + + const boards = useMemo(() => { + if (boardsQuery.data?.status !== 200) return []; + return boardsQuery.data.data.items ?? []; + }, [boardsQuery.data]); + + const boardLabelById = useMemo(() => { + const entries = boards.map((board: BoardRead) => [board.id, board.name]); + return Object.fromEntries(entries) as Record; + }, [boards]); + + const boardIdsKey = useMemo(() => { + const ids = boards.map((board) => board.id); + ids.sort(); + return ids.join(","); + }, [boards]); + + const approvalsKey = useMemo( + () => ["approvals", "global", boardIdsKey] as const, + [boardIdsKey], + ); + + const approvalsQuery = useQuery({ + queryKey: approvalsKey, + enabled: Boolean(isSignedIn && boards.length > 0), + refetchInterval: 15_000, + refetchOnMount: "always", + retry: false, + queryFn: async () => { + const results = await Promise.allSettled( + boards.map(async (board) => { + const response = await listApprovalsApiV1BoardsBoardIdApprovalsGet( + board.id, + { limit: 200 }, + { cache: "no-store" }, + ); + if (response.status !== 200) { + throw new Error( + `Failed to load approvals for ${board.name} (status ${response.status}).`, + ); + } + return { boardId: board.id, approvals: response.data.items ?? [] }; + }), + ); + + const approvals: ApprovalRead[] = []; + const warnings: string[] = []; + + for (const result of results) { + if (result.status === "fulfilled") { + approvals.push(...result.value.approvals); + } else { + warnings.push(result.reason?.message ?? "Unable to load approvals."); + } + } + + return { approvals, warnings }; + }, + }); + + const updateApprovalMutation = useMutation< + Awaited< + ReturnType< + typeof updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch + > + >, + ApiError, + { boardId: string; approvalId: string; status: "approved" | "rejected" } + >({ + mutationFn: ({ boardId, approvalId, status }) => + updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch( + boardId, + approvalId, + { status }, + { cache: "no-store" }, + ), + }); + + const approvals = useMemo( + () => approvalsQuery.data?.approvals ?? [], + [approvalsQuery.data], + ); + const warnings = useMemo( + () => approvalsQuery.data?.warnings ?? [], + [approvalsQuery.data], + ); + const errorText = approvalsQuery.error?.message ?? null; + + const handleDecision = useCallback( + (approvalId: string, status: "approved" | "rejected") => { + const approval = approvals.find((item) => item.id === approvalId); + const boardId = approval?.board_id; + if (!boardId) return; + + updateApprovalMutation.mutate( + { boardId, approvalId, status }, + { + onSuccess: (result) => { + if (result.status !== 200) return; + queryClient.setQueryData( + approvalsKey, + (prev) => { + if (!prev) return prev; + return { + ...prev, + approvals: prev.approvals.map((item) => + item.id === approvalId ? result.data : item, + ), + }; + }, + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: approvalsKey }); + }, + }, + ); + }, + [approvals, approvalsKey, queryClient, updateApprovalMutation], + ); + + const combinedError = useMemo(() => { + const parts: string[] = []; + if (errorText) parts.push(errorText); + if (warnings.length > 0) parts.push(warnings.join(" ")); + return parts.length > 0 ? parts.join(" ") : null; + }, [errorText, warnings]); + + return ( +
+
+
+ +
+
+
+ ); +} + +export default function GlobalApprovalsPage() { + return ( + + +
+

Sign in to view approvals.

+ + + +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx index eff05c2..0b02b78 100644 --- a/frontend/src/components/BoardApprovalsPanel.tsx +++ b/frontend/src/components/BoardApprovalsPanel.tsx @@ -5,7 +5,7 @@ import { useCallback, useMemo, useState } from "react"; import { useAuth } from "@/auth/clerk"; import { useQueryClient } from "@tanstack/react-query"; -import { Clock } from "lucide-react"; +import { CheckCircle2, Clock } from "lucide-react"; import { Cell, Pie, PieChart } from "recharts"; import { ApiError } from "@/api/mutator"; @@ -39,6 +39,7 @@ type BoardApprovalsPanelProps = { error?: string | null; onDecision?: (approvalId: string, status: "approved" | "rejected") => void; scrollable?: boolean; + boardLabelById?: Record; }; const formatTimestamp = (value?: string | null) => { @@ -153,7 +154,7 @@ const payloadValue = (payload: Approval["payload"], key: string) => { return null; }; -const approvalSummary = (approval: Approval) => { +const approvalSummary = (approval: Approval, boardLabel?: string | null) => { const payload = approval.payload ?? {}; const taskId = approval.task_id ?? @@ -169,6 +170,7 @@ const approvalSummary = (approval: Approval) => { const role = payloadValue(payload, "role"); const isAssign = approval.action_type.includes("assign"); const rows: Array<{ label: string; value: string }> = []; + if (boardLabel) rows.push({ label: "Board", value: boardLabel }); if (taskId) rows.push({ label: "Task", value: taskId }); if (isAssign) { rows.push({ @@ -188,6 +190,7 @@ export function BoardApprovalsPanel({ error: externalError, onDecision, scrollable = false, + boardLabelById, }: BoardApprovalsPanelProps) { const { isSignedIn } = useAuth(); const queryClient = useQueryClient(); @@ -346,7 +349,25 @@ export function BoardApprovalsPanel({ {loadingState ? (

Loading approvals…

) : pendingCount === 0 && resolvedCount === 0 ? ( -

No approvals yet.

+
+
+
+ +
+

+ All clear +

+

+ No approvals to review right now. New approvals will show up here + as soon as they arrive. +

+
+
) : (
{orderedApprovals.map((approval) => { - const summary = approvalSummary(approval); + const summary = approvalSummary( + approval, + boardLabelById?.[approval.board_id] ?? null, + ); const isSelected = effectiveSelectedId === approval.id; const isPending = approval.status === "pending"; const titleRow = summary.rows.find( (row) => row.label.toLowerCase() === "title", ); const fallbackRow = summary.rows.find( - (row) => row.label.toLowerCase() !== "title", + (row) => + row.label.toLowerCase() !== "title" && + row.label.toLowerCase() !== "board", ); const primaryLabel = titleRow?.value ?? fallbackRow?.value ?? "Untitled"; + const boardRow = summary.rows.find( + (row) => row.label.toLowerCase() === "board", + ); + const boardText = + boardRow && boardRow.value !== primaryLabel + ? boardRow.value + : null; return (