From 1253145d3b0c863756a478b262c04ea39ab9a556 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 23 May 2025 17:54:50 +0200 Subject: [PATCH] Add measures tab Signed-off-by: Bryan Frimin Signed-off-by: Sacha Al Himdani --- .../components/risks/MeasureLinkDialog.tsx | 162 +++++++++++ ...MeasureLinkDialogCreateMutation.graphql.ts | 93 ++++++ .../MeasureLinkDialogQuery.graphql.ts | 271 ++++++++++++++++++ .../useRiskFormFragment.graphql.ts | 0 .../risks => hooks}/forms/useRiskForm.tsx | 2 +- apps/console2/src/hooks/graph/RiskGraph.ts | 1 + .../RiskGraphNodeQuery.graphql.ts | 159 ++++++++-- .../organizations/risks/FormRiskDialog.tsx | 6 +- .../organizations/risks/RiskDetailPage.tsx | 15 + .../risks/tabs/RiskMeasuresTab.tsx | 104 +++++++ .../risks/{ => tabs}/RiskOverviewTab.tsx | 0 .../RiskMeasuresTabFragment.graphql.ts | 190 ++++++++++++ .../RiskOverviewTabFragment.graphql.ts | 0 apps/console2/src/routes.tsx | 48 +--- apps/console2/src/routes/riskRoutes.ts | 48 ++++ packages/ui/package.json | 1 + packages/ui/src/Atoms/Badge/Badge.tsx | 1 + packages/ui/src/Atoms/Input/Input.tsx | 44 ++- packages/ui/src/Atoms/Spinner/Spinner.tsx | 10 +- packages/ui/src/Atoms/Tabs/Tabs.stories.tsx | 20 ++ packages/ui/src/Atoms/Tabs/Tabs.tsx | 33 +++ packages/ui/src/Molecules/Dialog/Dialog.tsx | 20 +- packages/ui/src/index.ts | 1 + 23 files changed, 1161 insertions(+), 68 deletions(-) create mode 100644 apps/console2/src/components/risks/MeasureLinkDialog.tsx create mode 100644 apps/console2/src/components/risks/__generated__/MeasureLinkDialogCreateMutation.graphql.ts create mode 100644 apps/console2/src/components/risks/__generated__/MeasureLinkDialogQuery.graphql.ts rename apps/console2/src/{pages/organizations/risks => hooks}/forms/__generated__/useRiskFormFragment.graphql.ts (100%) rename apps/console2/src/{pages/organizations/risks => hooks}/forms/useRiskForm.tsx (95%) create mode 100644 apps/console2/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx rename apps/console2/src/pages/organizations/risks/{ => tabs}/RiskOverviewTab.tsx (100%) create mode 100644 apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskMeasuresTabFragment.graphql.ts rename apps/console2/src/pages/organizations/risks/{ => tabs}/__generated__/RiskOverviewTabFragment.graphql.ts (100%) create mode 100644 apps/console2/src/routes/riskRoutes.ts create mode 100644 packages/ui/src/Atoms/Tabs/Tabs.stories.tsx create mode 100644 packages/ui/src/Atoms/Tabs/Tabs.tsx diff --git a/apps/console2/src/components/risks/MeasureLinkDialog.tsx b/apps/console2/src/components/risks/MeasureLinkDialog.tsx new file mode 100644 index 000000000..75567bff5 --- /dev/null +++ b/apps/console2/src/components/risks/MeasureLinkDialog.tsx @@ -0,0 +1,162 @@ +import { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + IconMagnifyingGlass, + IconPlusLarge, + IconTrashCan, + Input, + Spinner, +} from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import { Suspense, useMemo, useState, type ReactNode } from "react"; +import { graphql } from "relay-runtime"; +import { useLazyLoadQuery } from "react-relay"; +import type { + MeasureLinkDialogQuery, + MeasureLinkDialogQuery$data, +} from "./__generated__/MeasureLinkDialogQuery.graphql"; +import type { NodeOf } from "../../types"; +import { useMutationWithToasts } from "../../hooks/useMutationWithToasts"; +import { useToggle } from "@probo/hooks"; + +const measuresQuery = graphql` + query MeasureLinkDialogQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ... on Organization { + measures(first: 100) @connection(key: "Organization__measures") { + edges { + node { + id + name + description + category + state + } + } + } + } + } + } +`; + +const attachMeasureMutation = graphql` + mutation MeasureLinkDialogCreateMutation( + $input: CreateRiskMeasureMappingInput! + ) { + createRiskMeasureMapping(input: $input) { + success + } + } +`; + +type Props = { + trigger: ReactNode; + organizationId: string; + connectionId: string; + riskId: string; +}; + +export function MeasureLinkDialog({ trigger, ...props }: Props) { + const { __ } = useTranslate(); + + return ( + + +

+ {__("Link or unlink measures to manage this risk.")} +

+ }> + + +
+ +
+ ); +} + +function MeasureLinkDialogContent(props: Omit) { + const data = useLazyLoadQuery(measuresQuery, { + organizationId: props.organizationId, + }); + const { __ } = useTranslate(); + const [search, setSearch] = useState(""); + const measures = + data.organization?.measures?.edges?.map((edge) => edge.node) ?? []; + + const filteredMeasures = useMemo(() => { + return measures.filter( + (measure) => + measure.name.toLowerCase().includes(search.toLowerCase()) || + measure.description?.toLowerCase().includes(search.toLowerCase()) + ); + }, [measures, search]); + + return ( + <> +
+ +
+
+ {filteredMeasures.map((measure) => ( + + ))} +
+ + ); +} + +type RowProps = { + measure: NodeOf< + Required["measures"] + >; +} & Omit; + +function MeasureRow(props: RowProps) { + const [isLinked, toggleLinked] = useToggle(false); + const { __ } = useTranslate(); + const [attachMeasure, isFetching] = useMutationWithToasts( + attachMeasureMutation, + { + successMessage: __("Measure linked successfully"), + errorMessage: __("Failed to link measure"), + } + ); + + const onClick = () => { + attachMeasure({ + variables: { + input: { + riskId: props.riskId, + measureId: props.measure.id, + }, + }, + onSuccess() { + toggleLinked(); + }, + }); + }; + + return ( +
+ {props.measure.name} + {props.measure.category} + +
+ ); +} diff --git a/apps/console2/src/components/risks/__generated__/MeasureLinkDialogCreateMutation.graphql.ts b/apps/console2/src/components/risks/__generated__/MeasureLinkDialogCreateMutation.graphql.ts new file mode 100644 index 000000000..855bf49c9 --- /dev/null +++ b/apps/console2/src/components/risks/__generated__/MeasureLinkDialogCreateMutation.graphql.ts @@ -0,0 +1,93 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type CreateRiskMeasureMappingInput = { + measureId: string; + riskId: string; +}; +export type MeasureLinkDialogCreateMutation$variables = { + input: CreateRiskMeasureMappingInput; +}; +export type MeasureLinkDialogCreateMutation$data = { + readonly createRiskMeasureMapping: { + readonly success: boolean; + }; +}; +export type MeasureLinkDialogCreateMutation = { + response: MeasureLinkDialogCreateMutation$data; + variables: MeasureLinkDialogCreateMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "CreateRiskMeasureMappingPayload", + "kind": "LinkedField", + "name": "createRiskMeasureMapping", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "success", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "MeasureLinkDialogCreateMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "MeasureLinkDialogCreateMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "f7117ee68c2d7b6c2ceca56c054a4892", + "id": null, + "metadata": {}, + "name": "MeasureLinkDialogCreateMutation", + "operationKind": "mutation", + "text": "mutation MeasureLinkDialogCreateMutation(\n $input: CreateRiskMeasureMappingInput!\n) {\n createRiskMeasureMapping(input: $input) {\n success\n }\n}\n" + } +}; +})(); + +(node as any).hash = "b54abd7d4d4f88ff7d54bfa35121a015"; + +export default node; diff --git a/apps/console2/src/components/risks/__generated__/MeasureLinkDialogQuery.graphql.ts b/apps/console2/src/components/risks/__generated__/MeasureLinkDialogQuery.graphql.ts new file mode 100644 index 000000000..b3a5b7422 --- /dev/null +++ b/apps/console2/src/components/risks/__generated__/MeasureLinkDialogQuery.graphql.ts @@ -0,0 +1,271 @@ +/** + * @generated SignedSource<<746421c9f8fc0c80ed1fd5643b3bbae8>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED" | "%future added value"; +export type MeasureLinkDialogQuery$variables = { + organizationId: string; +}; +export type MeasureLinkDialogQuery$data = { + readonly organization: { + readonly id: string; + readonly measures?: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly category: string; + readonly description: string; + readonly id: string; + readonly name: string; + readonly state: MeasureState; + }; + }>; + }; + }; +}; +export type MeasureLinkDialogQuery = { + response: MeasureLinkDialogQuery$data; + variables: MeasureLinkDialogQuery$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 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v4 = [ + { + "alias": null, + "args": null, + "concreteType": "MeasureEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Measure", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: 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": "state", + "storageKey": null + }, + (v3/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } +], +v5 = [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "MeasureLinkDialogQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": "measures", + "args": null, + "concreteType": "MeasureConnection", + "kind": "LinkedField", + "name": "__Organization__measures_connection", + "plural": false, + "selections": (v4/*: any*/), + "storageKey": null + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "MeasureLinkDialogQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v3/*: any*/), + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": (v5/*: any*/), + "concreteType": "MeasureConnection", + "kind": "LinkedField", + "name": "measures", + "plural": false, + "selections": (v4/*: any*/), + "storageKey": "measures(first:100)" + }, + { + "alias": null, + "args": (v5/*: any*/), + "filters": null, + "handle": "connection", + "key": "Organization__measures", + "kind": "LinkedHandle", + "name": "measures" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "13eb493b76ecc90bc9dd27963b5dba84", + "id": null, + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "forward", + "path": [ + "organization", + "measures" + ] + } + ] + }, + "name": "MeasureLinkDialogQuery", + "operationKind": "query", + "text": "query MeasureLinkDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n measures(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "468d35ade53ae7655cd0fbab242b7d1a"; + +export default node; diff --git a/apps/console2/src/pages/organizations/risks/forms/__generated__/useRiskFormFragment.graphql.ts b/apps/console2/src/hooks/forms/__generated__/useRiskFormFragment.graphql.ts similarity index 100% rename from apps/console2/src/pages/organizations/risks/forms/__generated__/useRiskFormFragment.graphql.ts rename to apps/console2/src/hooks/forms/__generated__/useRiskFormFragment.graphql.ts diff --git a/apps/console2/src/pages/organizations/risks/forms/useRiskForm.tsx b/apps/console2/src/hooks/forms/useRiskForm.tsx similarity index 95% rename from apps/console2/src/pages/organizations/risks/forms/useRiskForm.tsx rename to apps/console2/src/hooks/forms/useRiskForm.tsx index 8a6da5f79..8a5658172 100644 --- a/apps/console2/src/pages/organizations/risks/forms/useRiskForm.tsx +++ b/apps/console2/src/hooks/forms/useRiskForm.tsx @@ -1,5 +1,5 @@ import { z } from "zod"; -import { useFormWithSchema } from "../../../../hooks/useFormWithSchema"; +import { useFormWithSchema } from "../useFormWithSchema"; import { graphql } from "relay-runtime"; import type { useRiskFormFragment$data, diff --git a/apps/console2/src/hooks/graph/RiskGraph.ts b/apps/console2/src/hooks/graph/RiskGraph.ts index 107b0afc7..c0f4dbbb3 100644 --- a/apps/console2/src/hooks/graph/RiskGraph.ts +++ b/apps/console2/src/hooks/graph/RiskGraph.ts @@ -110,6 +110,7 @@ export const riskNodeQuery = graphql` note ...useRiskFormFragment ...RiskOverviewTabFragment + ...RiskMeasuresTabFragment } } } diff --git a/apps/console2/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts b/apps/console2/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts index d11ee84f8..2e977e063 100644 --- a/apps/console2/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts +++ b/apps/console2/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<6efbbaf2cb9056e26a38c89835260547>> + * @generated SignedSource<<3485aabb6e5e1992e2f0c089deaf4b2d>> * @lightSyntaxTransform * @nogrep */ @@ -24,7 +24,7 @@ export type RiskGraphNodeQuery$data = { readonly id: string; } | null | undefined; readonly treatment?: RiskTreatment; - readonly " $fragmentSpreads": FragmentRefs<"RiskOverviewTabFragment" | "useRiskFormFragment">; + readonly " $fragmentSpreads": FragmentRefs<"RiskMeasuresTabFragment" | "RiskOverviewTabFragment" | "useRiskFormFragment">; }; }; export type RiskGraphNodeQuery = { @@ -100,7 +100,28 @@ v7 = { "kind": "ScalarField", "name": "note", "storageKey": null -}; +}, +v8 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v9 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "category", + "storageKey": null +}, +v10 = [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } +]; return { "fragment": { "argumentDefinitions": (v0/*: any*/), @@ -133,6 +154,11 @@ return { "args": null, "kind": "FragmentSpread", "name": "RiskOverviewTabFragment" + }, + { + "args": null, + "kind": "FragmentSpread", + "name": "RiskMeasuresTabFragment" } ], "type": "Risk", @@ -159,13 +185,7 @@ return { "name": "node", "plural": false, "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "__typename", - "storageKey": null - }, + (v8/*: any*/), (v5/*: any*/), { "kind": "InlineFragment", @@ -175,13 +195,7 @@ return { (v4/*: any*/), (v6/*: any*/), (v7/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "category", - "storageKey": null - }, + (v9/*: any*/), { "alias": null, "args": null, @@ -223,6 +237,111 @@ return { "kind": "ScalarField", "name": "residualRiskScore", "storageKey": null + }, + { + "alias": null, + "args": (v10/*: any*/), + "concreteType": "MeasureConnection", + "kind": "LinkedField", + "name": "measures", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "MeasureEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Measure", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v5/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + (v9/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, + (v8/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": "measures(first:100)" + }, + { + "alias": null, + "args": (v10/*: any*/), + "filters": null, + "handle": "connection", + "key": "Risk__measures", + "kind": "LinkedHandle", + "name": "measures" } ], "type": "Risk", @@ -234,16 +353,16 @@ return { ] }, "params": { - "cacheID": "f3e35dd3d4ea0933858541184c664b4d", + "cacheID": "fc9da3522dfacf8b5f887a0ebbd3e21e", "id": null, "metadata": {}, "name": "RiskGraphNodeQuery", "operationKind": "query", - "text": "query RiskGraphNodeQuery(\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 note\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n }\n id\n }\n}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\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" + "text": "query RiskGraphNodeQuery(\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 note\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n ...RiskMeasuresTabFragment\n }\n id\n }\n}\n\nfragment RiskMeasuresTabFragment on Risk {\n id\n measures(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n createdAt\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\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 = "76afe5fc70188381350154bdba7aa066"; +(node as any).hash = "c742a5b3e536eabc8dca8b680f763a04"; export default node; diff --git a/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx b/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx index 6c0f91f77..1911612e9 100644 --- a/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx +++ b/apps/console2/src/pages/organizations/risks/FormRiskDialog.tsx @@ -24,7 +24,11 @@ import { ControlledField, ControlledSelect, } from "../../../components/form/ControlledField"; -import { useRiskForm, type RiskForm, type RiskKey } from "./forms/useRiskForm"; +import { + useRiskForm, + type RiskForm, + type RiskKey, +} from "../../../hooks/forms/useRiskForm"; import type { FieldErrors } from "react-hook-form"; import { useMutationWithToasts } from "../../../hooks/useMutationWithToasts"; import type { FormRiskDialogMutation } from "./__generated__/FormRiskDialogMutation.graphql"; diff --git a/apps/console2/src/pages/organizations/risks/RiskDetailPage.tsx b/apps/console2/src/pages/organizations/risks/RiskDetailPage.tsx index 608f60667..93c779160 100644 --- a/apps/console2/src/pages/organizations/risks/RiskDetailPage.tsx +++ b/apps/console2/src/pages/organizations/risks/RiskDetailPage.tsx @@ -11,6 +11,8 @@ import { IconTrashCan, PageHeader, PropertyRow, + TabLink, + Tabs, } from "@probo/ui"; import { Outlet, useNavigate, useParams } from "react-router"; import { useTranslate } from "@probo/i18n"; @@ -107,6 +109,19 @@ export default function RiskDetailPage(props: Props) { + + + {__("Overview")} + + + {__("Measures")} + + + diff --git a/apps/console2/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx b/apps/console2/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx new file mode 100644 index 000000000..63fb06a6f --- /dev/null +++ b/apps/console2/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx @@ -0,0 +1,104 @@ +import { graphql, useFragment } from "react-relay"; +import type { + RiskMeasuresTabFragment$data, + RiskMeasuresTabFragment$key, +} from "./__generated__/RiskMeasuresTabFragment.graphql"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + IconPlusLarge, + Table, + Thead, + Tbody, + Td, + Th, + Tr, +} from "@probo/ui"; +import { MeasureLinkDialog } from "../../../../components/risks/MeasureLinkDialog"; +import { useOrganizationId } from "../../../../hooks/useOrganizationId"; +import { useOutletContext } from "react-router"; +import type { NodeOf } from "../../../../types"; + +const measuresFragment = graphql` + fragment RiskMeasuresTabFragment on Risk { + id + measures(first: 100) @connection(key: "Risk__measures") { + __id + edges { + node { + id + name + description + category + createdAt + state + } + } + } + } +`; + +export default function RiskMeasuresTab() { + const { risk } = useOutletContext<{ + risk: RiskMeasuresTabFragment$key & { id: string }; + }>(); + const data = useFragment(measuresFragment, risk); + const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + const connectionId = data.measures.__id; + const measures = data.measures?.edges?.map((edge) => edge.node) ?? []; + + if (measures.length === 0) { + return ( +
+ {__("No measures associated with this risk.")} + + {__("Link measure")} + + } + /> +
+ ); + } + + return ( + + + + + + + + + + + + + {measures.map((measure) => ( + + ))} + +
{__("Measure")}{__("Frameworks")}{__("Risks")}{__("Lead")}{__("Status")}
+ ); +} + +function MeasureRow({ + measure, +}: { + measure: NodeOf; +}) { + return ( + + {measure.name} + {measure.category} + {measure.createdAt} + {measure.state} + + + ); +} diff --git a/apps/console2/src/pages/organizations/risks/RiskOverviewTab.tsx b/apps/console2/src/pages/organizations/risks/tabs/RiskOverviewTab.tsx similarity index 100% rename from apps/console2/src/pages/organizations/risks/RiskOverviewTab.tsx rename to apps/console2/src/pages/organizations/risks/tabs/RiskOverviewTab.tsx diff --git a/apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskMeasuresTabFragment.graphql.ts b/apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskMeasuresTabFragment.graphql.ts new file mode 100644 index 000000000..2bd1467df --- /dev/null +++ b/apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskMeasuresTabFragment.graphql.ts @@ -0,0 +1,190 @@ +/** + * @generated SignedSource<<83a07ae53aaa5005db49f5ea8b4479c6>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED" | "%future added value"; +import { FragmentRefs } from "relay-runtime"; +export type RiskMeasuresTabFragment$data = { + readonly id: string; + readonly measures: { + readonly __id: string; + readonly edges: ReadonlyArray<{ + readonly node: { + readonly category: string; + readonly createdAt: any; + readonly description: string; + readonly id: string; + readonly name: string; + readonly state: MeasureState; + }; + }>; + }; + readonly " $fragmentType": "RiskMeasuresTabFragment"; +}; +export type RiskMeasuresTabFragment$key = { + readonly " $data"?: RiskMeasuresTabFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"RiskMeasuresTabFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "forward", + "path": [ + "measures" + ] + } + ] + }, + "name": "RiskMeasuresTabFragment", + "selections": [ + (v0/*: any*/), + { + "alias": "measures", + "args": null, + "concreteType": "MeasureConnection", + "kind": "LinkedField", + "name": "__Risk__measures_connection", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "MeasureEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Measure", + "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": "description", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "category", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": null + } + ], + "type": "Risk", + "abstractKey": null +}; +})(); + +(node as any).hash = "e04c5962a40b48aade0d1e4a31f0c583"; + +export default node; diff --git a/apps/console2/src/pages/organizations/risks/__generated__/RiskOverviewTabFragment.graphql.ts b/apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskOverviewTabFragment.graphql.ts similarity index 100% rename from apps/console2/src/pages/organizations/risks/__generated__/RiskOverviewTabFragment.graphql.ts rename to apps/console2/src/pages/organizations/risks/tabs/__generated__/RiskOverviewTabFragment.graphql.ts diff --git a/apps/console2/src/routes.tsx b/apps/console2/src/routes.tsx index 75fb8dc41..999756c3a 100644 --- a/apps/console2/src/routes.tsx +++ b/apps/console2/src/routes.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter, + redirect, useLoaderData, useRouteError, type RouteObject, @@ -7,9 +8,9 @@ import { import { MainLayout } from "./layouts/MainLayout"; import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; import { + Fragment, lazy, Suspense, - useEffect, type FC, type LazyExoticComponent, } from "react"; @@ -22,6 +23,7 @@ import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx"; import { loadQuery, type PreloadedQuery } from "react-relay"; import { riskNodeQuery, risksQuery } from "./hooks/graph/RiskGraph.ts"; import { useCleanup } from "./hooks/useDelayedEffect.ts"; +import { riskRoutes } from "./routes/riskRoutes.ts"; function ErrorBoundary() { const error = useRouteError(); @@ -32,9 +34,9 @@ function ErrorBoundary() { return
error
; } -type Route = { +export type AppRoute = { Component: FC | LazyExoticComponent>; - children?: Route[]; + children?: AppRoute[]; fallback?: FC; queryLoader?: (params: Record) => PreloadedQuery; } & Omit; @@ -62,7 +64,7 @@ const routes = [ { path: "organizations/new", Component: lazy( - () => import("./pages/organizations/NewOrganizationPage"), + () => import("./pages/organizations/NewOrganizationPage") ), }, ], @@ -76,45 +78,22 @@ const routes = [ path: "vendors", fallback: PageSkeleton, Component: lazy( - () => import("./pages/organizations/vendors/VendorsPage"), + () => import("./pages/organizations/vendors/VendorsPage") ), }, - { - path: "risks", - fallback: RisksPageSkeleton, - queryLoader: ({ organizationId }) => - loadQuery(relayEnvironment, risksQuery, { organizationId }), - Component: lazy(() => import("./pages/organizations/risks/RisksPage")), - }, - { - path: "risks/:riskId", - fallback: PageSkeleton, - queryLoader: ({ riskId }) => - loadQuery(relayEnvironment, riskNodeQuery, { riskId }), - Component: lazy( - () => import("./pages/organizations/risks/RiskDetailPage"), - ), - children: [ - { - path: "", - Component: lazy( - () => import("./pages/organizations/risks/RiskOverviewTab"), - ), - }, - ], - }, + ...riskRoutes, ], }, -] satisfies Route[]; +] satisfies AppRoute[]; /** - * Wrap component with a suspense to handle lazy loading & relay loading states + * Wrap components with suspense to handle lazy loading & relay loading states */ function routeTransformer({ fallback: FallbackComponent, queryLoader, ...route -}: Route): RouteObject { +}: AppRoute): RouteObject { let result = { ...route }; if (FallbackComponent) { result = { @@ -133,10 +112,7 @@ function routeTransformer({ const query = queryLoader(params as Record); return { queryRef: query, - dispose: () => { - console.log("cleaning up query"); - query.dispose(); - }, + dispose: query.dispose, }; }, Component: () => { diff --git a/apps/console2/src/routes/riskRoutes.ts b/apps/console2/src/routes/riskRoutes.ts new file mode 100644 index 000000000..740378cc6 --- /dev/null +++ b/apps/console2/src/routes/riskRoutes.ts @@ -0,0 +1,48 @@ +import { Fragment, lazy } from "react"; +import { loadQuery } from "react-relay"; +import { RisksPageSkeleton } from "../components/skeletons/RisksPageSkeleton.tsx"; +import type { AppRoute } from "../routes.tsx"; +import { relayEnvironment } from "../providers/RelayProviders"; +import { riskNodeQuery, risksQuery } from "../hooks/graph/RiskGraph"; +import { PageSkeleton } from "../components/skeletons/PageSkeleton.tsx"; +import { redirect } from "react-router"; + +export const riskRoutes = [ + { + path: "risks", + fallback: RisksPageSkeleton, + queryLoader: ({ organizationId }) => + loadQuery(relayEnvironment, risksQuery, { organizationId }), + Component: lazy(() => import("../pages/organizations/risks/RisksPage")), + }, + { + path: "risks/:riskId", + fallback: PageSkeleton, + queryLoader: ({ riskId }) => + loadQuery(relayEnvironment, riskNodeQuery, { riskId }), + Component: lazy( + () => import("../pages/organizations/risks/RiskDetailPage") + ), + children: [ + { + path: "", + loader: () => { + throw redirect("overview"); + }, + Component: Fragment, + }, + { + path: "overview", + Component: lazy( + () => import("../pages/organizations/risks/tabs/RiskOverviewTab.tsx") + ), + }, + { + path: "measures", + Component: lazy( + () => import("../pages/organizations/risks/tabs/RiskMeasuresTab.tsx") + ), + }, + ], + }, +] satisfies AppRoute[]; diff --git a/packages/ui/package.json b/packages/ui/package.json index 58275cb81..2504f127a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-portal": "^1.1.9", "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-tabs": "^1.1.12", "@tailwindcss/vite": "^4.1.7", "clsx": "^2.1.1", "tailwind-variants": "^1.0.0", diff --git a/packages/ui/src/Atoms/Badge/Badge.tsx b/packages/ui/src/Atoms/Badge/Badge.tsx index d8cea60dd..8492086a1 100644 --- a/packages/ui/src/Atoms/Badge/Badge.tsx +++ b/packages/ui/src/Atoms/Badge/Badge.tsx @@ -31,6 +31,7 @@ const badge = tv({ }, }, defaultVariants: { + variant: "neutral", size: "sm", }, }); diff --git a/packages/ui/src/Atoms/Input/Input.tsx b/packages/ui/src/Atoms/Input/Input.tsx index a8284fd38..fc78b2f66 100644 --- a/packages/ui/src/Atoms/Input/Input.tsx +++ b/packages/ui/src/Atoms/Input/Input.tsx @@ -1,13 +1,16 @@ -import type { InputHTMLAttributes } from "react"; +import { type InputHTMLAttributes, type FC, useState } from "react"; import { tv } from "tailwind-variants"; +import type { IconProps } from "../Icons/type"; type Props = { invalid?: boolean; disabled?: boolean; + icon?: FC; + onValueChange?: (value: string) => void; } & InputHTMLAttributes; export const input = tv({ - base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent focus:outline-none", + base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent focus:outline-none data-[focus=true]:shadow-focus", variants: { invalid: { true: "border-border-danger", @@ -15,6 +18,41 @@ export const input = tv({ }, }); -export function Input({ invalid, ...props }: Props) { +export function Input({ + invalid, + icon: IconComponent, + onValueChange, + ...props +}: Props) { + if (IconComponent) { + const [focus, setFocus] = useState(false); + return ( +
+ + { + setFocus(true); + props.onFocus?.(e); + }} + onBlur={(e) => { + setFocus(false); + props.onBlur?.(e); + }} + aria-invalid={invalid} + className="w-full outline-none" + {...props} + onChange={(e) => { + onValueChange?.(e.currentTarget.value); + props.onChange?.(e); + }} + /> +
+ ); + } return ; } diff --git a/packages/ui/src/Atoms/Spinner/Spinner.tsx b/packages/ui/src/Atoms/Spinner/Spinner.tsx index 0cfa047d7..f0ec16424 100644 --- a/packages/ui/src/Atoms/Spinner/Spinner.tsx +++ b/packages/ui/src/Atoms/Spinner/Spinner.tsx @@ -1,11 +1,15 @@ import clsx from "clsx"; -type Props = { size: number; className?: string }; +type Props = { size?: number; className?: string; centered?: boolean }; -export function Spinner({ size = 16, className }: Props) { +export function Spinner({ size = 16, className, centered }: Props) { return (
); diff --git a/packages/ui/src/Atoms/Tabs/Tabs.stories.tsx b/packages/ui/src/Atoms/Tabs/Tabs.stories.tsx new file mode 100644 index 000000000..348be5a8e --- /dev/null +++ b/packages/ui/src/Atoms/Tabs/Tabs.stories.tsx @@ -0,0 +1,20 @@ +import { Tabs, TabLink } from "./Tabs"; +import type { Meta, StoryObj } from "@storybook/react"; + +export default { + title: "Atoms/Tabs", + component: Tabs, + argTypes: {}, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Tab 1 + Tab 2 + Tab 3 + + ), +}; diff --git a/packages/ui/src/Atoms/Tabs/Tabs.tsx b/packages/ui/src/Atoms/Tabs/Tabs.tsx new file mode 100644 index 000000000..bc781ddf9 --- /dev/null +++ b/packages/ui/src/Atoms/Tabs/Tabs.tsx @@ -0,0 +1,33 @@ +import type { PropsWithChildren } from "react"; +import { NavLink } from "react-router"; +import { Root, List } from "@radix-ui/react-tabs"; +import clsx from "clsx"; + +export function Tabs(props: PropsWithChildren) { + return ( + + +
+ + + ); +} + +export function TabLink(props: PropsWithChildren<{ to: string }>) { + return ( + + clsx( + "py-4 hover:text-txt-primary border-b-2 active:border-border-active -mb-[1px] active:text-txt-primary", + params.isActive + ? "border-border-active text-txt-primary" + : "border-transparent", + ) + } + {...props} + /> + ); +} diff --git a/packages/ui/src/Molecules/Dialog/Dialog.tsx b/packages/ui/src/Molecules/Dialog/Dialog.tsx index aac6d0fbf..20638448e 100644 --- a/packages/ui/src/Molecules/Dialog/Dialog.tsx +++ b/packages/ui/src/Molecules/Dialog/Dialog.tsx @@ -70,23 +70,35 @@ export function Dialog({ ); } -export function DialogFooter({ children }: { children?: ReactNode }) { +export function DialogFooter({ + children, + exitLabel, +}: { + children?: ReactNode; + exitLabel?: string; +}) { const { __ } = useTranslate(); return (
- + {children}
); } -export function DialogContent(props: HTMLAttributes) { +export function DialogContent( + props: HTMLAttributes & { padded?: boolean }, +) { return (