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 { useAuth } from "@/auth/clerk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { Clock } from "lucide-react";
|
import { CheckCircle2, Clock } from "lucide-react";
|
||||||
import { Cell, Pie, PieChart } from "recharts";
|
import { Cell, Pie, PieChart } from "recharts";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
@@ -39,6 +39,7 @@ type BoardApprovalsPanelProps = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
|
boardLabelById?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimestamp = (value?: string | null) => {
|
const formatTimestamp = (value?: string | null) => {
|
||||||
@@ -153,7 +154,7 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const approvalSummary = (approval: Approval) => {
|
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
||||||
const payload = approval.payload ?? {};
|
const payload = approval.payload ?? {};
|
||||||
const taskId =
|
const taskId =
|
||||||
approval.task_id ??
|
approval.task_id ??
|
||||||
@@ -169,6 +170,7 @@ const approvalSummary = (approval: Approval) => {
|
|||||||
const role = payloadValue(payload, "role");
|
const role = payloadValue(payload, "role");
|
||||||
const isAssign = approval.action_type.includes("assign");
|
const isAssign = approval.action_type.includes("assign");
|
||||||
const rows: Array<{ label: string; value: string }> = [];
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
|
if (boardLabel) rows.push({ label: "Board", value: boardLabel });
|
||||||
if (taskId) rows.push({ label: "Task", value: taskId });
|
if (taskId) rows.push({ label: "Task", value: taskId });
|
||||||
if (isAssign) {
|
if (isAssign) {
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -188,6 +190,7 @@ export function BoardApprovalsPanel({
|
|||||||
error: externalError,
|
error: externalError,
|
||||||
onDecision,
|
onDecision,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
|
boardLabelById,
|
||||||
}: BoardApprovalsPanelProps) {
|
}: BoardApprovalsPanelProps) {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -346,7 +349,25 @@ export function BoardApprovalsPanel({
|
|||||||
{loadingState ? (
|
{loadingState ? (
|
||||||
<p className="text-sm text-slate-500">Loading approvals…</p>
|
<p className="text-sm text-slate-500">Loading approvals…</p>
|
||||||
) : pendingCount === 0 && resolvedCount === 0 ? (
|
) : 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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -375,17 +396,29 @@ export function BoardApprovalsPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{orderedApprovals.map((approval) => {
|
{orderedApprovals.map((approval) => {
|
||||||
const summary = approvalSummary(approval);
|
const summary = approvalSummary(
|
||||||
|
approval,
|
||||||
|
boardLabelById?.[approval.board_id] ?? null,
|
||||||
|
);
|
||||||
const isSelected = effectiveSelectedId === approval.id;
|
const isSelected = effectiveSelectedId === approval.id;
|
||||||
const isPending = approval.status === "pending";
|
const isPending = approval.status === "pending";
|
||||||
const titleRow = summary.rows.find(
|
const titleRow = summary.rows.find(
|
||||||
(row) => row.label.toLowerCase() === "title",
|
(row) => row.label.toLowerCase() === "title",
|
||||||
);
|
);
|
||||||
const fallbackRow = summary.rows.find(
|
const fallbackRow = summary.rows.find(
|
||||||
(row) => row.label.toLowerCase() !== "title",
|
(row) =>
|
||||||
|
row.label.toLowerCase() !== "title" &&
|
||||||
|
row.label.toLowerCase() !== "board",
|
||||||
);
|
);
|
||||||
const primaryLabel =
|
const primaryLabel =
|
||||||
titleRow?.value ?? fallbackRow?.value ?? "Untitled";
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={approval.id}
|
key={approval.id}
|
||||||
@@ -413,6 +446,11 @@ export function BoardApprovalsPanel({
|
|||||||
<p className="mt-2 text-sm font-semibold text-slate-900">
|
<p className="mt-2 text-sm font-semibold text-slate-900">
|
||||||
{primaryLabel}
|
{primaryLabel}
|
||||||
</p>
|
</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">
|
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
|
||||||
<Clock className="h-3.5 w-3.5 opacity-60" />
|
<Clock className="h-3.5 w-3.5 opacity-60" />
|
||||||
<span>{formatTimestamp(approval.created_at)}</span>
|
<span>{formatTimestamp(approval.created_at)}</span>
|
||||||
@@ -442,7 +480,10 @@ export function BoardApprovalsPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
const summary = approvalSummary(selectedApproval);
|
const summary = approvalSummary(
|
||||||
|
selectedApproval,
|
||||||
|
boardLabelById?.[selectedApproval.board_id] ?? null,
|
||||||
|
);
|
||||||
const titleRow = summary.rows.find(
|
const titleRow = summary.rows.find(
|
||||||
(row) => row.label.toLowerCase() === "title",
|
(row) => row.label.toLowerCase() === "title",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Bot,
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
Folder,
|
Folder,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
@@ -102,6 +103,18 @@ export function DashboardSidebar() {
|
|||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Boards
|
Boards
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/activity"
|
href="/activity"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user