Add risk charts
Signed-off-by: Bryan Frimin <bryan@getprobo.com> Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
committed by
Sacha Al Himdani
parent
fc45d2f00d
commit
08bf65d919
@@ -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}
|
||||
>
|
||||
<Option value="1">1 - Negligible</Option>
|
||||
<Option value="2">2 - Low</Option>
|
||||
<Option value="3">3 - Moderate</Option>
|
||||
<Option value="4">4 - Significant</Option>
|
||||
<Option value="5">5 - Catastrophic</Option>
|
||||
{getRiskImpacts(__).map((i) => (
|
||||
<Option key={i.value} value={i.value}>
|
||||
{i.value} - {i.label}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledField>
|
||||
<ControlledField
|
||||
control={control}
|
||||
@@ -290,11 +291,11 @@ function ImpactAndLikelihood({
|
||||
placeholder={__("Select likelihood level")}
|
||||
error={errors?.[`${prefix}Likelihood`]?.message}
|
||||
>
|
||||
<Option value="1">1 - Improbable</Option>
|
||||
<Option value="2">2 - Remote</Option>
|
||||
<Option value="3">3 - Occasional</Option>
|
||||
<Option value="4">4 - Probable</Option>
|
||||
<Option value="5">5 - Frequent</Option>
|
||||
{getRiskLikelihoods(__).map((l) => (
|
||||
<Option key={l.value} value={l.value}>
|
||||
{l.value} - {l.label}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledField>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RisksChart
|
||||
organizationId={organizationId}
|
||||
type="inherent"
|
||||
risks={risks}
|
||||
/>
|
||||
<RisksChart
|
||||
organizationId={organizationId}
|
||||
type="residual"
|
||||
risks={risks}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
|
||||
20
packages/helpers/src/array.ts
Normal file
20
packages/helpers/src/array.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function times<T>(n: number, cb: (i: number) => T): T[] {
|
||||
return Array.from({ length: n }, (_, i) => cb(i));
|
||||
}
|
||||
|
||||
export function groupBy<T>(
|
||||
arr: T[],
|
||||
key: (item: T) => string,
|
||||
): Record<string, T[]> {
|
||||
return arr.reduce(
|
||||
(acc, item) => {
|
||||
const k = key(item);
|
||||
if (!acc[k]) {
|
||||
acc[k] = [];
|
||||
}
|
||||
acc[k].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, T[]>,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
3
packages/helpers/src/number.ts
Normal file
3
packages/helpers/src/number.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
51
packages/helpers/src/risk.ts
Normal file
51
packages/helpers/src/risk.ts
Normal file
@@ -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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 size={16} />}
|
||||
{IconComponent && <IconComponent size={16} className="flex-none" />}
|
||||
{children}
|
||||
{IconAfterComponent && <IconAfterComponent size={16} />}
|
||||
{IconAfterComponent && (
|
||||
<IconAfterComponent size={16} className="flex-none" />
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,8 +31,12 @@ export function Dropdown({ children, toggle, className }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownSeparator() {
|
||||
return <DropdownMenu.Separator className="h-[1px] bg-border-low my-2" />;
|
||||
export function DropdownSeparator({ className }: { className?: string }) {
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
className={clsx("h-[1px] bg-border-low my-2", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownItemProps = PropsWithChildren<{
|
||||
@@ -86,7 +90,9 @@ export function DropdownItem({
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{IconComponent && <IconComponent size={16} />}
|
||||
{IconComponent && (
|
||||
<IconComponent size={16} className="flex-none" />
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Table({ children }: PropsWithChildren) {
|
||||
|
||||
export function Thead({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<thead className="text-xs text-txt-tertiary font-semibold">
|
||||
<thead className="text-xs text-txt-tertiary font-semibold border-border-low border-b">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
@@ -30,7 +30,7 @@ export function Tr({ children, to }: PropsWithChildren<{ to?: string }>) {
|
||||
<TrContext value={{ to }}>
|
||||
<tr
|
||||
className={clsx(
|
||||
"border-border-low border",
|
||||
"border-border-low border-y first:border-none last:border-none",
|
||||
to && "hover:bg-subtle",
|
||||
)}
|
||||
>
|
||||
@@ -54,7 +54,7 @@ export function Td({
|
||||
}: PropsWithChildren<{ noLink?: boolean }>) {
|
||||
const { to } = useContext(TrContext);
|
||||
if (!to || noLink) {
|
||||
return <td className="first:pl-6 last:pr-6 py-3">{children}</td>;
|
||||
return <td className="first:pl-6 py-3">{children}</td>;
|
||||
}
|
||||
return (
|
||||
<td className="first:*:pl-6 *:block last:*:pr-6 py-3">
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = PropsWithChildren<{
|
||||
|
||||
export function Layout({ header, sidebar, children }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-txt-primary bg-level-0">
|
||||
<header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-1">
|
||||
<Logo className="w-12 h-5" />
|
||||
<svg
|
||||
@@ -27,8 +27,10 @@ export function Layout({ header, sidebar, children }: Props) {
|
||||
</header>
|
||||
<div className="flex h-screen">
|
||||
<Sidebar>{sidebar}</Sidebar>
|
||||
<main className="p-12 pt-24 max-w-[1200px] w-full mx-auto overflow-y-auto">
|
||||
{children}
|
||||
<main className="overflow-y-auto w-full mt-12">
|
||||
<div className="py-12 px-8 max-w-[1200px] w-full mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toasts />
|
||||
|
||||
31
packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx
Normal file
31
packages/ui/src/Molecules/RisksChart/RisksChart.stories.tsx
Normal file
@@ -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<typeof RisksChart>;
|
||||
|
||||
type Story = StoryObj<typeof RisksChart>;
|
||||
|
||||
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) => (
|
||||
<div style={{ maxWidth: 630 }}>
|
||||
<RisksChart {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
229
packages/ui/src/Molecules/RisksChart/RisksChart.tsx
Normal file
229
packages/ui/src/Molecules/RisksChart/RisksChart.tsx
Normal file
@@ -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 (
|
||||
<Card padded className="text-txt-primary">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="font-semibold text-lg">
|
||||
{type === "inherent"
|
||||
? __("Inherent Risk")
|
||||
: __("Residual Risk")}
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
{legend.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"size-[10px] rounded-xs",
|
||||
levelColors[i].color,
|
||||
)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Grid */}
|
||||
<div className="flex gap-6">
|
||||
<div
|
||||
className="text-xs font-medium flex-none text-center"
|
||||
style={{ writingMode: "sideways-lr" }}
|
||||
>
|
||||
{__("Impact")}
|
||||
</div>
|
||||
<div className="grid grid-cols-[90px_1fr_1fr_1fr_1fr_1fr] gap-1 w-full">
|
||||
{impacts.map((impact) => (
|
||||
<Fragment key={impact.value}>
|
||||
<div className="pr-2 text-right text-xs text-txt-secondary flex items-center">
|
||||
{impact.label} ({impact.value})
|
||||
</div>
|
||||
{likelihoods.map((likelihood) => (
|
||||
<RisksChartCell
|
||||
key={likelihood.value}
|
||||
impact={impact.value}
|
||||
likelihood={likelihood.value}
|
||||
organizationId={organizationId}
|
||||
risks={
|
||||
riskMap[
|
||||
cellKey(
|
||||
impact.value,
|
||||
likelihood.value,
|
||||
)
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* X axis */}
|
||||
<div></div>
|
||||
{likelihoods.map((likelihood) => (
|
||||
<div
|
||||
className="text-center text-xs text-txt-secondary mt-4"
|
||||
key={likelihood.value}
|
||||
>
|
||||
{likelihood.label} ({likelihood.value})
|
||||
{likelihood.value === 3 && (
|
||||
<div className="text-xs text-txt-primary font-medium flex-none text-center mt-3">
|
||||
{__("Likelihood")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div className={clsx(baseClass, levelColors[level].bg)}></div>;
|
||||
}
|
||||
|
||||
const infos = [
|
||||
{ label: __("Number of risks"), value: risks.length },
|
||||
{ label: __("Impact"), value: impact },
|
||||
{ label: __("Likelihood"), value: likelihood },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="text-sm w-75 p-4 space-y-1"
|
||||
toggle={
|
||||
<button
|
||||
className={clsx(
|
||||
baseClass,
|
||||
levelColors[level].color,
|
||||
"cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{risks.length}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{infos.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="text-txt-secondary">{info.label}</div>
|
||||
<div>{info.value}</div>
|
||||
</div>
|
||||
))}
|
||||
<DropdownSeparator className="my-3" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-txt-secondary">Risk Score</div>
|
||||
<div>{impact * likelihood}</div>
|
||||
</div>
|
||||
<DropdownSeparator className="my-3" />
|
||||
<div className="text-txt-secondary mb-1">{__("Linked Risks")}</div>
|
||||
{risks.map((risk) => (
|
||||
<DropdownItem key={risk.id} asChild>
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/risks/${risk.id}`}
|
||||
>
|
||||
<IconFire3 size={16} className="flex-none" />
|
||||
{risk.name}
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
className="flex-none ml-auto"
|
||||
/>
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user