Add detail page

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-22 18:27:00 +02:00
committed by Sacha Al Himdani
parent 72c3fe586a
commit fe3128da2a
34 changed files with 969 additions and 922 deletions

View File

@@ -1,180 +0,0 @@
/**
* @generated SignedSource<<45329c77cfacf0f653adac2e9172b371>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UserSelectQuery$variables = {
organizationId: string;
};
export type UserSelectQuery$data = {
readonly organization: {
readonly users?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly fullName: string;
readonly id: string;
};
}>;
};
};
};
export type UserSelectQuery = {
response: UserSelectQuery$data;
variables: UserSelectQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "CREATED_AT"
}
}
],
"concreteType": "UserConnection",
"kind": "LinkedField",
"name": "users",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "UserEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "users(first:100,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
}
],
"type": "Organization",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UserSelectQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UserSelectQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v3/*: any*/),
(v2/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "795bb4c1131f2cacedb1ab8faf9f10ed",
"id": null,
"metadata": {},
"name": "UserSelectQuery",
"operationKind": "query",
"text": "query UserSelectQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n users(first: 100, orderBy: {direction: ASC, field: CREATED_AT}) {\n edges {\n node {\n id\n fullName\n }\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "f2a6754264e15c49c53a9ac9fa60264e";
export default node;

View File

@@ -0,0 +1,12 @@
import { PageHeader, Skeleton } from "@probo/ui";
export function PageSkeleton() {
return (
<div className="space-y-6">
<PageHeader title={<Skeleton className="w-40 h-8" />} />
<div>
<Skeleton style={{ aspectRatio: "1280/280" }} />
</div>
</div>
);
}

View File

@@ -8,19 +8,24 @@ import type { MutationParameters, GraphQLTaggedNode } from "relay-runtime";
* A decorated useMutation hook that emits toast notifications on success or error.
*/
export function useMutationWithToasts<T extends MutationParameters>(
query: GraphQLTaggedNode
query: GraphQLTaggedNode,
baseOptions?: {
successMessage?: string;
errorMessage?: string;
}
) {
const [mutate, isLoading] = useMutation<T>(query);
const { toast } = useToast();
const { __ } = useTranslate();
const mutateWithToast = useCallback(
(
options: UseMutationConfig<T> & {
queryOptions: UseMutationConfig<T> & {
onSuccess?: () => void;
successMessage: string;
successMessage?: string;
errorMessage?: string;
}
) => {
const options = { ...baseOptions, ...queryOptions };
mutate({
...options,
onCompleted: (_, error) => {
@@ -35,7 +40,8 @@ export function useMutationWithToasts<T extends MutationParameters>(
}
toast({
title: __("Success"),
description: options.successMessage,
description:
options.successMessage ?? __("Operation completed successfully"),
variant: "success",
});
options.onSuccess?.();

View File

@@ -3,3 +3,4 @@
@import "@probo/ui/src/theme.css";
@import "tw-animate-css";
@source "../../../packages/ui/src";
@source "../../../packages/helpers/src";

View File

@@ -0,0 +1,21 @@
import { graphql } from "relay-runtime";
import { useTranslate } from "@probo/i18n";
import { useMutationWithToasts } from "../hooks/useMutationWithToasts";
import type { RisksDeleteMutation } from "./__generated__/RisksDeleteMutation.graphql";
const deleteRiskMutation = graphql`
mutation RisksDeleteMutation($input: DeleteRiskInput!, $connections: [ID!]!) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
}
`;
export function useDeleteRiskMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<RisksDeleteMutation>(deleteRiskMutation, {
successMessage: __("Risk deleted successfully."),
errorMessage: __("Failed to delete risk. Please try again."),
});
}

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<5102c0f9ea660855dc5db7c6b16fd64b>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteRiskInput = {
riskId: string;
};
export type RisksDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteRiskInput;
};
export type RisksDeleteMutation$data = {
readonly deleteRisk: {
readonly deletedRiskId: string;
};
};
export type RisksDeleteMutation = {
response: RisksDeleteMutation$data;
variables: RisksDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedRiskId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "RisksDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteRiskPayload",
"kind": "LinkedField",
"name": "deleteRisk",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "RisksDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteRiskPayload",
"kind": "LinkedField",
"name": "deleteRisk",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedRiskId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "f4273ec8d2cd76543169a6d1b7cd72b3",
"id": null,
"metadata": {},
"name": "RisksDeleteMutation",
"operationKind": "mutation",
"text": "mutation RisksDeleteMutation(\n $input: DeleteRiskInput!\n) {\n deleteRisk(input: $input) {\n deletedRiskId\n }\n}\n"
}
};
})();
(node as any).hash = "ad609ad0f9c749fe47f2bd0d382777bb";
export default node;

View File

@@ -1,150 +0,0 @@
/**
* @generated SignedSource<<282a94b76fe5e17bd40b45ced6445ef3>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type OrganizationSelectionPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationSelectionPageQuery$data = {
readonly viewer: {
readonly organizations: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
}>;
};
};
};
export type OrganizationSelectionPageQuery = {
response: OrganizationSelectionPageQuery$data;
variables: OrganizationSelectionPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = {
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 25
}
],
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "organizations(first:25)"
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "OrganizationSelectionPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "OrganizationSelectionPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/),
(v0/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "2938cb693a84c22366862de424a84d4b",
"id": null,
"metadata": {},
"name": "OrganizationSelectionPageQuery",
"operationKind": "query",
"text": "query OrganizationSelectionPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "c41774fd198ab5f0f4b866d805099baf";
export default node;

View File

@@ -72,7 +72,12 @@ const updateRiskMutation = graphql`
/**
* Dialog to create or update a risk
*/
export default function FormRiskDialog({ trigger, risk, onSuccess }: Props) {
export default function FormRiskDialog({
trigger,
risk,
onSuccess,
open,
}: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
@@ -131,11 +136,11 @@ export default function FormRiskDialog({ trigger, risk, onSuccess }: Props) {
});
const [showNote, toggleNote] = useToggle(false);
const [open, setOpen] = useState(!!risk);
const [isOpen, setOpen] = useState(open);
return (
<Dialog
open={open}
open={isOpen}
onOpenChange={setOpen}
trigger={trigger}
title={

View File

@@ -0,0 +1,153 @@
import {
ActionDropdown,
Avatar,
Breadcrumb,
Button,
ConfirmDialog,
Drawer,
DropdownItem,
IconPencil,
IconTrashCan,
PageHeader,
PropertyRow,
RiskOverview,
Badge,
} from "@probo/ui";
import { useNavigate, useParams } from "react-router";
import { useTranslate } from "@probo/i18n";
import { getTreatment, sprintf } from "@probo/helpers";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type {
RiskDetailPageQuery,
RiskDetailPageQuery$data,
} from "./__generated__/RiskDetailPageQuery.graphql";
import FormRiskDialog from "./FormRiskDialog";
import { usePageTitle } from "@probo/hooks";
import { useOrganizationId } from "../../../hooks/useOrganizationId";
import { useDeleteRiskMutation } from "../../../mutations/Risks";
const riskQuery = graphql`
query RiskDetailPageQuery($riskId: ID!) {
node(id: $riskId) {
... on Risk {
name
description
treatment
owner {
id
fullName
}
inherentLikelihood
inherentImpact
residualLikelihood
residualImpact
note
createdAt
updatedAt
...useRiskFormFragment
}
}
}
`;
export default function RiskDetailPage() {
const { riskId } = useParams<{ riskId: string }>();
const organizationId = useOrganizationId();
const navigate = useNavigate();
if (!riskId) {
throw new Error("Cannot load risk detail page without riskId parameter");
}
const { __ } = useTranslate();
const data = useLazyLoadQuery<RiskDetailPageQuery>(riskQuery, { riskId });
const risk = data.node as Required<RiskDetailPageQuery$data["node"]>;
const [deleteRisk] = useDeleteRiskMutation();
usePageTitle(risk.name ?? "Risk detail");
const onDelete = (riskId: string) => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
"RisksPage_risks"
);
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
},
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center mb-4">
<Breadcrumb
items={[
{
label: __("Risks"),
to: `/organizations/${organizationId}/risks`,
},
{
label: __("Risk detail"),
},
]}
/>
<div className="flex gap-2">
<FormRiskDialog
trigger={
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
}
risk={{ id: riskId, ...risk }}
/>
<ActionDropdown variant="secondary">
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(riskId)}
>
<DropdownItem variant="danger" icon={IconTrashCan}>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
</ActionDropdown>
</div>
</div>
<PageHeader title={risk.name} />
<div className="grid grid-cols-2">
<RiskOverview type="inherent" risk={risk} />
<RiskOverview type="residual" risk={risk} />
</div>
<Drawer>
<PropertyRow label={__("Owner")}>
<Badge variant="highlight" size="md" className="gap-2">
<Avatar name={risk.owner?.fullName ?? ""} />
{risk.owner?.fullName}
</Badge>
</PropertyRow>
<PropertyRow label={__("Treatment")}>
<Badge variant="highlight" size="md" className="gap-2">
{getTreatment(__, risk.treatment)}
</Badge>
</PropertyRow>
<PropertyRow label={__("Note")}>
<div className="text-sm text-txt-secondary">{risk.note}</div>
</PropertyRow>
</Drawer>
</div>
);
}

View File

@@ -30,8 +30,9 @@ import { useMemo, useState } from "react";
import { usePageTitle } from "@probo/hooks";
import type { RisksPageDeleteMutation } from "./__generated__/RisksPageDeleteMutation.graphql";
import { useMutationWithToasts } from "../../../hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import { getTreatment, sprintf } from "@probo/helpers";
import type { ItemOf } from "../../../types";
import { useDeleteRiskMutation } from "../../../mutations/Risks";
const risksQuery = graphql`
query RisksPageQuery(
@@ -65,17 +66,6 @@ const risksQuery = graphql`
}
`;
const deleteRiskMutation = graphql`
mutation RisksPageDeleteMutation(
$input: DeleteRiskInput!
$connections: [ID!]!
) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
}
`;
export default function RisksPage() {
const { __ } = useTranslate();
const data = useLazyLoadQuery<RisksPageQueryType>(risksQuery, {
@@ -85,18 +75,9 @@ export default function RisksPage() {
const [editedRisk, setEditedRisk] = useState<ItemOf<typeof risks> | null>(
null
);
const treatmentLabels = useMemo(() => {
return {
MITIGATED: __("Mitigate"),
ACCEPTED: __("Accept"),
TRANSFERRED: __("Transfer"),
AVOIDED: __("Avoid"),
} as Record<RiskTreatment, string>;
}, [__]);
const organizationId = useOrganizationId();
const [deleteRisk] =
useMutationWithToasts<RisksPageDeleteMutation>(deleteRiskMutation);
const [deleteRisk] = useDeleteRiskMutation();
const onDelete = (riskId: string) => {
const connectionId = ConnectionHandler.getConnectionID(
@@ -108,8 +89,6 @@ export default function RisksPage() {
input: { riskId },
connections: [connectionId],
},
successMessage: __("Risk deleted successfully."),
errorMessage: __("Failed to delete risk. Please try again."),
});
};
@@ -124,6 +103,7 @@ export default function RisksPage() {
</PageHeader>
{editedRisk && (
<FormRiskDialog
open
risk={editedRisk}
onSuccess={() => setEditedRisk(null)}
/>
@@ -161,7 +141,7 @@ export default function RisksPage() {
>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>
<Td>{treatmentLabels[risk.treatment]}</Td>
<Td>{getTreatment(__, risk.treatment)}</Td>
<Td>
<RiskBadge
score={risk.inherentLikelihood * risk.inherentImpact}

View File

@@ -1,249 +0,0 @@
/**
* @generated SignedSource<<3577f9a5c1b1f5b26dcac244a9a55fc5>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RiskTreatment = "ACCEPTED" | "AVOIDED" | "MITIGATED" | "TRANSFERRED" | "%future added value";
export type CreateRiskInput = {
category: string;
description: string;
inherentImpact: number;
inherentLikelihood: number;
name: string;
note?: string | null | undefined;
organizationId: string;
ownerId?: string | null | undefined;
residualImpact?: number | null | undefined;
residualLikelihood?: number | null | undefined;
treatment: RiskTreatment;
};
export type NewRiskDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateRiskInput;
};
export type NewRiskDialogMutation$data = {
readonly createRisk: {
readonly riskEdge: {
readonly node: {
readonly category: string;
readonly createdAt: any;
readonly description: string;
readonly id: string;
readonly inherentImpact: number;
readonly inherentLikelihood: number;
readonly name: string;
readonly residualImpact: number;
readonly residualLikelihood: number;
readonly treatment: RiskTreatment;
readonly updatedAt: any;
};
};
};
};
export type NewRiskDialogMutation = {
response: NewRiskDialogMutation$data;
variables: NewRiskDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "riskEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "treatment",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "NewRiskDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskPayload",
"kind": "LinkedField",
"name": "createRisk",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "NewRiskDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskPayload",
"kind": "LinkedField",
"name": "createRisk",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "riskEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e9b461ea2a98edd7d4a1b289d5d4673f",
"id": null,
"metadata": {},
"name": "NewRiskDialogMutation",
"operationKind": "mutation",
"text": "mutation NewRiskDialogMutation(\n $input: CreateRiskInput!\n) {\n createRisk(input: $input) {\n riskEdge {\n node {\n id\n name\n description\n category\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n treatment\n createdAt\n updatedAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "532223f85627f715a9a457680073f358";
export default node;

View File

@@ -0,0 +1,262 @@
/**
* @generated SignedSource<<cfb39c3c814ed520de659842d6fffe38>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type RiskTreatment = "ACCEPTED" | "AVOIDED" | "MITIGATED" | "TRANSFERRED" | "%future added value";
export type RiskDetailPageQuery$variables = {
riskId: string;
};
export type RiskDetailPageQuery$data = {
readonly node: {
readonly createdAt?: any;
readonly description?: string;
readonly inherentImpact?: number;
readonly inherentLikelihood?: number;
readonly name?: string;
readonly note?: string;
readonly owner?: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly residualImpact?: number;
readonly residualLikelihood?: number;
readonly treatment?: RiskTreatment;
readonly updatedAt?: any;
readonly " $fragmentSpreads": FragmentRefs<"useRiskFormFragment">;
};
};
export type RiskDetailPageQuery = {
response: RiskDetailPageQuery$data;
variables: RiskDetailPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "riskId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "riskId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "treatment",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentLikelihood",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentImpact",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualLikelihood",
"storageKey": null
},
v10 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualImpact",
"storageKey": null
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "note",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v13 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "RiskDetailPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "useRiskFormFragment"
}
],
"type": "Risk",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "RiskDetailPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v5/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
}
],
"type": "Risk",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "0ed24be13c829da91705d506a46eb562",
"id": null,
"metadata": {},
"name": "RiskDetailPageQuery",
"operationKind": "query",
"text": "query RiskDetailPageQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n ... on Risk {\n name\n description\n treatment\n owner {\n id\n fullName\n }\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n createdAt\n updatedAt\n ...useRiskFormFragment\n }\n id\n }\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "94de027c557f9b633765f9dde7c0aae1";
export default node;

View File

@@ -1,220 +0,0 @@
/**
* @generated SignedSource<<78e5a6f1db510ffe28be99140539b234>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RiskTreatment = "ACCEPTED" | "AVOIDED" | "MITIGATED" | "TRANSFERRED" | "%future added value";
export type UpdateRiskInput = {
category?: string | null | undefined;
description?: string | null | undefined;
id: string;
inherentImpact?: number | null | undefined;
inherentLikelihood?: number | null | undefined;
name?: string | null | undefined;
note?: string | null | undefined;
ownerId?: string | null | undefined;
residualImpact?: number | null | undefined;
residualLikelihood?: number | null | undefined;
treatment?: RiskTreatment | null | undefined;
};
export type RisksPageUpdateRiskMutation$variables = {
input: UpdateRiskInput;
};
export type RisksPageUpdateRiskMutation$data = {
readonly updateRisk: {
readonly risk: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly inherentImpact: number;
readonly inherentLikelihood: number;
readonly name: string;
readonly note: string;
readonly owner: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly residualImpact: number;
readonly residualLikelihood: number;
readonly treatment: RiskTreatment;
readonly updatedAt: any;
};
};
};
export type RisksPageUpdateRiskMutation = {
response: RisksPageUpdateRiskMutation$data;
variables: RisksPageUpdateRiskMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateRiskPayload",
"kind": "LinkedField",
"name": "updateRisk",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "risk",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "treatment",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "note",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "RisksPageUpdateRiskMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "RisksPageUpdateRiskMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "a3c421bf079967f96acf2215e72cf8a7",
"id": null,
"metadata": {},
"name": "RisksPageUpdateRiskMutation",
"operationKind": "mutation",
"text": "mutation RisksPageUpdateRiskMutation(\n $input: UpdateRiskInput!\n) {\n updateRisk(input: $input) {\n risk {\n id\n name\n description\n category\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n treatment\n note\n updatedAt\n owner {\n id\n fullName\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e3c8daa449e937742695adf8deb6dbe5";
export default node;

View File

@@ -7,7 +7,8 @@ import { MainLayout } from "./layouts/MainLayout";
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
import { lazy, Suspense, type FC } from "react";
import { UnAuthenticatedError } from "./providers/RelayProviders";
import { RisksPageSkeleton } from "./pages/organizations/risks/skeletons/RisksPage";
import { RisksPageSkeleton } from "./components/skeletons/RisksPageSkeleton.tsx";
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
function ErrorBoundary() {
const error = useRouteError();
@@ -49,7 +50,7 @@ const routes = [
{
path: "organizations/new",
Component: lazy(
() => import("./pages/organizations/NewOrganizationPage")
() => import("./pages/organizations/NewOrganizationPage"),
),
},
],
@@ -64,6 +65,13 @@ const routes = [
fallback: RisksPageSkeleton,
Component: lazy(() => import("./pages/organizations/risks/RisksPage")),
},
{
path: "risks/:riskId",
fallback: PageSkeleton,
Component: lazy(
() => import("./pages/organizations/risks/RiskDetailPage"),
),
},
],
},
] satisfies Route[];
@@ -74,7 +82,6 @@ const routes = [
function routeTransformer(route: Route): RouteObject {
if ("fallback" in route && route.fallback) {
const fallback = <route.fallback />;
console.log(fallback, route.path);
return {
...route,
children: route.children?.map(routeTransformer),

View File

@@ -1,5 +1,10 @@
export { objectKeys } from "./object";
export { sprintf } from "./string";
export { getRiskImpacts, getRiskLikelihoods } from "./risk";
export {
getTreatment,
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
} from "./risk";
export { times, groupBy } from "./array";
export { randomInt } from "./number";

View File

@@ -25,6 +25,21 @@ export function getRiskImpacts(__: Translator) {
];
}
export function getTreatment(__: Translator, treatment: string): string {
switch (treatment) {
case "MITIGATED":
return __("Mitigate");
case "ACCEPTED":
return __("Accept");
case "TRANSFERRED":
return __("Transfer");
case "AVOIDED":
return __("Avoid");
default:
return __("Unknown");
}
}
export function getRiskLikelihoods(__: Translator) {
return [
{
@@ -49,3 +64,33 @@ export function getRiskLikelihoods(__: Translator) {
},
];
}
function getRiskSeverities(__: Translator) {
return [
{
min: 15,
variant: "danger",
label: __("Critical"),
bg: "bg-danger",
color: "text-txt-danger",
},
{
min: 5,
variant: "warning",
label: __("High"),
bg: "bg-warning",
color: "text-txt-warning",
},
{
min: 0,
variant: "neutral",
label: __("Low"),
bg: "bg-txt-quaternary",
color: "text-txt-primary",
},
] as const;
}
export function getSeverity(__: Translator, score: number) {
return getRiskSeverities(__).find((s) => score >= s.min);
}

View File

@@ -2,3 +2,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../src/theme.css";
@source "../../helpers/src";

View File

@@ -9,23 +9,34 @@ export default {
type Story = StoryObj<typeof Badge>;
const sizes = ["sm", "md"] as const;
const variants = [
"success",
"warning",
"danger",
"info",
"neutral",
"outline",
"highlight",
] as const;
export const Default: Story = {
render: () => (
<>
{(
[
"success",
"warning",
"danger",
"info",
"neutral",
"outline",
] as const
).map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
<div className="space-y-4">
{sizes.map((size) => (
<div key={size} className="space-y-1">
<div className="text-xs text-txt-secondary">
Size : {size}
</div>
<div className="flex gap-2">
{variants.map((variant) => (
<Badge key={variant} variant={variant} size={size}>
{variant}
</Badge>
))}
</div>
</div>
))}
</>
</div>
),
};

View File

@@ -2,11 +2,19 @@ import type { HTMLAttributes } from "react";
import { tv } from "tailwind-variants";
type Props = {
variant?: "success" | "warning" | "danger" | "info" | "neutral" | "outline";
variant?:
| "success"
| "warning"
| "danger"
| "info"
| "neutral"
| "outline"
| "highlight";
size?: "sm" | "md";
} & HTMLAttributes<HTMLDivElement>;
const badge = tv({
base: "text-xs font-medium py-[2px] px-[6px] rounded-lg w-max",
base: "font-medium rounded-lg w-max flex gap-1 items-center",
variants: {
variant: {
success: "bg-success text-txt-success",
@@ -15,10 +23,18 @@ const badge = tv({
info: "bg-info text-txt-info",
neutral: "bg-subtle text-txt-secondary",
outline: "text-txt-tertiary border border-border-low",
highlight: "bg-highlight text-txt-primary",
},
size: {
sm: "text-xs py-[2px] px-[6px]",
md: "text-sm py-[6px] px-2",
},
},
defaultVariants: {
size: "md",
},
});
export function Badge(props: Props) {
return <div className={badge(props)} {...props} />;
return <div {...props} className={badge(props)} />;
}

View File

@@ -61,12 +61,19 @@ const dropdownItem = tv({
},
});
export function ActionDropdown(props: Omit<Props, "toggle">) {
export function ActionDropdown(
props: Omit<Props, "toggle"> & {
variant?: ComponentProps<typeof Button>["variant"];
},
) {
return (
<Dropdown
{...props}
toggle={
<Button variant="tertiary" icon={IconDotGrid1x3Horizontal} />
<Button
variant={props.variant ?? "tertiary"}
icon={IconDotGrid1x3Horizontal}
/>
}
/>
);

View File

@@ -3,7 +3,7 @@ import { Label } from "../Label/Label";
type Props = {
label: string;
id: string;
id?: string;
children: ReactNode;
error?: string;
};

View File

@@ -112,7 +112,7 @@ export function Select({
(c) => c.props.value?.toString() === value?.toString(),
)
: undefined;
console.log(childrenArr, valueNode);
return (
<Root onValueChange={onValueChange} value={value}>
<Trigger className={trigger({ ...props })} {...props}>

View File

@@ -1,4 +1,4 @@
import { Layout } from "./Layout";
import { Layout, Drawer } from "./Layout";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
@@ -12,4 +12,36 @@ const meta = {
export default meta;
type Story = StoryObj<typeof Layout>;
export const Default: Story = {};
export const Default: Story = {
args: {
children: (
<p>
Lorem ipsum dolor sit amet consectetur, adipisicing elit.
Tempora tempore odit at ipsa dignissimos cumque deleniti, illum
rerum sunt laudantium molestias ducimus vero maiores eligendi
necessitatibus nemo animi. Ipsam, fugit.
</p>
),
},
};
export const WithSidebar: Story = {
args: {
children: (
<>
<p>
Lorem ipsum dolor sit amet consectetur, adipisicing elit.
Tempora tempore odit at ipsa dignissimos cumque deleniti,
illum rerum sunt laudantium molestias ducimus vero maiores
eligendi necessitatibus nemo animi. Ipsam, fugit.
</p>
<Drawer>
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Nostrum reiciendis eveniet possimus illo rerum labore nisi
voluptatibus sequi consectetur corrupti, a, sunt dicta ut ad
pariatur. Beatae optio libero pariatur!
</Drawer>
</>
),
},
};

View File

@@ -1,39 +1,84 @@
import type { PropsWithChildren, ReactNode } from "react";
import {
createContext,
useContext,
useEffect,
useState,
type PropsWithChildren,
type ReactNode,
useMemo,
} from "react";
import { Sidebar } from "../Atoms/Sidebar/Sidebar.tsx";
import { Logo } from "../Atoms/Logo/Logo.tsx";
import { Toasts } from "../Atoms/Toasts/Toasts.tsx";
import { createPortal } from "react-dom";
import clsx from "clsx";
type Props = PropsWithChildren<{
header: ReactNode;
sidebar: ReactNode;
}>;
const LayoutContext = createContext({
setDrawer: (() => {}) as (v: boolean) => void,
});
export function Layout({ header, sidebar, children }: Props) {
const [hasDrawer, setDrawer] = useState(false);
return (
<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
className="mx-3"
width="8"
height="18"
viewBox="0 0 8 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 17L7 1" stroke="#C3C8C2" />
</svg>
{header}
</header>
<div className="flex h-screen">
<Sidebar>{sidebar}</Sidebar>
<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>
<LayoutContext
value={useMemo(
() => ({
setDrawer,
}),
[],
)}
>
<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
className="mx-3"
width="8"
height="18"
viewBox="0 0 8 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 17L7 1" stroke="#C3C8C2" />
</svg>
{header}
</header>
<div className="flex h-screen" id="main">
<Sidebar>{sidebar}</Sidebar>
<main
className={clsx(
"overflow-y-auto w-full mt-12 transition-all duration-300",
hasDrawer && "pr-105",
)}
>
<div className="py-12 px-8 max-w-[1200px] w-full mx-auto">
{children}
</div>
</main>
</div>
<Toasts />
</div>
<Toasts />
</div>
</LayoutContext>
);
}
export function Drawer({ children }: PropsWithChildren) {
const { setDrawer } = useContext(LayoutContext);
useEffect(() => {
setDrawer(true);
return () => {
setDrawer(false);
};
}, []);
return createPortal(
<aside className="absolute pt-20 top-0 right-0 w-105 px-6 pb-8 border-border-solid border-l h-screen">
{children}
</aside>,
document.body,
);
}

View File

@@ -2,35 +2,29 @@ import { useTranslate } from "@probo/i18n";
import { Badge } from "../../Atoms/Badge/Badge.tsx";
type Props = {
severity: number;
score: number;
};
const badgeVariant = (severity: number) => {
if (severity >= 75) {
const badgeVariant = (score: number) => {
if (score >= 15) {
return "danger";
}
if (severity >= 50) {
if (score >= 5) {
return "warning";
}
if (severity >= 25) {
return "info";
}
return "success";
return "neutral";
};
export function SeverityBadge({ severity }: Props) {
export function SeverityBadge({ score }: Props) {
const { __ } = useTranslate();
const label = () => {
if (severity >= 75) {
if (score >= 15) {
return __("Critical");
}
if (severity >= 50) {
if (score >= 5) {
return __("High");
}
if (severity >= 25) {
return __("Low");
}
return __("None");
return __("Negligible");
};
return <Badge variant={badgeVariant(severity)}>{label()}</Badge>;
return <Badge variant={badgeVariant(score)}>{label()}</Badge>;
}

View File

@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react";
import type { PropsWithChildren, ReactNode } from "react";
type Props = PropsWithChildren<{
title: string;
title: ReactNode;
description?: string;
}>;

View File

@@ -0,0 +1,22 @@
import { RiskOverview } from "./RiskOverview";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Molecules/Risks/RiskOverview",
component: RiskOverview,
argTypes: {},
} satisfies Meta<typeof RiskOverview>;
type Story = StoryObj<typeof RiskOverview>;
export const Default: Story = {
args: {
type: "inherent",
risk: {
inherentLikelihood: 5,
inherentImpact: 5,
residualLikelihood: 1,
residualImpact: 1,
},
},
};

View File

@@ -0,0 +1,88 @@
import { useTranslate } from "@probo/i18n";
import { Card } from "../../Atoms/Card/Card";
import { levelColors } from "./constants";
import {
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
} from "@probo/helpers";
import clsx from "clsx";
type Props = {
type: "inherent" | "residual";
risk?: Risk;
};
type Risk = {
inherentLikelihood: number;
inherentImpact: number;
residualLikelihood: number;
residualImpact: number;
};
const getColor = (score: number): string => {
return levelColors[Math.ceil(score / 2) - 1].color;
};
export function RiskOverview({ type, risk }: Props) {
const { __ } = useTranslate();
const impact = risk?.[`${type}Impact`] ?? 0;
const likelihood = risk?.[`${type}Likelihood`] ?? 0;
const severity = getSeverity(__, impact * likelihood);
return (
<Card padded>
<h2 className="font-semibold text-base mb-6">
{type === "inherent" ? __("Initial Risk") : __("Residual Risk")}
</h2>
<div className="grid grid-cols-2 gap-4 mb-4">
<RiskOverviewBadge
label={__("Impact")}
textCb={getRiskImpacts}
score={impact}
/>
<RiskOverviewBadge
label={__("Likelihood")}
textCb={getRiskLikelihoods}
score={likelihood}
/>
</div>
<div className="space-y-2">
<div className="font-medium text-xs">{__("Severity")}</div>
<div
className={clsx(
severity?.bg,
severity?.color,
"py-2 text-sm font-semibold rounded-lg text-center",
)}
>
{severity?.label}
</div>
</div>
</Card>
);
}
function RiskOverviewBadge({
score,
label,
textCb,
}: {
score: number;
label: string;
textCb: (t: (s: string) => string) => { value: number; label: string }[];
}) {
const { __ } = useTranslate();
return (
<div className="space-y-2">
<div className="font-medium text-xs">{__(label)}</div>
<div
className={clsx(
getColor(score),
"py-2 text-sm font-semibold rounded-lg text-txt-invert text-center",
)}
>
{textCb(__).find((i) => i.value === score)?.label} ({score})
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { RisksChart } from "./RisksChart";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Molecules/RisksChart",
title: "Molecules/Risks/RisksChart",
component: RisksChart,
argTypes: {},
} satisfies Meta<typeof RisksChart>;

View File

@@ -9,8 +9,8 @@ import {
DropdownSeparator,
} from "../../Atoms/Dropdown/Dropdown";
import { IconChevronRight, IconFire3 } from "../../Atoms/Icons";
import { Button } from "../../Atoms/Button/Button";
import { Link } from "react-router";
import { levelColors } from "./constants";
type Props = {
organizationId: string;
@@ -27,21 +27,6 @@ type Risk = {
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;

View File

@@ -0,0 +1,14 @@
export 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;

View File

@@ -1,5 +1,5 @@
// Layouts
export { Layout } from "./Layouts/Layout";
export { Layout, Drawer } from "./Layouts/Layout";
export { AuthLayout } from "./Layouts/AuthLayout";
export { ErrorLayout } from "./Layouts/ErrorLayout";
export {
@@ -14,6 +14,7 @@ export { SidebarItem } from "./Atoms/Sidebar/SidebarItem";
export { Button } from "./Atoms/Button/Button";
export { Card } from "./Atoms/Card/Card";
export { Breadcrumb } from "./Atoms/Breadcrumb/Breadcrumb";
export { Badge } from "./Atoms/Badge/Badge";
export {
Dropdown,
ActionDropdown,
@@ -40,7 +41,8 @@ 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";
export { RisksChart } from "./Molecules/Risks/RisksChart";
export { RiskOverview } from "./Molecules/Risks/RiskOverview";
// Hooks
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";

View File

@@ -13,7 +13,7 @@
--color-txt-invert: var(--color-invert);
--color-txt-secondary: #6b716a;
--color-txt-tertiary: #818780;
--color-txt-quaternary: #d8dcd8;
--color-txt-quaternary: #c3c8c2;
--color-txt-disabled: #000000;
--color-txt-accent: #93c926;
--color-txt-danger: #cd2b31;