feat: enhance approvals panel with board labels and improved empty state display

This commit is contained in:
Abhimanyu Saharan
2026-02-08 01:39:13 +05:30
parent e612b6e41c
commit 8422b0ca01
4 changed files with 330 additions and 6 deletions

View 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();
});
});

View 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>
);
}

View File

@@ -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",
);

View File

@@ -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(