feat: enhance approvals panel with board labels and improved empty state display
This commit is contained in:
62
frontend/src/app/approvals/page.test.tsx
Normal file
62
frontend/src/app/approvals/page.test.tsx
Normal file
@@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
|
||||
|
||||
return {
|
||||
default: ({ href, children, ...props }: LinkProps) => (
|
||||
<a href={typeof href === "string" ? href : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<GlobalApprovalsPage />
|
||||
</QueryProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/sign in to view approvals/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
208
frontend/src/app/approvals/page.tsx
Normal file
208
frontend/src/app/approvals/page.tsx
Normal file
@@ -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<string, string>;
|
||||
}, [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<GlobalApprovalsData, ApiError>({
|
||||
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<GlobalApprovalsData>(
|
||||
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 (
|
||||
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="p-6">
|
||||
<div className="h-[calc(100vh-160px)] min-h-[520px]">
|
||||
<BoardApprovalsPanel
|
||||
boardId="global"
|
||||
approvals={approvals}
|
||||
isLoading={boardsQuery.isLoading || approvalsQuery.isLoading}
|
||||
error={combinedError}
|
||||
onDecision={handleDecision}
|
||||
scrollable
|
||||
boardLabelById={boardLabelById}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GlobalApprovalsPage() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
|
||||
<p className="text-sm text-muted">Sign in to view approvals.</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl="/approvals"
|
||||
signUpForceRedirectUrl="/approvals"
|
||||
>
|
||||
<Button>Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<GlobalApprovalsInner />
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<p className="text-sm text-slate-500">Loading approvals…</p>
|
||||
) : pendingCount === 0 && resolvedCount === 0 ? (
|
||||
<p className="text-sm text-slate-500">No approvals yet.</p>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-dashed border-slate-200 bg-white px-6 py-10 text-center",
|
||||
scrollable && "flex h-full items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<div className="max-w-sm">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-50 text-emerald-600">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold text-slate-900">
|
||||
All clear
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
No approvals to review right now. New approvals will show up here
|
||||
as soon as they arrive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -375,17 +396,29 @@ export function BoardApprovalsPanel({
|
||||
)}
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={approval.id}
|
||||
@@ -413,6 +446,11 @@ export function BoardApprovalsPanel({
|
||||
<p className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{primaryLabel}
|
||||
</p>
|
||||
{boardText ? (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Board · {boardText}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Clock className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>{formatTimestamp(approval.created_at)}</span>
|
||||
@@ -442,7 +480,10 @@ export function BoardApprovalsPanel({
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const summary = approvalSummary(selectedApproval);
|
||||
const summary = approvalSummary(
|
||||
selectedApproval,
|
||||
boardLabelById?.[selectedApproval.board_id] ?? null,
|
||||
);
|
||||
const titleRow = summary.rows.find(
|
||||
(row) => row.label.toLowerCase() === "title",
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
@@ -102,6 +103,18 @@ export function DashboardSidebar() {
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Boards
|
||||
</Link>
|
||||
<Link
|
||||
href="/approvals"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/approvals")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Approvals
|
||||
</Link>
|
||||
<Link
|
||||
href="/activity"
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user