Add finding review states, ECO export, and a report release signature.
Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
|
||||
interface ComponentGroupProps {
|
||||
@@ -26,9 +26,11 @@ interface ComponentGroupProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: ComponentGroupProps) {
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const sorted = sortFindings(findings);
|
||||
|
||||
@@ -82,6 +84,8 @@ export function ComponentGroup({ designator, findings, component, onViewReferenc
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
|
||||
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { FindingComments } from "./finding-comments";
|
||||
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
import { FindingReviewControls } from "./finding-review-controls";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BORDER_COLOR: Record<string, string> = {
|
||||
@@ -31,6 +32,8 @@ interface FindingCardProps {
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
isReported?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
review?: FindingReview;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingCard({
|
||||
@@ -48,11 +51,16 @@ export function FindingCard({
|
||||
onReportFinding,
|
||||
isReported,
|
||||
defaultOpen,
|
||||
review,
|
||||
onReviewSaved,
|
||||
}: FindingCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
const commentCount = comments?.length ?? 0;
|
||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||
const expandable = !!finding.recommendation || (hasCommentSupport && !!finding.finding_id);
|
||||
const expandable =
|
||||
!!finding.recommendation ||
|
||||
(hasCommentSupport && !!finding.finding_id) ||
|
||||
!!(projectId && finding.finding_id && onReviewSaved);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -189,6 +197,15 @@ export function FindingCard({
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
)}
|
||||
{projectId && finding.finding_id && onReviewSaved && (
|
||||
<FindingReviewControls
|
||||
projectId={projectId}
|
||||
findingId={finding.finding_id}
|
||||
review={review}
|
||||
userName={currentUserName ?? "User"}
|
||||
onSaved={onReviewSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PdfViewerPanel } from "@/components/pdf/pdf-viewer-panel";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
|
||||
import { subtypeLabel } from "@/lib/utils";
|
||||
|
||||
interface FindingFocusViewProps {
|
||||
@@ -29,6 +29,8 @@ interface FindingFocusViewProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
isReported?: boolean;
|
||||
review?: FindingReview;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingFocusView({
|
||||
@@ -51,6 +53,8 @@ export function FindingFocusView({
|
||||
onCommentDeleted,
|
||||
onReportFinding,
|
||||
isReported,
|
||||
review,
|
||||
onReviewSaved,
|
||||
}: FindingFocusViewProps) {
|
||||
const backRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -110,6 +114,8 @@ export function FindingFocusView({
|
||||
onReportFinding={onReportFinding}
|
||||
isReported={isReported}
|
||||
defaultOpen
|
||||
review={review}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-auto lg:shrink-0 lg:sticky lg:top-6 h-[70vh] lg:h-[calc(100vh-3rem)] rounded-lg border border-border bg-card overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { setFindingReview } from "@/lib/api";
|
||||
import type { FindingReview, FindingReviewState } from "@/lib/types";
|
||||
|
||||
const STATES: { value: FindingReviewState; label: string }[] = [
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "accepted", label: "Accepted (ECO)" },
|
||||
{ value: "false_positive", label: "False positive" },
|
||||
{ value: "wontfix", label: "Won't fix" },
|
||||
];
|
||||
|
||||
export function FindingReviewControls({
|
||||
projectId,
|
||||
findingId,
|
||||
review,
|
||||
userName,
|
||||
onSaved,
|
||||
}: {
|
||||
projectId: string;
|
||||
findingId: string;
|
||||
review?: FindingReview;
|
||||
userName: string;
|
||||
onSaved: (findingId: string, review: FindingReview) => void;
|
||||
}) {
|
||||
const [state, setState] = useState<FindingReviewState>(review?.state ?? "open");
|
||||
const [reason, setReason] = useState(review?.reason ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await setFindingReview(projectId, findingId, state, reason, userName);
|
||||
onSaved(findingId, saved.state === "open" ? { ...saved, state: "open", reason: "" } : saved);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-xs"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value as FindingReviewState)}
|
||||
>
|
||||
{STATES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
className="h-8 min-w-[160px] flex-1 text-xs"
|
||||
placeholder={state === "open" ? "Reason optional when open" : "Reason (required)"}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={save} disabled={busy}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useMemo } from "react";
|
||||
import { ComponentGroup } from "./component-group";
|
||||
import { ReportFilters } from "./report-filters";
|
||||
import { ReviewedSection } from "./reviewed-section";
|
||||
import type { Finding, FindingComment, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
|
||||
interface FindingsListProps {
|
||||
@@ -23,9 +23,11 @@ interface FindingsListProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: FindingsListProps) {
|
||||
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: FindingsListProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -38,6 +40,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
|
||||
const statusParam = searchParams.get("status");
|
||||
const componentParam = searchParams.get("component");
|
||||
const reviewParam = searchParams.get("review");
|
||||
const searchParam = searchParams.get("q") ?? "";
|
||||
|
||||
const statusFilters = useMemo(() => {
|
||||
@@ -77,9 +80,13 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
const q = searchParam.toLowerCase();
|
||||
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q))
|
||||
return false;
|
||||
if (reviewParam === "open") {
|
||||
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
||||
if (st && st !== "open") return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[statusFilters, componentParam, searchParam]
|
||||
[statusFilters, componentParam, searchParam, reviewParam, reviews]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -120,6 +127,10 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
|
||||
search={searchParam}
|
||||
onSearchChange={(v) => updateParams({ q: v || null })}
|
||||
needsReview={reviewParam === "open"}
|
||||
onToggleNeedsReview={() =>
|
||||
updateParams({ review: reviewParam === "open" ? null : "open" })
|
||||
}
|
||||
designators={designators}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
@@ -144,6 +155,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
|
||||
|
||||
@@ -21,6 +21,8 @@ interface ReportFiltersProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
designators: string[];
|
||||
needsReview?: boolean;
|
||||
onToggleNeedsReview?: () => void;
|
||||
}
|
||||
|
||||
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
|
||||
@@ -37,6 +39,8 @@ export function ReportFilters({
|
||||
search,
|
||||
onSearchChange,
|
||||
designators,
|
||||
needsReview,
|
||||
onToggleNeedsReview,
|
||||
}: ReportFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -56,6 +60,19 @@ export function ReportFilters({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{onToggleNeedsReview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
|
||||
)}
|
||||
onClick={onToggleNeedsReview}
|
||||
>
|
||||
Needs review
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
|
||||
Reference in New Issue
Block a user