Add measures tab

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-23 17:54:50 +02:00
committed by Sacha Al Himdani
parent fbfc3bb2b5
commit 1253145d3b
23 changed files with 1161 additions and 68 deletions

View File

@@ -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 (
<Dialog trigger={trigger} title={__("Manage Risk Measures")}>
<DialogContent className="px-6">
<p className="text-sm text-txt-secondary mt-6">
{__("Link or unlink measures to manage this risk.")}
</p>
<Suspense fallback={<Spinner centered />}>
<MeasureLinkDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function MeasureLinkDialogContent(props: Omit<Props, "trigger">) {
const data = useLazyLoadQuery<MeasureLinkDialogQuery>(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 (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={setSearch}
/>
</div>
<div className="space-y-2 divide-y divide-border-low">
{filteredMeasures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} {...props} />
))}
</div>
</>
);
}
type RowProps = {
measure: NodeOf<
Required<MeasureLinkDialogQuery$data["organization"]>["measures"]
>;
} & Omit<Props, "trigger">;
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 (
<div className="py-4 flex items-center gap-4 hover:bg-subtle">
{props.measure.name}
<Badge variant="neutral">{props.measure.category}</Badge>
<Button
disabled={isFetching}
icon={isLinked ? IconTrashCan : IconPlusLarge}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
onClick={onClick}
>
{isLinked ? __("Unlink") : __("Link")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,93 @@
/**
* @generated SignedSource<<bce124e2ad364bd742c5d2b2a92f768b>>
* @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;

View File

@@ -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;