diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 5cd82e4e5..8bacd38a0 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -91,6 +91,8 @@ "noDataStateBody": "Create a new {{what}} to start seeing data here.", "noDataStateTitle": "No {{what}} available", "Nquestions": "{{n}} questions", + "ofTotalApplications": "Of {{count}} application", + "ofTotalApplications_plural": "Of {{count}} applications", "ofTotalAssessments": "Of {{count}} assessment", "ofTotalAssessments_plural": "Of {{count}} assessments", "selectMany": "Select {{what}}", diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 2f2c07102..aaf47dc38 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -72,6 +72,9 @@ export interface Ref { id: number; name: string; } +export interface IdRef { + id: number; +} export interface JobFunction { id: number; @@ -773,3 +776,8 @@ export interface SectionWithQuestionOrder extends Section { export interface AssessmentWithSectionOrder extends Assessment { sections: SectionWithQuestionOrder[]; } + +export interface AssessmentWithArchetypeApplications + extends AssessmentWithSectionOrder { + archetypeApplications: Ref[]; +} diff --git a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx index f79e677df..627faa55f 100644 --- a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx +++ b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -138,8 +138,6 @@ export const MultiselectFilterControl = ({ const input = textInput?.toLowerCase(); return renderSelectOptions((optionProps, groupName) => { - if (!input) return false; - // TODO: Checking for a filter match against the key or the value may not be desirable. return ( groupName?.toLowerCase().includes(input) || diff --git a/client/src/app/components/answer-table/answer-table.tsx b/client/src/app/components/answer-table/answer-table.tsx index 0ca99f038..4a6d903ec 100644 --- a/client/src/app/components/answer-table/answer-table.tsx +++ b/client/src/app/components/answer-table/answer-table.tsx @@ -16,6 +16,7 @@ import { IconedStatus } from "@app/components/IconedStatus"; import { TimesCircleIcon } from "@patternfly/react-icons"; import { WarningTriangleIcon } from "@patternfly/react-icons"; import { List, ListItem } from "@patternfly/react-core"; +import RiskIcon from "../risk-icon/risk-icon"; export interface IAnswerTableProps { answers: Answer[]; @@ -45,19 +46,6 @@ const AnswerTable: React.FC = ({ propHelpers: { tableProps, getThProps, getTrProps, getTdProps }, } = tableControls; - const getIconByRisk = (risk: string): React.ReactElement => { - switch (risk) { - case "green": - return ; - case "red": - return } status="danger" />; - case "yellow": - return } status="warning" />; - default: - return ; - } - }; - return ( <> @@ -126,7 +114,7 @@ const AnswerTable: React.FC = ({ {answer.text} diff --git a/client/src/app/components/risk-icon/risk-icon.tsx b/client/src/app/components/risk-icon/risk-icon.tsx new file mode 100644 index 000000000..d4a7ddf52 --- /dev/null +++ b/client/src/app/components/risk-icon/risk-icon.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { TimesCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons"; +import { IconedStatus } from "@app/components/IconedStatus"; + +interface RiskIconProps { + risk: string; +} + +const RiskIcon: React.FC = ({ risk }) => { + switch (risk) { + case "green": + return ; + case "red": + return } status="danger" />; + case "yellow": + return } status="warning" />; + default: + return ; + } +}; + +export default RiskIcon; diff --git a/client/src/app/hooks/table-controls/filtering/useFilterState.ts b/client/src/app/hooks/table-controls/filtering/useFilterState.ts index 1b2b267d6..890f9dd68 100644 --- a/client/src/app/hooks/table-controls/filtering/useFilterState.ts +++ b/client/src/app/hooks/table-controls/filtering/useFilterState.ts @@ -44,6 +44,7 @@ export type IFilterStateArgs< * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) */ filterCategories: FilterCategory[]; + initialFilterValues?: IFilterValues; } >; @@ -63,6 +64,10 @@ export const useFilterState = < ): IFilterState => { const { isFilterEnabled, persistTo = "state", persistenceKeyPrefix } = args; + const initialFilterValues: IFilterValues = isFilterEnabled + ? args?.initialFilterValues ?? {} + : {}; + // We won't need to pass the latter two type params here if TS adds support for partial inference. // See https://github.com/konveyor/tackle2-ui/issues/1456 const [filterValues, setFilterValues] = usePersistentState< @@ -71,7 +76,7 @@ export const useFilterState = < "filters" >({ isEnabled: !!isFilterEnabled, - defaultValue: {}, + defaultValue: initialFilterValues, persistenceKeyPrefix, // Note: For the discriminated union here to work without TypeScript getting confused // (e.g. require the urlParams-specific options when persistTo === "urlParams"), diff --git a/client/src/app/pages/applications/applications-table/applications-table.tsx b/client/src/app/pages/applications/applications-table/applications-table.tsx index 94dd73eaf..92147bc65 100644 --- a/client/src/app/pages/applications/applications-table/applications-table.tsx +++ b/client/src/app/pages/applications/applications-table/applications-table.tsx @@ -68,7 +68,10 @@ import WarningTriangleIcon from "@patternfly/react-icons/dist/esm/icons/warning- // Hooks import { useQueryClient } from "@tanstack/react-query"; -import { useLocalTableControls } from "@app/hooks/table-controls"; +import { + deserializeFilterUrlParams, + useLocalTableControls, +} from "@app/hooks/table-controls"; // Queries import { Application, Assessment, Ref, Task } from "@app/api/models"; @@ -303,6 +306,11 @@ export const ApplicationsTable: React.FC = () => { } }; + const urlParams = new URLSearchParams(window.location.search); + const filters = urlParams.get("filters"); + + const deserializedFilterValues = deserializeFilterUrlParams({ filters }); + const tableControls = useLocalTableControls({ idProperty: "id", items: applications || [], @@ -321,6 +329,7 @@ export const ApplicationsTable: React.FC = () => { isActiveItemEnabled: true, sortableColumns: ["name", "businessService", "tags", "effort"], initialSort: { columnKey: "name", direction: "asc" }, + initialFilterValues: deserializedFilterValues, getSortValues: (app) => ({ name: app.name, businessService: app.businessService?.name || "", @@ -331,12 +340,17 @@ export const ApplicationsTable: React.FC = () => { { key: "name", title: t("terms.name"), - type: FilterType.search, + type: FilterType.multiselect, placeholderText: t("actions.filterBy", { what: t("terms.name").toLowerCase(), }) + "...", getItemValue: (item) => item?.name || "", + selectOptions: [ + ...new Set( + applications.map((application) => application.name).filter(Boolean) + ), + ].map((name) => ({ key: name, value: name })), }, { key: "archetypes", @@ -468,6 +482,22 @@ export const ApplicationsTable: React.FC = () => { return matchString; }, }, + { + key: "risk", + title: t("terms.risk"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.risk").toLowerCase(), + }) + "...", + selectOptions: [ + { key: "green", value: "Low" }, + { key: "yellow", value: "Medium" }, + { key: "red", value: "High" }, + { key: "unknown", value: "Unknown" }, + ], + getItemValue: (item) => item.risk || "", + }, ], initialItemsPerPage: 10, hasActionsColumn: true, @@ -690,7 +720,9 @@ export const ApplicationsTable: React.FC = () => { return ( } >
{ + let low = 0; + let medium = 0; + let high = 0; + let unknown = 0; + const processedAppIds = new Set(); // Set to track processed application IDs + + const findFullApplication = (ref: Ref) => { + return applications.find((app) => app.id === ref.id); + }; + + assessments?.forEach((assessment) => { + const combinedApplications = [ + ...(assessment.application ? [assessment.application] : []), + ...(assessment.archetypeApplications ?? []), + ]; + + const uniqueApplications = combinedApplications.reduce( + (acc: Ref[], current) => { + if (!acc.find((item) => item?.id === current.id)) { + acc.push(current); + } + return acc; + }, + [] + ); + + uniqueApplications.forEach((appRef) => { + const fullApp = findFullApplication(appRef); + if (fullApp && fullApp.risk && !processedAppIds.has(fullApp.id)) { + processedAppIds.add(fullApp.id); + + switch (fullApp.risk) { + case "green": + low++; + break; + case "yellow": + medium++; + break; + case "red": + high++; + break; + case "unknown": + unknown++; + break; + } + } + }); + }); + const unassessed = applications.length - processedAppIds.size; + + return { + green: low, + yellow: medium, + red: high, + unknown, + unassessed, + applicationsCount: processedAppIds.size, + }; +}; + +interface IApplicationLandscapeProps { + /** + * The selected questionnaire or `null` if _all questionnaires_ is selected. + */ + questionnaire: Questionnaire | null; + + /** + * The set of assessments for the selected questionnaire. Risk values will be + * aggregated from the individual assessment risks. + */ + assessmentRefs?: IdRef[]; +} + +export const ApplicationLandscape: React.FC = ({ + questionnaire, + assessmentRefs, +}) => { + const { t } = useTranslation(); + + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); + const { data: applications } = useFetchApplications(); + + const filteredAssessments = assessmentsWithArchetypeApplications.filter( + (assessment) => assessmentRefs?.some((ref) => ref.id === assessment.id) + ); + + const landscapeData = useMemo( + () => aggregateRiskData(filteredAssessments, applications), + [filteredAssessments, applications] + ); + + return ( + + +
+ } + > + {landscapeData && ( + + + {t("terms.highRisk")} + } + riskDescription={questionnaire?.riskMessages?.red ?? ""} + /> + + + + {t("terms.mediumRisk")} + + } + riskDescription={questionnaire?.riskMessages?.yellow ?? ""} + /> + + + {t("terms.lowRisk")} + } + riskDescription={questionnaire?.riskMessages?.green ?? ""} + /> + + + + {`${t("terms.unassessed")}/${t("terms.unknown")}`} + + } + /> + + + )} +
+ ); +}; + +const getRisksUrl = (risks: string[]) => { + const filterValues = { + risk: risks, + }; + + const serializedParams = serializeFilterUrlParams(filterValues); + + const queryString = serializedParams.filters + ? `filters=${serializedParams.filters}` + : ""; + return `${Paths.applications}?${queryString}`; +}; diff --git a/client/src/app/pages/reports/components/application-landscape/index.ts b/client/src/app/pages/reports/components/application-landscape/index.ts new file mode 100644 index 000000000..c94255222 --- /dev/null +++ b/client/src/app/pages/reports/components/application-landscape/index.ts @@ -0,0 +1 @@ +export { ApplicationLandscape } from "./application-landscape"; diff --git a/client/src/app/pages/reports/components/landscape/landscape.tsx b/client/src/app/pages/reports/components/assessment-landscape/assessment-landscape.tsx similarity index 79% rename from client/src/app/pages/reports/components/landscape/landscape.tsx rename to client/src/app/pages/reports/components/assessment-landscape/assessment-landscape.tsx index 6a9fec9b3..72458c62c 100644 --- a/client/src/app/pages/reports/components/landscape/landscape.tsx +++ b/client/src/app/pages/reports/components/assessment-landscape/assessment-landscape.tsx @@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next"; import { Flex, FlexItem, Skeleton } from "@patternfly/react-core"; import { RISK_LIST } from "@app/Constants"; -import { Assessment, Questionnaire } from "@app/api/models"; +import { Assessment, IdRef, Questionnaire } from "@app/api/models"; import { ConditionalRender } from "@app/components/ConditionalRender"; -import { Donut } from "./donut"; +import { Donut } from "../donut/donut"; +import { useFetchAssessmentsWithArchetypeApplications } from "@app/queries/assessments"; interface IAggregateRiskData { green: number; @@ -49,7 +50,7 @@ const aggregateRiskData = (assessments: Assessment[]): IAggregateRiskData => { }; }; -interface ILandscapeProps { +interface IAssessmentLandscapeProps { /** * The selected questionnaire or `null` if _all questionnaires_ is selected. */ @@ -59,23 +60,30 @@ interface ILandscapeProps { * The set of assessments for the selected questionnaire. Risk values will be * aggregated from the individual assessment risks. */ - assessments: Assessment[]; + assessmentRefs?: IdRef[]; } -export const Landscape: React.FC = ({ +export const AssessmentLandscape: React.FC = ({ questionnaire, - assessments, + assessmentRefs, }) => { const { t } = useTranslation(); + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); + + const filteredAssessments = assessmentsWithArchetypeApplications.filter( + (assessment) => assessmentRefs?.some((ref) => ref.id === assessment.id) + ); + const landscapeData = useMemo( - () => aggregateRiskData(assessments), - [assessments] + () => aggregateRiskData(filteredAssessments), + [filteredAssessments] ); return ( @@ -90,6 +98,7 @@ export const Landscape: React.FC = ({ > = ({ = ({ = ({ = ({ @@ -27,7 +28,7 @@ export const Donut: React.FC = ({ total, color, riskLabel, - riskDescription, + isAssessment, }) => { const { t } = useTranslation(); @@ -38,9 +39,15 @@ export const Donut: React.FC = ({ = ({ {riskLabel} - {riskDescription} diff --git a/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx b/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx index 74507382f..78a425379 100644 --- a/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx +++ b/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx @@ -1,46 +1,100 @@ import React from "react"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { useFetchAssessments } from "@app/queries/assessments"; +import { useFetchAssessmentsWithArchetypeApplications } from "@app/queries/assessments"; import { useTranslation } from "react-i18next"; -import { Ref } from "@app/api/models"; +import { + Answer, + AssessmentWithArchetypeApplications, + IdRef, + Question, + Ref, +} from "@app/api/models"; import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import { TableHeaderContentWithControls, ConditionalTableBody, TableRowContentWithControls, } from "@app/components/TableControls"; -import { useLocalTableControls } from "@app/hooks/table-controls"; +import { + serializeFilterUrlParams, + useLocalTableControls, +} from "@app/hooks/table-controls"; import { SimplePagination } from "@app/components/SimplePagination"; -import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { + TextContent, + Toolbar, + ToolbarContent, + ToolbarItem, + Text, + Divider, +} from "@patternfly/react-core"; +import { Link } from "react-router-dom"; +import { Paths } from "@app/Paths"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import RiskIcon from "@app/components/risk-icon/risk-icon"; -export interface IIdentifiedRisksTableProps {} +export interface IIdentifiedRisksTableProps { + assessmentRefs?: IdRef[]; +} -export const IdentifiedRisksTable: React.FC< - IIdentifiedRisksTableProps -> = () => { +export const IdentifiedRisksTable: React.FC = ({ + assessmentRefs, +}) => { const { t } = useTranslation(); - const { assessments } = useFetchAssessments(); + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); interface ITableRowData { assessmentName: string; questionId: string; section: string; - question: string; - answer: string; + question: Question; + answer: Answer; applications: Ref[]; } const tableData: ITableRowData[] = []; - // ... - assessments.forEach((assessment) => { + const filterAssessmentsByRefs = ( + assessments: AssessmentWithArchetypeApplications[], + refs: IdRef[] + ) => { + if (refs && refs.length > 0) { + return assessments.filter((assessment) => + refs.some((ref) => ref.id === assessment.id) + ); + } + return assessments; + }; + + const filteredAssessments = filterAssessmentsByRefs( + assessmentsWithArchetypeApplications, + assessmentRefs || [] + ); + + filteredAssessments.forEach((assessment) => { + const combinedApplications = [ + ...(assessment.application ? [assessment.application] : []), + ...(assessment.archetypeApplications ?? []), + ]; + + const uniqueApplications = combinedApplications.reduce( + (acc: Ref[], current) => { + if (!acc.find((item) => item?.id === current.id)) { + acc.push(current); + } + return acc; + }, + [] + ); + assessment.sections.forEach((section) => { section.questions.forEach((question) => { question.answers.forEach((answer) => { if (answer.selected) { const itemId = [ - assessment.id, + assessment.questionnaire.id, section.order, question.order, answer.order, @@ -52,22 +106,21 @@ export const IdentifiedRisksTable: React.FC< if (existingItemIndex !== -1) { const existingItem = tableData[existingItemIndex]; - if ( - assessment.application && - !existingItem.applications - .map((app) => app.name) - .includes(assessment.application.name) - ) { - existingItem.applications.push(assessment.application); - } + uniqueApplications.forEach((application) => { + if ( + !existingItem.applications.some( + (app) => app.id === application.id + ) + ) { + existingItem.applications.push(application); + } + }); } else { tableData.push({ section: section.name, - question: question.text, - answer: answer.text, - applications: assessment.application - ? [assessment.application] - : [], + question: question, + answer: answer, + applications: uniqueApplications ? uniqueApplications : [], assessmentName: assessment.questionnaire.name, questionId: itemId, }); @@ -86,6 +139,7 @@ export const IdentifiedRisksTable: React.FC< section: "Section", question: "Question", answer: "Answer", + risk: "Risk", applications: "Applications", }, variant: "compact", @@ -95,11 +149,13 @@ export const IdentifiedRisksTable: React.FC< getSortValues: (item) => ({ assessmentName: item.assessmentName, section: item.section, - question: item.question, - answer: item.answer, + question: item.question.text, + answer: item.answer.text, applications: item.applications.length, }), sortableColumns: ["assessmentName", "section", "question", "answer"], + isExpansionEnabled: true, + expandableVariant: "single", }); const { @@ -113,7 +169,9 @@ export const IdentifiedRisksTable: React.FC< getThProps, getTrProps, getTdProps, + getExpandedContentTdProps, }, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( @@ -143,6 +201,7 @@ export const IdentifiedRisksTable: React.FC<
+ @@ -150,7 +209,7 @@ export const IdentifiedRisksTable: React.FC< @@ -161,34 +220,88 @@ export const IdentifiedRisksTable: React.FC< {currentPageItems?.map((item, rowIndex) => { return ( - - - - - - - - - + <> + + + + + + + + + + + {isCellExpanded(item) ? ( + + + + ) : null} + ); })}
- {getIconByRisk(answer.risk)} +
Section Question AnswerRisk Application
- {item.assessmentName} - - {item?.section ?? "N/A"} - - {item?.question ?? "N/A"} - - {item.answer ?? "N/A"} - - {item?.applications.length ?? "N/A"} -
+ {item.assessmentName} + + {item?.section ?? "N/A"} + + {item?.question.text ?? "N/A"} + + {item.answer.text ?? "N/A"} + + + + {item?.applications.length ? ( + + {t("composed.totalApplications", { + count: item.applications.length, + })} + + ) : ( + "N/A" + )} +
+ + + Rationale + + {item?.answer?.rationale + ? item.answer.rationale + : "N/A"} + + + + Mitigation + + {item?.answer?.mitigation + ? item.answer.mitigation + : "N/A"} + + +
+ ); }; + +const getApplicationsUrl = (applications: Ref[]) => { + const filterValues = { + name: applications.map((app) => app.name), + }; + + const serializedParams = serializeFilterUrlParams(filterValues); + + const queryString = serializedParams.filters + ? `filters=${serializedParams.filters}` + : ""; + return `${Paths.applications}?${queryString}`; +}; diff --git a/client/src/app/pages/reports/components/landscape/index.ts b/client/src/app/pages/reports/components/landscape/index.ts deleted file mode 100644 index b3d59d110..000000000 --- a/client/src/app/pages/reports/components/landscape/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Landscape } from "./landscape"; diff --git a/client/src/app/pages/reports/reports.tsx b/client/src/app/pages/reports/reports.tsx index 201b3bcad..7ea098017 100644 --- a/client/src/app/pages/reports/reports.tsx +++ b/client/src/app/pages/reports/reports.tsx @@ -1,18 +1,16 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Bullseye, - Button, - ButtonVariant, Card, CardBody, - CardExpandableContent, CardHeader, CardTitle, + Flex, + FlexItem, MenuToggle, PageSection, PageSectionVariants, - Popover, Select, SelectOption, Split, @@ -22,7 +20,6 @@ import { Text, TextContent, } from "@patternfly/react-core"; -import { HelpIcon } from "@patternfly/react-icons"; import { Questionnaire } from "@app/api/models"; import { useFetchApplications } from "@app/queries/applications"; @@ -34,10 +31,9 @@ import { ConditionalRender } from "@app/components/ConditionalRender"; import { StateError } from "@app/components/StateError"; import { ApplicationSelectionContextProvider } from "./application-selection-context"; -import { Landscape } from "./components/landscape"; -import AdoptionCandidateTable from "./components/adoption-candidate-table/adoption-candidate-table"; -import { AdoptionPlan } from "./components/adoption-plan"; import { IdentifiedRisksTable } from "./components/identified-risks-table"; +import { toIdRef } from "@app/utils/model-utils"; +import { ApplicationLandscape } from "./components/application-landscape"; const ALL_QUESTIONNAIRES = -1; @@ -77,10 +73,6 @@ export const Reports: React.FC = () => { const [selectedQuestionnaireId, setSelectedQuestionnaireId] = React.useState(ALL_QUESTIONNAIRES); - const [isAdoptionPlanOpen, setAdoptionPlanOpen] = useState(false); - - const [isRiskCardOpen, setIsRiskCardOpen] = useState(false); - const pageHeaderSection = ( @@ -115,12 +107,32 @@ export const Reports: React.FC = () => { const answeredQuestionnaires: Questionnaire[] = isAssessmentsFetching || isQuestionnairesFetching ? [] - : assessments - .map((assessment) => assessment?.questionnaire?.id) - .filter((id) => id > 0) + : Array.from( + new Set( + assessments + .map((assessment) => assessment?.questionnaire?.id) + .filter((id) => id > 0) + ) + ) .map((id) => questionnairesById[id]) - .sort((a, b) => a.name.localeCompare(b.name)) - .filter((questionnaire) => questionnaire !== undefined); + .filter((questionnaire) => questionnaire !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)); + + const isAllQuestionnairesSelected = + selectedQuestionnaireId === ALL_QUESTIONNAIRES; + + const questionnaire = isAllQuestionnairesSelected + ? null + : questionnairesById[selectedQuestionnaireId]; + + const assessmentRefs = assessments + .filter( + (assessment) => + isAllQuestionnairesSelected || + assessment.questionnaire.id === selectedQuestionnaireId + ) + .map((assessment) => toIdRef(assessment)) + .filter(Boolean); return ( <> @@ -140,139 +152,74 @@ export const Reports: React.FC = () => { - - setIsQuestionnaireSelectOpen(false) - } - toggle={(toggleRef) => ( - { - setIsQuestionnaireSelectOpen( - !isQuestionnaireSelectOpen - ); - }} - isExpanded={isQuestionnaireSelectOpen} - > - {selectedQuestionnaireId === ALL_QUESTIONNAIRES - ? "All questionnaires" - : questionnairesById[selectedQuestionnaireId] - ?.name} - - )} - shouldFocusToggleOnSelect - > - - All questionnaires - - {...answeredQuestionnaires.map( - (answeredQuestionnaire) => ( - - {answeredQuestionnaire.name} - - ) - )} - - ), - }} - > + - {t("terms.currentLandscape")} + + + + {t("terms.currentLandscape")} + + + + + + - - questionnaire.id === selectedQuestionnaireId - ) - } + - + - - - - {t("terms.adoptionCandidateDistribution")} - - - - - - - - - - - - setAdoptionPlanOpen((current) => !current)} - > - - - - {t("terms.suggestedAdoptionPlan")} - - {t("message.suggestedAdoptionPlanHelpText")} - - } - position="right" - > - - - - - - - - - {isAdoptionPlanOpen && } - - - - - - - setIsRiskCardOpen((current) => !current)} - > @@ -287,11 +234,9 @@ export const Reports: React.FC = () => { - - - {isRiskCardOpen && } - - + + + diff --git a/client/src/app/pages/review/components/application-details/application-details.tsx b/client/src/app/pages/review/components/application-details/application-details.tsx deleted file mode 100644 index bab21fcfb..000000000 --- a/client/src/app/pages/review/components/application-details/application-details.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { - DescriptionList, - DescriptionListDescription, - DescriptionListGroup, - DescriptionListTerm, - List, -} from "@patternfly/react-core"; - -import { Application, Assessment } from "@app/api/models"; -import { useFetchQuestionnaires } from "@app/queries/questionnaires"; - -export interface IApplicationDetailsProps { - application?: Application; - assessment?: Assessment; -} - -export const ApplicationDetails: React.FC = ({ - application, - assessment, -}) => { - const { questionnaires } = useFetchQuestionnaires(); - - const matchingQuestionnaire = questionnaires.find( - (questionnaire) => questionnaire.id === assessment?.questionnaire?.id - ); - const { t } = useTranslation(); - if (!matchingQuestionnaire || !application) { - return null; - } - - return ( - - - {t("terms.applicationName")} - - {application.name} - - - - {t("terms.description")} - - {application.description} - - - - {t("terms.assessmentNotes")} - - - {/* {matchingQuestionnaire.sections - .filter((f) => f.comment && f.comment.trim().length > 0) - .map((category, i) => ( - - {category.title}: {category.comment} - - ))} */} - - - - - ); -}; diff --git a/client/src/app/pages/review/components/application-details/index.ts b/client/src/app/pages/review/components/application-details/index.ts deleted file mode 100644 index 7ecfc28a6..000000000 --- a/client/src/app/pages/review/components/application-details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ApplicationDetails } from "./application-details"; diff --git a/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap b/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap deleted file mode 100644 index 0c82534d4..000000000 --- a/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap +++ /dev/null @@ -1,252 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AppTable Renders without crashing 1`] = ` -{ - "asFragment": [Function], - "baseElement": -
-
-
-
- - terms.applicationName - -
-
-
- myApp -
-
-
-
-
- - terms.description - -
-
-
- myDescription -
-
-
-
-
- - terms.assessmentNotes - -
-
-
-
    -
  • - title1 - : - comments1 -
  • -
  • - title2 - : - comments2 -
  • -
  • - title3 - : - comments3 -
  • -
-
-
-
-
-
- , - "container":
-
-
-
- - terms.applicationName - -
-
-
- myApp -
-
-
-
-
- - terms.description - -
-
-
- myDescription -
-
-
-
-
- - terms.assessmentNotes - -
-
-
-
    -
  • - title1 - : - comments1 -
  • -
  • - title2 - : - comments2 -
  • -
  • - title3 - : - comments3 -
  • -
-
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} -`; diff --git a/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx b/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx deleted file mode 100644 index 31443d7f1..000000000 --- a/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Application } from "@app/api/models"; - -describe("AppTable", () => { - it.skip("Renders without crashing", () => { - const application: Application = { - id: 1, - name: "myApp", - description: "myDescription", - migrationWave: null, - }; - - // const assessment: Assessment = { - // applicationId: 1, - // status: "COMPLETE", - // questionnaire: { - // categories: [ - // { - // id: 1, - // order: 1, - // questions: [], - // title: "title1", - // comment: "comments1", - // }, - // { - // id: 2, - // order: 2, - // questions: [], - // title: "title2", - // comment: "comments2", - // }, - // { - // id: 3, - // order: 3, - // questions: [], - // title: "title3", - // comment: "comments3", - // }, - // ], - // }, - // }; - // const wrapper = render( - // - // ); - // expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/client/src/app/pages/review/review-page.tsx b/client/src/app/pages/review/review-page.tsx index 9a0814801..0acfe9f2d 100644 --- a/client/src/app/pages/review/review-page.tsx +++ b/client/src/app/pages/review/review-page.tsx @@ -3,11 +3,15 @@ import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Bullseye, + Card, + CardBody, + CardHeader, FormSection, Grid, GridItem, PageSection, Text, + TextContent, } from "@patternfly/react-core"; import BanIcon from "@patternfly/react-icons/dist/esm/icons/ban-icon"; @@ -16,15 +20,13 @@ import { ReviewForm } from "./components/review-form"; import { SimpleEmptyState } from "@app/components/SimpleEmptyState"; import { ConditionalRender } from "@app/components/ConditionalRender"; import { AppPlaceholder } from "@app/components/AppPlaceholder"; -import { ApplicationAssessmentDonutChart } from "./components/application-assessment-donut-chart/application-assessment-donut-chart"; -import QuestionnaireSummary, { - SummaryType, -} from "@app/components/questionnaire-summary/questionnaire-summary"; import { PageHeader } from "@app/components/PageHeader"; import { useFetchReviewById } from "@app/queries/reviews"; import useIsArchetype from "@app/hooks/useIsArchetype"; import { useFetchApplicationById } from "@app/queries/applications"; import { useFetchArchetypeById } from "@app/queries/archetypes"; +import { IdentifiedRisksTable } from "../reports/components/identified-risks-table"; +import { AssessmentLandscape } from "../reports/components/assessment-landscape"; const ReviewPage: React.FC = () => { const { t } = useTranslation(); @@ -38,7 +40,6 @@ const ReviewPage: React.FC = () => { const { review, fetchError, isFetching } = useFetchReviewById( isArchetype ? archetype?.review?.id : application?.review?.id ); - const assessment = undefined; const breadcrumbs = [ ...(isArchetype ? [ @@ -53,10 +54,10 @@ const ReviewPage: React.FC = () => { path: Paths.applications, }, ]), - // { - // title: t("terms.review"), - // path: Paths.applicationsReview, - // }, + { + title: t("terms.review"), + path: Paths.applicationsReview, + }, ]; if (fetchError) { @@ -94,40 +95,50 @@ const ReviewPage: React.FC = () => { breadcrumbs={breadcrumbs} />
- - }> - - -
- {/* - - */} - - - -
-
- {assessment && ( - - - - )} -
-
- {assessment && ( - - )} + + + + }> + + +
+ + + + +
+
+
+
+
+
+ {(application?.assessments?.length || archetype?.assessments?.length) && ( + + + + + {t("terms.assessmentSummary")} + + + + + + + )} ); }; diff --git a/client/src/app/queries/assessments.ts b/client/src/app/queries/assessments.ts index d805b47ca..caffb76c3 100644 --- a/client/src/app/queries/assessments.ts +++ b/client/src/app/queries/assessments.ts @@ -1,9 +1,15 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { createAssessment, deleteAssessment, + getArchetypeById, getAssessmentById, getAssessments, getAssessmentsByItemId, @@ -12,6 +18,7 @@ import { import { AxiosError } from "axios"; import { Assessment, + AssessmentWithArchetypeApplications, AssessmentWithSectionOrder, InitialAssessment, } from "@app/api/models"; @@ -210,3 +217,49 @@ const removeSectionOrderFromQuestions = ( })), }; }; + +export const useFetchAssessmentsWithArchetypeApplications = () => { + const { assessments, isFetching: assessmentsLoading } = useFetchAssessments(); + + const archetypesUsedInAnAssessmentQueries = useQueries({ + queries: + [ + ...new Set( + assessments + .map((assessment) => assessment?.archetype?.id) + .filter(Boolean) + ), + ].map((archetypeId) => ({ + queryKey: ["archetype", archetypeId], + queryFn: () => + archetypeId ? getArchetypeById(archetypeId) : undefined, + enabled: !!archetypeId, + })) || [], + }); + + const isArchetypesLoading = archetypesUsedInAnAssessmentQueries.some( + (query) => query.isLoading + ); + + const archetypeApplicationsMap = new Map(); + archetypesUsedInAnAssessmentQueries.forEach((query, index) => { + if (query.data && assessments[index].archetype?.id) { + archetypeApplicationsMap.set( + assessments[index]?.archetype?.id, + query.data.applications + ); + } + }); + + const assessmentsWithArchetypeApplications: AssessmentWithArchetypeApplications[] = + assessments.map((assessment) => ({ + ...assessment, + archetypeApplications: + archetypeApplicationsMap.get(assessment?.archetype?.id) ?? [], + })); + + return { + assessmentsWithArchetypeApplications, + isLoading: assessmentsLoading || isArchetypesLoading, + }; +}; diff --git a/client/src/app/queries/risks.ts b/client/src/app/queries/risks.ts deleted file mode 100644 index cc5b8bc9e..000000000 --- a/client/src/app/queries/risks.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { AssessmentRisk } from "@app/api/models"; - -export const RisksQueryKey = "risks"; - -/** @deprecated Risk is attached to assessments now. */ -export const useFetchRisks = (applicationIDs: number[]) => { - const { data, refetch, isFetching, error } = useQuery({ - queryKey: ["assessmentrisks", applicationIDs], - queryFn: async () => { - if (applicationIDs.length > 0) - // return (await getAssessmentLandscape(applicationIDs)).data; - //TODO see if we still need this - return []; - else return []; - }, - onError: (error) => console.log("error, ", error), - }); - - return { - risks: data || [], - isFetching, - error, - refetch, - }; -}; diff --git a/client/src/app/utils/model-utils.tsx b/client/src/app/utils/model-utils.tsx index 017e5878c..1c89324b7 100644 --- a/client/src/app/utils/model-utils.tsx +++ b/client/src/app/utils/model-utils.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Application, BusinessService, + IdRef, Identity, IdentityKind, IssueManagerKind, @@ -213,6 +214,14 @@ export const IssueManagerOptions: OptionWithValue[] = [ }, ]; +export const toIdRef = ( + source: RefLike | undefined +): IdRef | undefined => { + if (!source || !source.id) return undefined; + + return { id: source.id }; +}; + /** * Convert any object that looks like a `Ref` into a `Ref`. If the source object * is `undefined`, or doesn't look like a `Ref`, return `undefined`.