From fc45d2f00d81d74947137cbd105fc77ab2fa61ed Mon Sep 17 00:00:00 2001
From: Jonathan
Date: Thu, 22 May 2025 12:40:12 +0200
Subject: [PATCH] Add risk forms
Signed-off-by: Bryan Frimin
Signed-off-by: Sacha Al Himdani
---
apps/console2/package.json | 1 +
.../src/components/form/ControlledField.tsx | 2 +-
.../form/{UserSelect.tsx => PeopleSelect.tsx} | 58 +--
.../PeopleSelectQuery.graphql.ts | 180 +++++++
.../src/hooks/useMutationWithToasts.ts | 57 +++
apps/console2/src/pages/OrganizationsPage.tsx | 2 +-
apps/console2/src/pages/auth/LoginPage.tsx | 6 +-
.../organizations/NewOrganizationPage.tsx | 4 +-
.../{NewRiskDialog.tsx => FormRiskDialog.tsx} | 142 +++---
.../pages/organizations/risks/RisksPage.tsx | 182 +++++++-
.../FormRiskDialogMutation.graphql.ts | 271 +++++++++++
...ormRiskDialogUpdateRiskMutation.graphql.ts | 221 +++++++++
.../RisksPageDeleteMutation.graphql.ts | 132 ++++++
.../__generated__/RisksPageQuery.graphql.ts | 441 ++++++++++++++++++
.../RisksPageUpdateRiskMutation.graphql.ts | 220 +++++++++
.../useRiskFormFragment.graphql.ts | 133 ++++++
.../organizations/risks/forms/useRiskForm.tsx | 81 +++-
apps/console2/src/routes.tsx | 3 +-
apps/console2/src/types.ts | 7 +
bun.lock | 24 +-
packages/helpers/src/index.ts | 1 +
packages/helpers/src/string.test.ts | 10 +
packages/helpers/src/string.ts | 9 +
packages/i18n/package.json | 4 +-
packages/ui/package.json | 1 +
packages/ui/src/Atoms/Badge/Badge.stories.tsx | 31 ++
packages/ui/src/Atoms/Badge/Badge.tsx | 24 +
packages/ui/src/Atoms/Dropdown/Dropdown.tsx | 20 +-
packages/ui/src/Atoms/Select/Select.tsx | 5 +-
packages/ui/src/Atoms/Table/Table.stories.tsx | 12 +
packages/ui/src/Atoms/Table/Table.tsx | 64 +++
.../src/Molecules/Badge/RiskBadge.stories.tsx | 20 +
packages/ui/src/Molecules/Badge/RiskBadge.tsx | 30 ++
.../Molecules/Badge/SeverityBadge.stories.tsx | 21 +
.../ui/src/Molecules/Badge/SeverityBadge.tsx | 36 ++
.../Dialog/ConfirmDialog.stories.tsx | 28 ++
.../ui/src/Molecules/Dialog/ConfirmDialog.tsx | 66 +++
packages/ui/src/Molecules/Dialog/Dialog.tsx | 4 +-
packages/ui/src/Molecules/Field/Field.tsx | 2 +
packages/ui/src/index.ts | 5 +
packages/ui/tsconfig.app.json | 2 +-
41 files changed, 2426 insertions(+), 136 deletions(-)
rename apps/console2/src/components/form/{UserSelect.tsx => PeopleSelect.tsx} (50%)
create mode 100644 apps/console2/src/components/form/__generated__/PeopleSelectQuery.graphql.ts
create mode 100644 apps/console2/src/hooks/useMutationWithToasts.ts
rename apps/console2/src/pages/organizations/risks/{NewRiskDialog.tsx => FormRiskDialog.tsx} (72%)
create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/FormRiskDialogMutation.graphql.ts
create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/FormRiskDialogUpdateRiskMutation.graphql.ts
create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/RisksPageDeleteMutation.graphql.ts
create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/RisksPageQuery.graphql.ts
create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/RisksPageUpdateRiskMutation.graphql.ts
create mode 100644 apps/console2/src/pages/organizations/risks/forms/__generated__/useRiskFormFragment.graphql.ts
create mode 100644 apps/console2/src/types.ts
create mode 100644 packages/helpers/src/string.test.ts
create mode 100644 packages/helpers/src/string.ts
create mode 100644 packages/ui/src/Atoms/Badge/Badge.stories.tsx
create mode 100644 packages/ui/src/Atoms/Badge/Badge.tsx
create mode 100644 packages/ui/src/Atoms/Table/Table.stories.tsx
create mode 100644 packages/ui/src/Atoms/Table/Table.tsx
create mode 100644 packages/ui/src/Molecules/Badge/RiskBadge.stories.tsx
create mode 100644 packages/ui/src/Molecules/Badge/RiskBadge.tsx
create mode 100644 packages/ui/src/Molecules/Badge/SeverityBadge.stories.tsx
create mode 100644 packages/ui/src/Molecules/Badge/SeverityBadge.tsx
create mode 100644 packages/ui/src/Molecules/Dialog/ConfirmDialog.stories.tsx
create mode 100644 packages/ui/src/Molecules/Dialog/ConfirmDialog.tsx
diff --git a/apps/console2/package.json b/apps/console2/package.json
index f5e733661..1ae3cbcf2 100644
--- a/apps/console2/package.json
+++ b/apps/console2/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@probo/hooks": "1.0.0",
+ "@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@probo/ui": "1.0.0",
"@radix-ui/react-dialog": "^1.1.14",
diff --git a/apps/console2/src/components/form/ControlledField.tsx b/apps/console2/src/components/form/ControlledField.tsx
index 9c967fda1..1dd316b10 100644
--- a/apps/console2/src/components/form/ControlledField.tsx
+++ b/apps/console2/src/components/form/ControlledField.tsx
@@ -1,4 +1,4 @@
-import type { ComponentProps, JSXElementConstructor } from "react";
+import type { ComponentProps, JSX, JSXElementConstructor } from "react";
import { Field } from "@probo/ui";
import { Controller, type Control } from "react-hook-form";
import { Select } from "@probo/ui";
diff --git a/apps/console2/src/components/form/UserSelect.tsx b/apps/console2/src/components/form/PeopleSelect.tsx
similarity index 50%
rename from apps/console2/src/components/form/UserSelect.tsx
rename to apps/console2/src/components/form/PeopleSelect.tsx
index 357d290cb..477cd5adb 100644
--- a/apps/console2/src/components/form/UserSelect.tsx
+++ b/apps/console2/src/components/form/PeopleSelect.tsx
@@ -2,15 +2,15 @@ import { Avatar, Option, Select } from "@probo/ui";
import { Suspense } from "react";
import { useLazyLoadQuery } from "react-relay";
import { graphql } from "relay-runtime";
-import type { UserSelectQuery as UserSelectQueryType } from "./__generated__/UserSelectQuery.graphql";
+import type { PeopleSelectQuery as PeopleSelectQueryType } from "./__generated__/PeopleSelectQuery.graphql";
import { useTranslate } from "@probo/i18n";
import { Controller, type Control } from "react-hook-form";
-const usersQuery = graphql`
- query UserSelectQuery($organizationId: ID!) {
+const peopleQuery = graphql`
+ query PeopleSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
- users(first: 100, orderBy: { direction: ASC, field: CREATED_AT }) {
+ peoples(first: 100, orderBy: { direction: ASC, field: CREATED_AT }) {
edges {
node {
id
@@ -23,7 +23,7 @@ const usersQuery = graphql`
}
`;
-export function UserSelect({
+export function PeopleSelect({
organization,
name,
control,
@@ -36,7 +36,7 @@ export function UserSelect({
}
>
- (usersQuery, {
+ const data = useLazyLoadQuery(peopleQuery, {
organizationId: organization,
});
- const users = data.organization?.users?.edges.map((edge) => edge.node);
+ const people = data.organization?.peoples?.edges.map((edge) => edge.node);
return (
- (
-
- )}
- />
+ <>
+ (
+
+ )}
+ />
+ >
);
}
diff --git a/apps/console2/src/components/form/__generated__/PeopleSelectQuery.graphql.ts b/apps/console2/src/components/form/__generated__/PeopleSelectQuery.graphql.ts
new file mode 100644
index 000000000..24b0fbc67
--- /dev/null
+++ b/apps/console2/src/components/form/__generated__/PeopleSelectQuery.graphql.ts
@@ -0,0 +1,180 @@
+/**
+ * @generated SignedSource<<758e3ece2d7d1d52975886d41b6ae905>>
+ * @lightSyntaxTransform
+ * @nogrep
+ */
+
+/* tslint:disable */
+/* eslint-disable */
+// @ts-nocheck
+
+import { ConcreteRequest } from 'relay-runtime';
+export type PeopleSelectQuery$variables = {
+ organizationId: string;
+};
+export type PeopleSelectQuery$data = {
+ readonly organization: {
+ readonly peoples?: {
+ readonly edges: ReadonlyArray<{
+ readonly node: {
+ readonly fullName: string;
+ readonly id: string;
+ };
+ }>;
+ };
+ };
+};
+export type PeopleSelectQuery = {
+ response: PeopleSelectQuery$data;
+ variables: PeopleSelectQuery$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": "PeopleConnection",
+ "kind": "LinkedField",
+ "name": "peoples",
+ "plural": false,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "PeopleEdge",
+ "kind": "LinkedField",
+ "name": "edges",
+ "plural": true,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "People",
+ "kind": "LinkedField",
+ "name": "node",
+ "plural": false,
+ "selections": [
+ (v2/*: any*/),
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "fullName",
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": "peoples(first:100,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
+ }
+ ],
+ "type": "Organization",
+ "abstractKey": null
+};
+return {
+ "fragment": {
+ "argumentDefinitions": (v0/*: any*/),
+ "kind": "Fragment",
+ "metadata": null,
+ "name": "PeopleSelectQuery",
+ "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": "PeopleSelectQuery",
+ "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": "4e58fc42b352af93ba7067921531af3e",
+ "id": null,
+ "metadata": {},
+ "name": "PeopleSelectQuery",
+ "operationKind": "query",
+ "text": "query PeopleSelectQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n peoples(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 = "77119544e99cf9dd74d8a0f446df108e";
+
+export default node;
diff --git a/apps/console2/src/hooks/useMutationWithToasts.ts b/apps/console2/src/hooks/useMutationWithToasts.ts
new file mode 100644
index 000000000..3a5325612
--- /dev/null
+++ b/apps/console2/src/hooks/useMutationWithToasts.ts
@@ -0,0 +1,57 @@
+import { useCallback } from "react";
+import { useMutation, type UseMutationConfig } from "react-relay";
+import { useToast } from "@probo/ui";
+import { useTranslate } from "@probo/i18n";
+import type { MutationParameters, GraphQLTaggedNode } from "relay-runtime";
+
+/**
+ * A decorated useMutation hook that emits toast notifications on success or error.
+ */
+export function useMutationWithToasts(
+ query: GraphQLTaggedNode
+) {
+ const [mutate, isLoading] = useMutation(query);
+ const { toast } = useToast();
+ const { __ } = useTranslate();
+ const mutateWithToast = useCallback(
+ (
+ options: UseMutationConfig & {
+ onSuccess?: () => void;
+ successMessage: string;
+ errorMessage?: string;
+ }
+ ) => {
+ mutate({
+ ...options,
+ onCompleted: (_, error) => {
+ if (error) {
+ toast({
+ title: __("Error"),
+ description:
+ options.errorMessage ?? __("Failed to commit this operation."),
+ variant: "error",
+ });
+ return;
+ }
+ toast({
+ title: __("Success"),
+ description: options.successMessage,
+ variant: "success",
+ });
+ options.onSuccess?.();
+ },
+ onError: () => {
+ toast({
+ title: __("Error"),
+ description:
+ options.errorMessage ?? __("Failed to commit this operation."),
+ variant: "error",
+ });
+ },
+ });
+ },
+ [mutate]
+ );
+
+ return [mutateWithToast, isLoading] as const;
+}
diff --git a/apps/console2/src/pages/OrganizationsPage.tsx b/apps/console2/src/pages/OrganizationsPage.tsx
index 6d9361b9d..7f43814d8 100644
--- a/apps/console2/src/pages/OrganizationsPage.tsx
+++ b/apps/console2/src/pages/OrganizationsPage.tsx
@@ -37,7 +37,7 @@ export default function OrganizationsPage() {
// Redirect to the first organization if only one exists
useEffect(() => {
if (organizations.length === 1) {
- // navigate(`/organizations/${organizations[0].id}`);
+ navigate(`/organizations/${organizations[0].id}`);
}
}, [organizations]);
diff --git a/apps/console2/src/pages/auth/LoginPage.tsx b/apps/console2/src/pages/auth/LoginPage.tsx
index 84c6f996c..16468cc1c 100644
--- a/apps/console2/src/pages/auth/LoginPage.tsx
+++ b/apps/console2/src/pages/auth/LoginPage.tsx
@@ -1,5 +1,5 @@
import { useTranslate } from "@probo/i18n";
-import { Button, Input, useToast } from "@probo/ui";
+import { Button, Field, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
import { Link, useNavigate } from "react-router";
@@ -46,14 +46,14 @@ export default function LoginPage() {
{__("Enter your email below to login to your account")}
-
-
{__("Enter the basic information about your organization.")}
- void;
};
-type Risk = {
+type RiskTemplate = {
category: string;
name: string;
description: string;
};
const createRiskMutation = graphql`
- mutation NewRiskDialogMutation(
+ mutation FormRiskDialogMutation(
$input: CreateRiskInput!
$connections: [ID!]!
) {
createRisk(input: $input) {
riskEdge @prependEdge(connections: $connections) {
node {
- id
- name
- description
- category
- inherentLikelihood
- inherentImpact
- residualLikelihood
- residualImpact
- treatment
- createdAt
- updatedAt
+ ...useRiskFormFragment
}
}
}
}
`;
-export default function NewRiskDialog({ trigger }: Props) {
+const updateRiskMutation = graphql`
+ mutation FormRiskDialogUpdateRiskMutation($input: UpdateRiskInput!) {
+ updateRisk(input: $input) {
+ risk {
+ ...useRiskFormFragment
+ }
+ }
+ }
+`;
+
+/**
+ * Dialog to create or update a risk
+ */
+export default function FormRiskDialog({ trigger, risk, onSuccess }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { control, handleSubmit, setValue, register, watch, formState } =
- useRiskForm(organizationId);
+ const { control, handleSubmit, setValue, register, watch, formState, reset } =
+ useRiskForm(risk);
const errors = formState.errors ?? {};
- const [createRisk, isLoading] = useMutation(createRiskMutation);
- const { toast } = useToast();
+ const [createRisk, isLoadingCreate] =
+ useMutationWithToasts(createRiskMutation);
+ const [updateRisk, isLoadingUpdate] =
+ useMutationWithToasts(updateRiskMutation);
+ const isLoading = isLoadingCreate || isLoadingUpdate;
- const onTemplateChange = (risk: Risk) => {
+ const onTemplateChange = (risk: RiskTemplate) => {
setValue("name", risk.name);
setValue("description", risk.description);
};
const onSubmit = handleSubmit((data) => {
+ if (risk) {
+ updateRisk({
+ variables: {
+ input: {
+ id: risk.id,
+ ...data,
+ },
+ },
+ successMessage: __("Risk updated successfully."),
+ errorMessage: __("Failed to update risk. Please try again."),
+ onSuccess: () => {
+ setOpen(false);
+ onSuccess?.();
+ },
+ });
+ return;
+ }
+ const connectionID = ConnectionHandler.getConnectionID(
+ organizationId,
+ "RisksPage_risks"
+ );
createRisk({
variables: {
- input: data,
+ input: {
+ ...data,
+ organizationId,
+ },
+ connections: [connectionID],
},
- onCompleted: (response, error) => {
- if (error) {
- toast({
- title: __("Error"),
- description: __("Failed to create risk. Please try again."),
- variant: "error",
- });
- return;
- }
-
- toast({
- title: __("Success"),
- description: __("Risk created successfully."),
- variant: "success",
- });
-
+ successMessage: __("Risk created successfully."),
+ errorMessage: __("Failed to create risk. Please try again."),
+ onSuccess: () => {
setOpen(false);
+ reset();
+ onSuccess?.();
},
});
});
const [showNote, toggleNote] = useToggle(false);
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(!!risk);
return (