From f9bd36a1a1c719adbc9ac76e7673dd090eb3edc6 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 31 Mar 2026 13:00:58 +0200 Subject: [PATCH 01/13] feat(risk-assessment): add assessment results view and query hooks Add query hooks for risk assessment API (fetch assessment, fetch results, create assessment, download report). Implement overall score card with submission date and action buttons, and criteria summary table with color-coded completeness and risk level labels. Integrate results view into the Product Risk Assessment tab, switching between wizard (in progress) and results (completed) based on assessment status. Implements JIRAPLAY-1380 Assisted-by: Claude Code --- .../components/assessment-results.tsx | 105 ++++++++++++++++++ .../components/criteria-summary-table.tsx | 68 ++++++++++++ .../product-risk-assessment.tsx | 42 ++++++- client/src/app/queries/risk-assessments.ts | 97 ++++++++++++++++ 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 client/src/app/pages/sbom-group-details/components/assessment-results.tsx create mode 100644 client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx create mode 100644 client/src/app/queries/risk-assessments.ts diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx new file mode 100644 index 000000000..c526216df --- /dev/null +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -0,0 +1,105 @@ +import type React from "react"; + +import { + Button, + Card, + CardBody, + CardTitle, + Content, + Flex, + FlexItem, + Spinner, + Stack, + StackItem, +} from "@patternfly/react-core"; + +import { StateError } from "@app/components/StateError"; +import { + useDownloadAssessment, + useFetchRiskAssessmentResults, +} from "@app/queries/risk-assessments"; + +import { CriteriaSummaryTable } from "./criteria-summary-table"; + +interface AssessmentResultsProps { + riskAssessmentId: string; + onStartNewAssessment: () => void; +} + +export const AssessmentResults: React.FC = ({ + riskAssessmentId, + onStartNewAssessment, +}) => { + const { results, isFetching, fetchError } = + useFetchRiskAssessmentResults(riskAssessmentId); + const { download } = useDownloadAssessment(riskAssessmentId); + + if (isFetching) { + return ; + } + + if (fetchError) { + return ; + } + + if (!results) { + return null; + } + + const submittedDate = new Date(results.submittedAt).toLocaleDateString(); + + return ( + + + + Overall Score + + + + + + {results.overallScore}% + + + + Submitted on {submittedDate} + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx new file mode 100644 index 000000000..7257053ac --- /dev/null +++ b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx @@ -0,0 +1,68 @@ +import type React from "react"; + +import { Label } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; + +import type { CriterionResult } from "@app/queries/risk-assessments"; + +interface CriteriaSummaryTableProps { + criteria: CriterionResult[]; +} + +const completenessColor = (value: CriterionResult["completeness"]) => { + switch (value) { + case "Complete": + return "green"; + case "Partial": + return "blue"; + case "Missing": + return "yellow"; + } +}; + +const riskLevelColor = (value: CriterionResult["riskLevel"]) => { + switch (value) { + case "Very high": + case "High": + return "red"; + case "Moderate": + return "orange"; + case "Low": + return "grey"; + } +}; + +export const CriteriaSummaryTable: React.FC = ({ + criteria, +}) => { + return ( + + + + + + + + + + + {criteria.map((item) => ( + + + + + + + ))} + +
CriterionCompletenessRisk LevelScore
{item.criterion} + + + + {item.score}
+ ); +}; diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index 01910b7e5..3813ed21e 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -1,7 +1,14 @@ import type React from "react"; -import { Content, Stack, StackItem } from "@patternfly/react-core"; +import { Content, Spinner, Stack, StackItem } from "@patternfly/react-core"; +import { StateError } from "@app/components/StateError"; +import { + useCreateRiskAssessmentMutation, + useFetchRiskAssessment, +} from "@app/queries/risk-assessments"; + +import { AssessmentResults } from "./components/assessment-results"; import { AssessmentWizard } from "./components/assessment-wizard"; interface ProductRiskAssessmentProps { @@ -11,6 +18,30 @@ interface ProductRiskAssessmentProps { export const ProductRiskAssessment: React.FC = ({ riskAssessmentId, }) => { + const { riskAssessment, isFetching, fetchError } = + useFetchRiskAssessment(riskAssessmentId); + + const createMutation = useCreateRiskAssessmentMutation( + () => { + window.location.reload(); + }, + () => {}, + ); + + const handleStartNewAssessment = () => { + createMutation.mutate(riskAssessmentId); + }; + + if (isFetching) { + return ; + } + + if (fetchError) { + return ; + } + + const isCompleted = riskAssessment?.status === "completed"; + return ( @@ -20,7 +51,14 @@ export const ProductRiskAssessment: React.FC = ({ - + {isCompleted ? ( + + ) : ( + + )} ); diff --git a/client/src/app/queries/risk-assessments.ts b/client/src/app/queries/risk-assessments.ts new file mode 100644 index 000000000..508c38164 --- /dev/null +++ b/client/src/app/queries/risk-assessments.ts @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import axios, { type AxiosError } from "axios"; + +const RISK_ASSESSMENTS = "/api/v2/risk-assessment"; + +export const RiskAssessmentsQueryKey = "risk-assessments"; + +export interface RiskAssessment { + id: string; + status: "in_progress" | "completed"; + submittedAt?: string; + overallScore?: number; +} + +export interface CriterionResult { + criterion: string; + completeness: "Complete" | "Partial" | "Missing"; + riskLevel: "Very high" | "High" | "Moderate" | "Low"; + score: number; +} + +export interface RiskAssessmentResults { + overallScore: number; + submittedAt: string; + criteria: CriterionResult[]; +} + +export const useFetchRiskAssessment = (id?: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: [RiskAssessmentsQueryKey, id], + queryFn: () => axios.get(`${RISK_ASSESSMENTS}/${id}`), + enabled: !!id, + }); + + return { + riskAssessment: data?.data, + isFetching: isLoading, + fetchError: error as AxiosError | null, + }; +}; + +export const useFetchRiskAssessmentResults = (id?: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: [RiskAssessmentsQueryKey, id, "results"], + queryFn: () => + axios.get(`${RISK_ASSESSMENTS}/${id}/results`), + enabled: !!id, + }); + + return { + results: data?.data, + isFetching: isLoading, + fetchError: error as AxiosError | null, + }; +}; + +export const useCreateRiskAssessmentMutation = ( + onSuccess: (response: RiskAssessment) => void, + onError: (err: AxiosError) => void, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (groupId: string) => { + const response = await axios.post(RISK_ASSESSMENTS, { + groupId, + }); + return response.data; + }, + onSuccess: async (response) => { + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); + onSuccess(response); + }, + onError: onError, + }); +}; + +export const useDownloadAssessment = (id?: string) => { + const download = async () => { + if (!id) return; + const response = await axios.get(`${RISK_ASSESSMENTS}/${id}/report`, { + responseType: "blob", + headers: { Accept: "application/pdf" }, + }); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `risk-assessment-${id}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }; + + return { download }; +}; From 810c40749a9c560e35f21a2b54258a189f105618 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 31 Mar 2026 15:09:41 +0200 Subject: [PATCH 02/13] fix(risk-assessment): replace window.location.reload with React Query invalidation Remove window.location.reload() from the mutation success callback in ProductRiskAssessment. The useCreateRiskAssessmentMutation hook already invalidates RiskAssessmentsQueryKey on success, so React Query will automatically re-fetch the assessment data without a full page reload. Implements JIRAPLAY-1394 Assisted-by: Claude Code --- .../app/pages/sbom-group-details/product-risk-assessment.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index 3813ed21e..111cdcf58 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -22,9 +22,7 @@ export const ProductRiskAssessment: React.FC = ({ useFetchRiskAssessment(riskAssessmentId); const createMutation = useCreateRiskAssessmentMutation( - () => { - window.location.reload(); - }, + () => {}, () => {}, ); From abab1dce3a067bf57da074ba2f66a2fdc2056156 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Thu, 2 Apr 2026 14:24:27 +0200 Subject: [PATCH 03/13] fix(risk-assessment): align query hooks and types with backend API Fix all risk assessment query hooks, TypeScript interfaces, and component logic to match the actual backend API: - Replace useFetchRiskAssessment with useFetchRiskAssessmentsByGroup calling GET /risk-assessment/group/{groupId} - Fix RiskAssessment, RiskAssessmentResults, CriterionResult types to match backend response shapes (nested categories, scoring object) - Add CategoryResult, CategoryScore, OverallScore, ScoringResult types - Rename ProductRiskAssessment prop from riskAssessmentId to groupId - Add group-to-assessment lookup flow: fetch by group, derive ID - Fix download endpoint to use /document/{category} path - Handle empty assessment state with StateNoData + create button Implements JIRAPLAY-1396 Assisted-by: Claude Code --- .../components/assessment-results.tsx | 50 +++++----- .../components/criteria-summary-table.tsx | 12 ++- .../product-risk-assessment.tsx | 50 ++++++++-- .../sbom-group-details/sbom-group-details.tsx | 2 +- client/src/app/queries/risk-assessments.ts | 95 +++++++++++++++---- 5 files changed, 152 insertions(+), 57 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx index c526216df..e901c695c 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -14,25 +14,23 @@ import { } from "@patternfly/react-core"; import { StateError } from "@app/components/StateError"; -import { - useDownloadAssessment, - useFetchRiskAssessmentResults, -} from "@app/queries/risk-assessments"; +import type { RiskAssessment } from "@app/queries/risk-assessments"; +import { useFetchRiskAssessmentResults } from "@app/queries/risk-assessments"; import { CriteriaSummaryTable } from "./criteria-summary-table"; interface AssessmentResultsProps { - riskAssessmentId: string; + assessment: RiskAssessment; onStartNewAssessment: () => void; } export const AssessmentResults: React.FC = ({ - riskAssessmentId, + assessment, onStartNewAssessment, }) => { - const { results, isFetching, fetchError } = - useFetchRiskAssessmentResults(riskAssessmentId); - const { download } = useDownloadAssessment(riskAssessmentId); + const { results, isFetching, fetchError } = useFetchRiskAssessmentResults( + assessment.id, + ); if (isFetching) { return ; @@ -46,7 +44,11 @@ export const AssessmentResults: React.FC = ({ return null; } - const submittedDate = new Date(results.submittedAt).toLocaleDateString(); + const scorePercent = results.scoring?.overall.score ?? results.overallScore; + const riskLevel = results.scoring?.overall.riskLevel; + const updatedDate = new Date(assessment.updatedAt).toLocaleDateString(); + + const allCriteria = results.categories.flatMap((cat) => cat.criteria); return ( @@ -68,12 +70,19 @@ export const AssessmentResults: React.FC = ({ fontWeight: "var(--pf-t--global--font--weight--bold)", }} > - {results.overallScore}% + {scorePercent != null + ? `${Math.round(scorePercent)}%` + : "—"} + {riskLevel && ( + + ({riskLevel}) + + )} - - Submitted on {submittedDate} - + Completed on {updatedDate} @@ -86,20 +95,17 @@ export const AssessmentResults: React.FC = ({ Start New Assessment - - - - - - + {allCriteria.length > 0 && ( + + + + )} ); }; diff --git a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx index 7257053ac..b7b2d108b 100644 --- a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx +++ b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx @@ -9,7 +9,8 @@ interface CriteriaSummaryTableProps { criteria: CriterionResult[]; } -const completenessColor = (value: CriterionResult["completeness"]) => { +/** Map a completeness string to a PatternFly Label color. */ +const completenessColor = (value: string) => { switch (value) { case "Complete": return "green"; @@ -17,10 +18,13 @@ const completenessColor = (value: CriterionResult["completeness"]) => { return "blue"; case "Missing": return "yellow"; + default: + return "grey"; } }; -const riskLevelColor = (value: CriterionResult["riskLevel"]) => { +/** Map a risk level string to a PatternFly Label color. */ +const riskLevelColor = (value: string) => { switch (value) { case "Very high": case "High": @@ -29,6 +33,8 @@ const riskLevelColor = (value: CriterionResult["riskLevel"]) => { return "orange"; case "Low": return "grey"; + default: + return "grey"; } }; @@ -47,7 +53,7 @@ export const CriteriaSummaryTable: React.FC = ({ {criteria.map((item) => ( - + {item.criterion} - + {item.score} diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index a387ce13e..b32384943 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -79,14 +79,17 @@ export const ProductRiskAssessment: React.FC = ({ - {isCompleted ? ( - - ) : ( - - )} + + ) : undefined + } + /> ); From 9663c09e796813bbbac21a776dc8063466f585fb Mon Sep 17 00:00:00 2001 From: mrizzi Date: Thu, 2 Apr 2026 19:39:12 +0200 Subject: [PATCH 05/13] fix(risk-assessment): preserve nav active state and button enabled state in results view Remove !!resultsContent guards from isActive and isDisabled props in the assessment wizard. The first nav item (SAR) is now visually highlighted when viewing results, and Back/Next buttons follow their normal disabled logic instead of being unconditionally disabled. Implements JIRAPLAY-1407 Assisted-by: Claude Code --- .../sbom-group-details/components/assessment-wizard.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index 001ea9f51..eac946b5c 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -76,7 +76,7 @@ export const AssessmentWizard: React.FC = ({ !resultsContent && setActiveStep(index)} icon={ completedSteps.has(index) ? ( @@ -117,7 +117,7 @@ export const AssessmentWizard: React.FC = ({ @@ -126,10 +126,7 @@ export const AssessmentWizard: React.FC = ({ From f26d59ddd2497161edaac05d8d69bb9dfabfd784 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 12:34:22 +0200 Subject: [PATCH 06/13] fix(risk-assessment): invalidate assessment query after document upload Call queryClient.invalidateQueries on RiskAssessmentsQueryKey after a successful document upload so the parent component re-fetches the assessment list and automatically switches from wizard to results view when the backend sets status to 'completed'. Implements JIRAPLAY-1409 Assisted-by: Claude Code --- .../sbom-group-details/components/assessment-wizard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index eac946b5c..51687c158 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import axios, { type AxiosRequestConfig } from "axios"; import { @@ -15,6 +16,7 @@ import { import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; import { FORM_DATA_FILE_KEY } from "@app/Constants"; +import { RiskAssessmentsQueryKey } from "@app/queries/risk-assessments"; import { ASSESSMENT_CATEGORIES, @@ -50,6 +52,7 @@ export const AssessmentWizard: React.FC = ({ riskAssessmentId, resultsContent, }) => { + const queryClient = useQueryClient(); const [activeStep, setActiveStep] = React.useState(0); const [completedSteps, setCompletedSteps] = React.useState>( new Set(), @@ -105,8 +108,11 @@ export const AssessmentWizard: React.FC = ({ config, ) } - onUploadSuccess={() => { + onUploadSuccess={async () => { setCompletedSteps((prev) => new Set(prev).add(activeStep)); + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); }} /> )} From 5c063a7be7346a99ae2da377a3095ef3b44a337b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 13:27:59 +0200 Subject: [PATCH 07/13] fix(risk-assessment): use circular step number indicators in wizard nav Replace plain text number prefix ('1. Category') with circular badge indicators matching the Figma mockup. Active step shows filled primary color circle with white text, inactive steps show outlined circles with neutral border. Completed steps retain the green CheckCircleIcon. Implements JIRAPLAY-1411 Assisted-by: Claude Code --- .../components/assessment-wizard.tsx | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index 51687c158..96d0a061e 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -75,21 +75,50 @@ export const AssessmentWizard: React.FC = ({ From 3d2b760bc518fff13b6ff2b8d3a5dd6de2bd56ea Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 15:09:28 +0200 Subject: [PATCH 08/13] fix(risk-assessment): select most recent assessment instead of oldest The backend returns assessments sorted by createdAt ascending (oldest first). Change from assessments[0] to assessments[assessments.length - 1] so the most recent assessment determines the view state (wizard vs results). Implements JIRAPLAY-1413 Assisted-by: Claude Code --- .../app/pages/sbom-group-details/product-risk-assessment.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index b32384943..ec2fdb6f0 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -45,7 +45,8 @@ export const ProductRiskAssessment: React.FC = ({ return ; } - const latestAssessment = assessments.length > 0 ? assessments[0] : undefined; + const latestAssessment = + assessments.length > 0 ? assessments[assessments.length - 1] : undefined; if (!latestAssessment) { return ( From c2cf0d55a9a275200a704b7073c5db35f9491531 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 15:59:05 +0200 Subject: [PATCH 09/13] refactor(risk-assessment): per-category content rendering in wizard Replace global resultsContent overlay with per-category rendering: - Each wizard step independently shows upload area or category results based on CategoryResult.processed from the API - Navigation (Next/Back/nav clicks) always works, switching between categories with different content per step - Derive completed steps from API data (processed boolean) merged with local upload tracking - Refactor AssessmentResults to AssessmentCategoryResults accepting per-category data via props instead of fetching globally - Add useDeleteRiskAssessmentMutation for Start New Assessment flow (delete old + create new instead of orphaning duplicates) - Show per-category score and criteria, not flattened across all Implements JIRAPLAY-1415 Assisted-by: Claude Code --- .../components/assessment-results.tsx | 65 +++++++++---------- .../components/assessment-wizard.tsx | 38 ++++++++--- .../product-risk-assessment.tsx | 38 ++++++----- client/src/app/queries/risk-assessments.ts | 20 ++++++ 4 files changed, 104 insertions(+), 57 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx index 674149418..c9bffab24 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -8,51 +8,51 @@ import { Content, Flex, FlexItem, - Spinner, Stack, StackItem, } from "@patternfly/react-core"; -import { StateError } from "@app/components/StateError"; -import type { RiskAssessment } from "@app/queries/risk-assessments"; -import { - useDownloadAssessmentDocument, - useFetchRiskAssessmentResults, +import type { + CategoryResult, + RiskAssessmentResults, } from "@app/queries/risk-assessments"; +import { useDownloadAssessmentDocument } from "@app/queries/risk-assessments"; +import type { AssessmentCategory } from "./assessment-category-step"; import { CriteriaSummaryTable } from "./criteria-summary-table"; -interface AssessmentResultsProps { - assessment: RiskAssessment; +interface AssessmentCategoryResultsProps { + /** The assessment ID for document download. */ + assessmentId: string; + /** The category definition (key, name, description). */ + category: AssessmentCategory; + /** The per-category result data including criteria. */ + categoryResult: CategoryResult; + /** The full results for scoring lookup. */ + overallResults: RiskAssessmentResults; onStartNewAssessment: () => void; } -/** Displays overall score card and criteria summary for a completed assessment. */ -export const AssessmentResults: React.FC = ({ - assessment, +/** Displays per-category score card and criteria summary for a processed category. */ +export const AssessmentCategoryResults: React.FC< + AssessmentCategoryResultsProps +> = ({ + assessmentId, + category, + categoryResult, + overallResults, onStartNewAssessment, }) => { - const { results, isFetching, fetchError } = useFetchRiskAssessmentResults( - assessment.id, + const { download } = useDownloadAssessmentDocument( + assessmentId, + category.key, ); - const { download } = useDownloadAssessmentDocument(assessment.id, "sar"); - - if (isFetching) { - return ; - } - - if (fetchError) { - return ; - } - if (!results) { - return null; - } - - const scorePercent = results.scoring?.overall.score ?? results.overallScore; - const riskLevel = results.scoring?.overall.riskLevel; - const updatedDate = new Date(assessment.updatedAt).toLocaleDateString(); - const allCriteria = results.categories.flatMap((cat) => cat.criteria); + const categoryScore = overallResults.scoring?.categories.find( + (c) => c.category === category.key, + ); + const scorePercent = categoryScore?.score ?? overallResults.overallScore; + const riskLevel = categoryScore?.riskLevel; return ( @@ -83,7 +83,6 @@ export const AssessmentResults: React.FC = ({ )} - Completed on {updatedDate} @@ -103,12 +102,12 @@ export const AssessmentResults: React.FC = ({ - {allCriteria.length > 0 && ( + {categoryResult.criteria.length > 0 && ( Criteria Summary - + diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index 96d0a061e..cf408d1ae 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -16,12 +16,14 @@ import { import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; import { FORM_DATA_FILE_KEY } from "@app/Constants"; +import type { RiskAssessmentResults } from "@app/queries/risk-assessments"; import { RiskAssessmentsQueryKey } from "@app/queries/risk-assessments"; import { ASSESSMENT_CATEGORIES, AssessmentCategoryStep, } from "./assessment-category-step"; +import { AssessmentCategoryResults } from "./assessment-results"; const RISK_ASSESSMENTS = "/api/v2/risk-assessment"; @@ -44,21 +46,30 @@ const uploadRiskAssessmentDocument = ( interface AssessmentWizardProps { riskAssessmentId: string; - /** When provided, renders this content in the right panel instead of the upload step. */ - resultsContent?: React.ReactNode; + /** Per-category results data from the API. */ + results?: RiskAssessmentResults; + /** Callback to delete the current assessment and create a fresh one. */ + onStartNewAssessment: () => void; } export const AssessmentWizard: React.FC = ({ riskAssessmentId, - resultsContent, + results, + onStartNewAssessment, }) => { const queryClient = useQueryClient(); const [activeStep, setActiveStep] = React.useState(0); - const [completedSteps, setCompletedSteps] = React.useState>( + const [uploadedSteps, setUploadedSteps] = React.useState>( new Set(), ); + const categoryResultMap = new Map( + results?.categories.map((cat) => [cat.category, cat]) ?? [], + ); + const currentCategory = ASSESSMENT_CATEGORIES[activeStep]; + const currentCategoryResult = categoryResultMap.get(currentCategory.key); + const isCategoryProcessed = currentCategoryResult?.processed ?? false; const handleBack = () => { setActiveStep((prev) => Math.max(0, prev - 1)); @@ -77,7 +88,10 @@ export const AssessmentWizard: React.FC = ({ {ASSESSMENT_CATEGORIES.map((category, index) => { const isActive = activeStep === index; - const isCompleted = completedSteps.has(index); + const isProcessed = + categoryResultMap.get(category.key)?.processed ?? false; + const isUploaded = uploadedSteps.has(index); + const isCompleted = isProcessed || isUploaded; const stepIcon = isCompleted ? ( @@ -112,7 +126,7 @@ export const AssessmentWizard: React.FC = ({ key={category.key} itemId={index} isActive={isActive} - onClick={() => !resultsContent && setActiveStep(index)} + onClick={() => setActiveStep(index)} icon={stepIcon} > {category.name} @@ -125,7 +139,15 @@ export const AssessmentWizard: React.FC = ({ - {resultsContent ?? ( + {isCategoryProcessed && currentCategoryResult ? ( + + ) : ( = ({ ) } onUploadSuccess={async () => { - setCompletedSteps((prev) => new Set(prev).add(activeStep)); + setUploadedSteps((prev) => new Set(prev).add(activeStep)); await queryClient.invalidateQueries({ queryKey: [RiskAssessmentsQueryKey], }); diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index ec2fdb6f0..f97bcb0e9 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -12,10 +12,11 @@ import { StateError } from "@app/components/StateError"; import { StateNoData } from "@app/components/StateNoData"; import { useCreateRiskAssessmentMutation, + useDeleteRiskAssessmentMutation, + useFetchRiskAssessmentResults, useFetchRiskAssessmentsByGroup, } from "@app/queries/risk-assessments"; -import { AssessmentResults } from "./components/assessment-results"; import { AssessmentWizard } from "./components/assessment-wizard"; interface ProductRiskAssessmentProps { @@ -28,12 +29,28 @@ export const ProductRiskAssessment: React.FC = ({ const { assessments, isFetching, fetchError } = useFetchRiskAssessmentsByGroup(groupId); + const latestAssessment = + assessments.length > 0 ? assessments[assessments.length - 1] : undefined; + + const { results } = useFetchRiskAssessmentResults(latestAssessment?.id); + + const deleteMutation = useDeleteRiskAssessmentMutation( + () => {}, + () => {}, + ); const createMutation = useCreateRiskAssessmentMutation( () => {}, () => {}, ); - const handleStartNewAssessment = () => { + const handleStartNewAssessment = async () => { + if (latestAssessment) { + await deleteMutation.mutateAsync(latestAssessment.id); + } + createMutation.mutate(groupId); + }; + + const handleCreateAssessment = () => { createMutation.mutate(groupId); }; @@ -45,9 +62,6 @@ export const ProductRiskAssessment: React.FC = ({ return ; } - const latestAssessment = - assessments.length > 0 ? assessments[assessments.length - 1] : undefined; - if (!latestAssessment) { return ( @@ -61,7 +75,7 @@ export const ProductRiskAssessment: React.FC = ({ - @@ -69,8 +83,6 @@ export const ProductRiskAssessment: React.FC = ({ ); } - const isCompleted = latestAssessment.status === "completed"; - return ( @@ -82,14 +94,8 @@ export const ProductRiskAssessment: React.FC = ({ - ) : undefined - } + results={results} + onStartNewAssessment={handleStartNewAssessment} /> diff --git a/client/src/app/queries/risk-assessments.ts b/client/src/app/queries/risk-assessments.ts index 302dd3fed..6cd88009b 100644 --- a/client/src/app/queries/risk-assessments.ts +++ b/client/src/app/queries/risk-assessments.ts @@ -119,6 +119,26 @@ export const useCreateRiskAssessmentMutation = ( }); }; +/** Delete a risk assessment by ID. */ +export const useDeleteRiskAssessmentMutation = ( + onSuccess: () => void, + onError: (err: AxiosError) => void, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + await axios.delete(`${RISK_ASSESSMENTS}/${id}`); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); + onSuccess(); + }, + onError: onError, + }); +}; + /** Download a risk assessment document for a specific category. */ export const useDownloadAssessmentDocument = ( assessmentId?: string, From ec19d800cc951899ec7fb72ecbf37522fe728ce3 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 16:06:06 +0200 Subject: [PATCH 10/13] fix(risk-assessment): use explicit white color for active step number The PatternFly token --pf-t--global--color--nonstatus--white--default does not resolve to white in PF6. Use explicit #fff for the active step number text color. Implements JIRAPLAY-1415 Assisted-by: Claude Code --- .../pages/sbom-group-details/components/assessment-wizard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index cf408d1ae..a58cf58dd 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -110,7 +110,7 @@ export const AssessmentWizard: React.FC = ({ ? "var(--pf-t--global--color--brand--default)" : "transparent", color: isActive - ? "var(--pf-t--global--color--nonstatus--white--default)" + ? "#fff" : "var(--pf-t--global--color--200)", border: isActive ? "none" From 1c6172e8dcac89f58a1a73aebdef96c30b254a58 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Apr 2026 16:55:09 +0200 Subject: [PATCH 11/13] fix(risk-assessment): format criterion keys as readable labels Add formatCriterionLabel() to convert snake_case backend keys (e.g., threat_identification) into readable labels (Threat identification). Applied to criterion names, completeness values, and risk level text. Falls back gracefully for unknown keys. Implements JIRAPLAY-1418 Assisted-by: Claude Code --- .../components/criteria-summary-table.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx index b1b382686..47eeccd31 100644 --- a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx +++ b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx @@ -12,6 +12,11 @@ interface CriteriaSummaryTableProps { criteria: CriterionResult[]; } +/** Format a snake_case criterion key into a readable label. */ +const formatCriterionLabel = (key: string) => { + return key.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); +}; + /** Map a completeness string to a PatternFly Label color. */ const completenessColor = (value: string) => { switch (value) { @@ -52,7 +57,7 @@ const RiskLevelDisplay: React.FC<{ value: string }> = ({ value }) => { flexWrap={{ default: "nowrap" }} > {icon} - {value} + {formatCriterionLabel(value)} ); }; @@ -73,10 +78,12 @@ export const CriteriaSummaryTable: React.FC = ({ {criteria.map((item) => ( - {item.criterion} + + {formatCriterionLabel(item.criterion)} + From 2a6436e60dd2dd147b13270c8c9cc69413b3c542 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 19:02:43 +0200 Subject: [PATCH 12/13] fix(risk-assessment): use report endpoint for Download Assessment button Replace useDownloadAssessmentDocument (which downloads the raw uploaded PDF via /document/{category}) with useDownloadAssessmentReport (which calls GET /risk-assessment/{id}/report to download the generated PDF report). Remove the category parameter since the report covers all categories. Implements JIRAPLAY-1426 Assisted-by: Claude Code --- .../components/assessment-results.tsx | 7 ++----- client/src/app/queries/risk-assessments.ts | 18 ++++++------------ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx index c9bffab24..7a85673b1 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -16,7 +16,7 @@ import type { CategoryResult, RiskAssessmentResults, } from "@app/queries/risk-assessments"; -import { useDownloadAssessmentDocument } from "@app/queries/risk-assessments"; +import { useDownloadAssessmentReport } from "@app/queries/risk-assessments"; import type { AssessmentCategory } from "./assessment-category-step"; import { CriteriaSummaryTable } from "./criteria-summary-table"; @@ -43,10 +43,7 @@ export const AssessmentCategoryResults: React.FC< overallResults, onStartNewAssessment, }) => { - const { download } = useDownloadAssessmentDocument( - assessmentId, - category.key, - ); + const { download } = useDownloadAssessmentReport(assessmentId); const categoryScore = overallResults.scoring?.categories.find( (c) => c.category === category.key, diff --git a/client/src/app/queries/risk-assessments.ts b/client/src/app/queries/risk-assessments.ts index 6cd88009b..40c415860 100644 --- a/client/src/app/queries/risk-assessments.ts +++ b/client/src/app/queries/risk-assessments.ts @@ -139,27 +139,21 @@ export const useDeleteRiskAssessmentMutation = ( }); }; -/** Download a risk assessment document for a specific category. */ -export const useDownloadAssessmentDocument = ( - assessmentId?: string, - category?: string, -) => { +/** Download the generated PDF report for a risk assessment. */ +export const useDownloadAssessmentReport = (assessmentId?: string) => { const download = async () => { - if (!assessmentId || !category) return; + if (!assessmentId) return; const response = await axios.get( - `${RISK_ASSESSMENTS}/${assessmentId}/document/${category}`, + `${RISK_ASSESSMENTS}/${assessmentId}/report`, { responseType: "blob", - headers: { Accept: "application/octet-stream" }, + headers: { Accept: "application/pdf" }, }, ); const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); link.href = url; - link.setAttribute( - "download", - `risk-assessment-${assessmentId}-${category}`, - ); + link.setAttribute("download", `risk-assessment-${assessmentId}.pdf`); document.body.appendChild(link); link.click(); link.remove(); From 41739959b97c16c8156457b9135cec6bfb889c8b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Thu, 9 Apr 2026 12:30:53 +0200 Subject: [PATCH 13/13] fix(risk-assessment): display overall completeness percentage instead of per-category LLM score Use overallResults.overallScore (0-100 completeness percentage from backend) for the Overall Score card instead of categoryScore.score (0-10 per-category LLM criterion average). Also use the overall risk level instead of per-category risk level for consistency. Implements JIRAPLAY-1435 Assisted-by: Claude Code --- .../sbom-group-details/components/assessment-results.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx index 7a85673b1..59afb49d6 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -38,18 +38,15 @@ export const AssessmentCategoryResults: React.FC< AssessmentCategoryResultsProps > = ({ assessmentId, - category, + category: _category, categoryResult, overallResults, onStartNewAssessment, }) => { const { download } = useDownloadAssessmentReport(assessmentId); - const categoryScore = overallResults.scoring?.categories.find( - (c) => c.category === category.key, - ); - const scorePercent = categoryScore?.score ?? overallResults.overallScore; - const riskLevel = categoryScore?.riskLevel; + const scorePercent = overallResults.overallScore; + const riskLevel = overallResults.scoring?.overall.riskLevel; return (