From 08bf65d91907fccf714882901b5b1706088466b2 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 22 May 2025 15:30:02 +0200 Subject: [PATCH] Add risk charts Signed-off-by: Bryan Frimin Signed-off-by: Sacha Al Himdani --- .../organizations/risks/FormRiskDialog.tsx | 21 +- .../pages/organizations/risks/RisksPage.tsx | 14 ++ packages/helpers/src/array.ts | 20 ++ packages/helpers/src/index.ts | 3 + packages/helpers/src/number.ts | 3 + packages/helpers/src/risk.ts | 51 ++++ packages/ui/src/Atoms/Button/Button.tsx | 10 +- packages/ui/src/Atoms/Dropdown/Dropdown.tsx | 12 +- packages/ui/src/Atoms/Table/Table.tsx | 6 +- packages/ui/src/Layouts/Layout.tsx | 8 +- .../RisksChart/RisksChart.stories.tsx | 31 +++ .../src/Molecules/RisksChart/RisksChart.tsx | 229 ++++++++++++++++++ packages/ui/src/index.ts | 1 + 13 files changed, 386 insertions(+), 23 deletions(-) create mode 100644 packages/helpers/src/array.ts create mode 100644 packages/helpers/src/number.ts create mode 100644 packages/helpers/src/risk.ts create mode 100644 packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx create mode 100644 packages/ui/src/Molecules/RisksChart/RisksChart.tsx diff --git a/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx b/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx index 131d60472..88ee01afb 100644 --- a/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx +++ b/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx @@ -29,6 +29,7 @@ import type { FieldErrors } from "react-hook-form"; import { useMutationWithToasts } from "../../../hooks/useMutationWithToasts"; import type { FormRiskDialogMutation } from "./__generated__/FormRiskDialogMutation.graphql"; import type { FormRiskDialogUpdateRiskMutation } from "./__generated__/FormRiskDialogUpdateRiskMutation.graphql"; +import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers"; type Props = { trigger?: ReactNode; @@ -276,11 +277,11 @@ function ImpactAndLikelihood({ placeholder={__("Select impact level")} error={errors?.[`${prefix}Impact`]?.message} > - - - - - + {getRiskImpacts(__).map((i) => ( + + ))} - - - - - + {getRiskLikelihoods(__).map((l) => ( + + ))} diff --git a/apps/console2/src/pages/organizations/risks/RisksPage.tsx b/apps/console2/src/pages/organizations/risks/RisksPage.tsx index 6b68241d6..fe7d1f777 100644 --- a/apps/console2/src/pages/organizations/risks/RisksPage.tsx +++ b/apps/console2/src/pages/organizations/risks/RisksPage.tsx @@ -14,6 +14,7 @@ import { IconTrashCan, IconPencil, ConfirmDialog, + RisksChart, } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; import { IconPlusLarge } from "@probo/ui"; @@ -127,6 +128,19 @@ export default function RisksPage() { onSuccess={() => setEditedRisk(null)} /> )} + +
+ + +
diff --git a/packages/helpers/src/array.ts b/packages/helpers/src/array.ts new file mode 100644 index 000000000..f253db08a --- /dev/null +++ b/packages/helpers/src/array.ts @@ -0,0 +1,20 @@ +export function times(n: number, cb: (i: number) => T): T[] { + return Array.from({ length: n }, (_, i) => cb(i)); +} + +export function groupBy( + arr: T[], + key: (item: T) => string, +): Record { + return arr.reduce( + (acc, item) => { + const k = key(item); + if (!acc[k]) { + acc[k] = []; + } + acc[k].push(item); + return acc; + }, + {} as Record, + ); +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 73bf5db5b..cb8880e62 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -1,2 +1,5 @@ export { objectKeys } from "./object"; export { sprintf } from "./string"; +export { getRiskImpacts, getRiskLikelihoods } from "./risk"; +export { times, groupBy } from "./array"; +export { randomInt } from "./number"; diff --git a/packages/helpers/src/number.ts b/packages/helpers/src/number.ts new file mode 100644 index 000000000..759194bfc --- /dev/null +++ b/packages/helpers/src/number.ts @@ -0,0 +1,3 @@ +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/packages/helpers/src/risk.ts b/packages/helpers/src/risk.ts new file mode 100644 index 000000000..e44428593 --- /dev/null +++ b/packages/helpers/src/risk.ts @@ -0,0 +1,51 @@ +type Translator = (s: string) => string; + +export function getRiskImpacts(__: Translator) { + return [ + { + value: 1, + label: __("Negligible"), + }, + { + value: 2, + label: __("Low"), + }, + { + value: 3, + label: __("Moderate"), + }, + { + value: 4, + label: __("Significant"), + }, + { + value: 5, + label: __("Catastrophic"), + }, + ]; +} + +export function getRiskLikelihoods(__: Translator) { + return [ + { + value: 1, + label: __("Improbable"), + }, + { + value: 2, + label: __("Remote"), + }, + { + value: 3, + label: __("Occasional"), + }, + { + value: 4, + label: __("Probable"), + }, + { + value: 5, + label: __("Frequent"), + }, + ]; +} diff --git a/packages/ui/src/Atoms/Button/Button.tsx b/packages/ui/src/Atoms/Button/Button.tsx index 79f13400c..aade1b371 100644 --- a/packages/ui/src/Atoms/Button/Button.tsx +++ b/packages/ui/src/Atoms/Button/Button.tsx @@ -37,8 +37,8 @@ const button = tv({ type Props = PropsWithChildren< { - icon?: FC<{ size: number }>; - iconAfter?: FC<{ size: number }>; + icon?: FC<{ size: number; className?: string }>; + iconAfter?: FC<{ size: number; className?: string }>; disabled?: boolean; onClick?: () => void; variant?: @@ -71,9 +71,11 @@ export const Button = (props: Props) => { onClick={onClick} className={button({ ...props, empty: !children })} > - {IconComponent && } + {IconComponent && } {children} - {IconAfterComponent && } + {IconAfterComponent && ( + + )} ); }; diff --git a/packages/ui/src/Atoms/Dropdown/Dropdown.tsx b/packages/ui/src/Atoms/Dropdown/Dropdown.tsx index 678844436..e7a7ca907 100644 --- a/packages/ui/src/Atoms/Dropdown/Dropdown.tsx +++ b/packages/ui/src/Atoms/Dropdown/Dropdown.tsx @@ -31,8 +31,12 @@ export function Dropdown({ children, toggle, className }: Props) { ); } -export function DropdownSeparator() { - return ; +export function DropdownSeparator({ className }: { className?: string }) { + return ( + + ); } type DropdownItemProps = PropsWithChildren<{ @@ -86,7 +90,9 @@ export function DropdownItem({ children ) : ( <> - {IconComponent && } + {IconComponent && ( + + )} {children} )} diff --git a/packages/ui/src/Atoms/Table/Table.tsx b/packages/ui/src/Atoms/Table/Table.tsx index 953adddcb..5d25a55d1 100644 --- a/packages/ui/src/Atoms/Table/Table.tsx +++ b/packages/ui/src/Atoms/Table/Table.tsx @@ -13,7 +13,7 @@ export function Table({ children }: PropsWithChildren) { export function Thead({ children }: PropsWithChildren) { return ( - + {children} ); @@ -30,7 +30,7 @@ export function Tr({ children, to }: PropsWithChildren<{ to?: string }>) { @@ -54,7 +54,7 @@ export function Td({ }: PropsWithChildren<{ noLink?: boolean }>) { const { to } = useContext(TrContext); if (!to || noLink) { - return ; + return ; } return (
{children}{children} diff --git a/packages/ui/src/Layouts/Layout.tsx b/packages/ui/src/Layouts/Layout.tsx index a9bf80492..75d52c0bd 100644 --- a/packages/ui/src/Layouts/Layout.tsx +++ b/packages/ui/src/Layouts/Layout.tsx @@ -10,7 +10,7 @@ type Props = PropsWithChildren<{ export function Layout({ header, sidebar, children }: Props) { return ( -
+
{sidebar} -
- {children} +
+
+ {children} +
diff --git a/packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx b/packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx new file mode 100644 index 000000000..fa8a66666 --- /dev/null +++ b/packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx @@ -0,0 +1,31 @@ +import { randomInt, times } from "@probo/helpers"; +import { RisksChart } from "./RisksChart"; +import type { Meta, StoryObj } from "@storybook/react"; + +export default { + title: "Molecules/RisksChart", + component: RisksChart, + argTypes: {}, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + type: "inherent", + organizationId: "1", + risks: times(20, (i) => ({ + id: i.toString(), + name: `Risk ${i}`, + inherentLikelihood: randomInt(1, 5), + inherentImpact: randomInt(1, 5), + residualLikelihood: randomInt(1, 5), + residualImpact: randomInt(1, 5), + })), + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/ui/src/Molecules/RisksChart/RisksChart.tsx b/packages/ui/src/Molecules/RisksChart/RisksChart.tsx new file mode 100644 index 000000000..6b7a2d5f9 --- /dev/null +++ b/packages/ui/src/Molecules/RisksChart/RisksChart.tsx @@ -0,0 +1,229 @@ +import clsx from "clsx"; +import { Card } from "../../Atoms/Card/Card"; +import { useTranslate } from "@probo/i18n"; +import { getRiskImpacts, getRiskLikelihoods, groupBy } from "@probo/helpers"; +import { Fragment, useMemo } from "react"; +import { + Dropdown, + DropdownItem, + DropdownSeparator, +} from "../../Atoms/Dropdown/Dropdown"; +import { IconChevronRight, IconFire3 } from "../../Atoms/Icons"; +import { Button } from "../../Atoms/Button/Button"; +import { Link } from "react-router"; + +type Props = { + organizationId: string; + type: "inherent" | "residual"; + risks?: Risk[]; +}; + +type Risk = { + id: string; + name: string; + inherentLikelihood: number; + inherentImpact: number; + residualLikelihood: number; + residualImpact: number; +}; + +const levelColors = [ + { + color: "bg-txt-success", + bg: "bg-success", + }, + { + color: "bg-txt-warning", + bg: "bg-warning", + }, + { + color: "bg-txt-danger", + bg: "bg-danger", + }, +] as const; + +const getLevel = (score: number): 0 | 1 | 2 => { + if (score >= 15) { + return 2; + } + if (score > 4) { + return 1; + } + return 0; +}; + +const cellKey = (impact: number, likelihood: number) => + `${impact}-${likelihood}`; + +/** + * Displays a grid of risk grouped by impact & likelihood + */ +export function RisksChart({ organizationId, type, risks }: Props) { + const { __ } = useTranslate(); + + const legend = [__("Low"), __("Medium"), __("High")]; + + const impacts = getRiskImpacts(__).reverse(); + const likelihoods = getRiskLikelihoods(__); + const impactField = + type === "inherent" ? "inherentImpact" : "residualImpact"; + const likelihoodField = + type === "inherent" ? "inherentLikelihood" : "residualLikelihood"; + + const riskMap = useMemo(() => { + return groupBy(risks ?? [], (risk) => + cellKey(risk[impactField], risk[likelihoodField]), + ); + }, [organizationId, risks]); + + return ( + +
+

+ {type === "inherent" + ? __("Inherent Risk") + : __("Residual Risk")} +

+
+ {legend.map((label, i) => ( +
+
+ {label} +
+ ))} +
+
+ {/* Grid */} +
+
+ {__("Impact")} +
+
+ {impacts.map((impact) => ( + +
+ {impact.label} ({impact.value}) +
+ {likelihoods.map((likelihood) => ( + + ))} +
+ ))} + {/* X axis */} +
+ {likelihoods.map((likelihood) => ( +
+ {likelihood.label} ({likelihood.value}) + {likelihood.value === 3 && ( +
+ {__("Likelihood")} +
+ )} +
+ ))} +
+
+ + ); +} + +function RisksChartCell({ + risks, + impact, + likelihood, + organizationId, +}: { + risks?: Risk[]; + impact: number; + likelihood: number; + organizationId: string; +}) { + const { __ } = useTranslate(); + const level = getLevel(impact * likelihood); + const baseClass = + "flex items-center justify-center aspect-square rounded-xl text-txt-invert text-sm font-semibold"; + if (!risks) { + return
; + } + + const infos = [ + { label: __("Number of risks"), value: risks.length }, + { label: __("Impact"), value: impact }, + { label: __("Likelihood"), value: likelihood }, + ]; + + return ( + + {risks.length} + + } + > + {infos.map((info) => ( +
+
{info.label}
+
{info.value}
+
+ ))} + +
+
Risk Score
+
{impact * likelihood}
+
+ +
{__("Linked Risks")}
+ {risks.map((risk) => ( + + + + {risk.name} + + + + ))} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c9a1efce5..41439d103 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -40,6 +40,7 @@ export { Dialog, DialogContent, DialogFooter } from "./Molecules/Dialog/Dialog"; export { RiskBadge } from "./Molecules/Badge/RiskBadge"; export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx"; export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx"; +export { RisksChart } from "./Molecules/RisksChart/RisksChart"; // Hooks export { useToast, Toasts } from "./Atoms/Toasts/Toasts";