From 3d4ecf5f8f4524c6fd19a2a707c58ebd8db798dc Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 21 May 2025 17:20:24 +0200 Subject: [PATCH] Add risk creation form Signed-off-by: Bryan Frimin Signed-off-by: Sacha Al Himdani --- apps/console2/package.json | 6 +- .../src/components/form/ControlledField.tsx | 47 ++++ .../src/components/form/UserSelect.tsx | 85 ++++++ .../__generated__/UserSelectQuery.graphql.ts | 180 +++++++++++++ apps/console2/src/hooks/useFormWithSchema.ts | 13 + apps/console2/src/hooks/useOrganizationId.ts | 9 + apps/console2/src/pages/OrganizationsPage.tsx | 12 +- .../OrganizationsPageQuery.graphql.ts | 150 +++++++++++ .../organizations/risks/NewRiskDialog.tsx | 243 +++++++++++++++-- .../NewRiskDialogMutation.graphql.ts | 249 ++++++++++++++++++ .../organizations/risks/forms/useRiskForm.tsx | 38 +++ bun.lock | 14 + package.json | 3 +- packages/hooks/src/index.ts | 1 + packages/hooks/src/useToggle.tsx | 7 + packages/ui/src/Atoms/Input/Input.stories.tsx | 6 + packages/ui/src/Atoms/Input/Input.tsx | 16 +- packages/ui/src/Atoms/Label/Label.tsx | 11 +- .../Atoms/PropertyRow/PropertyRow.stories.tsx | 33 +++ .../ui/src/Atoms/PropertyRow/PropertyRow.tsx | 29 ++ .../ui/src/Atoms/Select/Select.stories.tsx | 12 +- packages/ui/src/Atoms/Select/Select.tsx | 105 ++++++-- packages/ui/src/Atoms/Textarea/Textarea.tsx | 8 +- packages/ui/src/Atoms/Toasts/Toasts.tsx | 2 +- packages/ui/src/Molecules/Dialog/Dialog.tsx | 65 ++--- .../ui/src/Molecules/Field/Field.stories.tsx | 7 + packages/ui/src/Molecules/Field/Field.tsx | 93 +++++-- packages/ui/src/index.ts | 2 + turbo.json | 3 + 29 files changed, 1330 insertions(+), 119 deletions(-) create mode 100644 apps/console2/src/components/form/ControlledField.tsx create mode 100644 apps/console2/src/components/form/UserSelect.tsx create mode 100644 apps/console2/src/components/form/__generated__/UserSelectQuery.graphql.ts create mode 100644 apps/console2/src/hooks/useFormWithSchema.ts create mode 100644 apps/console2/src/hooks/useOrganizationId.ts create mode 100644 apps/console2/src/pages/__generated__/OrganizationsPageQuery.graphql.ts create mode 100644 apps/console2/src/pages/organizations/risks/__generated__/NewRiskDialogMutation.graphql.ts create mode 100644 apps/console2/src/pages/organizations/risks/forms/useRiskForm.tsx create mode 100644 packages/hooks/src/useToggle.tsx create mode 100644 packages/ui/src/Atoms/PropertyRow/PropertyRow.stories.tsx create mode 100644 packages/ui/src/Atoms/PropertyRow/PropertyRow.tsx diff --git a/apps/console2/package.json b/apps/console2/package.json index 35cd2369c..f5e733661 100644 --- a/apps/console2/package.json +++ b/apps/console2/package.json @@ -12,18 +12,22 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^5.0.1", "@probo/hooks": "1.0.0", "@probo/i18n": "1.0.0", "@probo/ui": "1.0.0", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@tanstack/react-query": "^5.76.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.56.4", "react-relay": "^19.0.0", "react-router": "^7.6.0", - "relay-runtime": "^19.0.0" + "relay-runtime": "^19.0.0", + "zod": "^3.25.17" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/apps/console2/src/components/form/ControlledField.tsx b/apps/console2/src/components/form/ControlledField.tsx new file mode 100644 index 000000000..9c967fda1 --- /dev/null +++ b/apps/console2/src/components/form/ControlledField.tsx @@ -0,0 +1,47 @@ +import type { ComponentProps, JSXElementConstructor } from "react"; +import { Field } from "@probo/ui"; +import { Controller, type Control } from "react-hook-form"; +import { Select } from "@probo/ui"; + +type Props> = + ComponentProps & { + control: Control; + name: string; + }; + +export function ControlledField({ + control, + name, + ...props +}: Props) { + return ( + ( + + )} + /> + ); +} + +export function ControlledSelect({ + control, + name, + ...props +}: Props) { + return ( + ( + + {users?.map((user) => ( + + ))} + + )} + /> + ); +} diff --git a/apps/console2/src/components/form/__generated__/UserSelectQuery.graphql.ts b/apps/console2/src/components/form/__generated__/UserSelectQuery.graphql.ts new file mode 100644 index 000000000..ccd28b58f --- /dev/null +++ b/apps/console2/src/components/form/__generated__/UserSelectQuery.graphql.ts @@ -0,0 +1,180 @@ +/** + * @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; diff --git a/apps/console2/src/hooks/useFormWithSchema.ts b/apps/console2/src/hooks/useFormWithSchema.ts new file mode 100644 index 000000000..565c466d7 --- /dev/null +++ b/apps/console2/src/hooks/useFormWithSchema.ts @@ -0,0 +1,13 @@ +import { useForm } from "react-hook-form"; +import type { z, ZodType } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +export function useFormWithSchema>( + schema: T, + options: Parameters>>[0] +) { + return useForm>({ + ...options, + resolver: zodResolver(schema), + }); +} diff --git a/apps/console2/src/hooks/useOrganizationId.ts b/apps/console2/src/hooks/useOrganizationId.ts new file mode 100644 index 000000000..4d7c1d529 --- /dev/null +++ b/apps/console2/src/hooks/useOrganizationId.ts @@ -0,0 +1,9 @@ +import { useParams } from "react-router"; + +export function useOrganizationId(): string { + const { organizationId } = useParams<{ organizationId?: string }>(); + if (!organizationId) { + throw new Error("Cannot resolve organizationId in route params"); + } + return organizationId; +} diff --git a/apps/console2/src/pages/OrganizationsPage.tsx b/apps/console2/src/pages/OrganizationsPage.tsx index 85c61ece1..6d9361b9d 100644 --- a/apps/console2/src/pages/OrganizationsPage.tsx +++ b/apps/console2/src/pages/OrganizationsPage.tsx @@ -1,13 +1,13 @@ import { useTranslate } from "@probo/i18n"; import { useLazyLoadQuery } from "react-relay"; import { graphql } from "relay-runtime"; -import type { OrganizationSelectionPageQuery as OrganizationSelectionPageQueryType } from "./__generated__/OrganizationSelectionPageQuery.graphql"; +import type { OrganizationsPageQuery as OrganizationsPageQueryType } from "./__generated__/OrganizationsPageQuery.graphql"; import { useEffect } from "react"; import { Link, useNavigate } from "react-router"; import { Avatar, Button, Card, IconPlusLarge } from "@probo/ui"; -const OrganizationSelectionPageQuery = graphql` - query OrganizationSelectionPageQuery { +const OrganizationsPageQuery = graphql` + query OrganizationsPageQuery { viewer { organizations(first: 25) { edges { @@ -22,11 +22,11 @@ const OrganizationSelectionPageQuery = graphql` } `; -export default function OrganizationSelectionPage() { +export default function OrganizationsPage() { const { __ } = useTranslate(); const navigate = useNavigate(); - const data = useLazyLoadQuery( - OrganizationSelectionPageQuery, + const data = useLazyLoadQuery( + OrganizationsPageQuery, {} ); diff --git a/apps/console2/src/pages/__generated__/OrganizationsPageQuery.graphql.ts b/apps/console2/src/pages/__generated__/OrganizationsPageQuery.graphql.ts new file mode 100644 index 000000000..85bd22adb --- /dev/null +++ b/apps/console2/src/pages/__generated__/OrganizationsPageQuery.graphql.ts @@ -0,0 +1,150 @@ +/** + * @generated SignedSource<<91cc89e5e07572ab16c7bc9b8443fcd6>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type OrganizationsPageQuery$variables = Record; +export type OrganizationsPageQuery$data = { + readonly viewer: { + readonly organizations: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly id: string; + readonly logoUrl: string | null | undefined; + readonly name: string; + }; + }>; + }; + }; +}; +export type OrganizationsPageQuery = { + response: OrganizationsPageQuery$data; + variables: OrganizationsPageQuery$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": "OrganizationsPageQuery", + "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": "OrganizationsPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Viewer", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "e771f8e44e9f9224d9cbe0f271b096ed", + "id": null, + "metadata": {}, + "name": "OrganizationsPageQuery", + "operationKind": "query", + "text": "query OrganizationsPageQuery {\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 = "457b1b8b28355830d77458bab7af23f2"; + +export default node; diff --git a/apps/console2/src/pages/organizations/risks/NewRiskDialog.tsx b/apps/console2/src/pages/organizations/risks/NewRiskDialog.tsx index 6a584072b..c9a041066 100644 --- a/apps/console2/src/pages/organizations/risks/NewRiskDialog.tsx +++ b/apps/console2/src/pages/organizations/risks/NewRiskDialog.tsx @@ -9,9 +9,24 @@ import { Select, Option, Field, + Card, + PropertyRow, + IconPlusLarge, + Textarea, + useToast, } from "@probo/ui"; import { useMemo, useState, type ReactNode } from "react"; import { useFetchQuery } from "../../../hooks/useFetchQuery"; +import { graphql } from "relay-runtime"; +import { UserSelect } from "../../../components/form/UserSelect"; +import { useOrganizationId } from "../../../hooks/useOrganizationId"; +import { useToggle } from "@probo/hooks"; +import { + ControlledField, + ControlledSelect, +} from "../../../components/form/ControlledField"; +import { useRiskForm, type RiskForm } from "./forms/useRiskForm"; +import { useMutation } from "react-relay"; type Props = { trigger: ReactNode; @@ -23,50 +38,243 @@ type Risk = { description: string; }; +const createRiskMutation = graphql` + mutation NewRiskDialogMutation( + $input: CreateRiskInput! + $connections: [ID!]! + ) { + createRisk(input: $input) { + riskEdge @prependEdge(connections: $connections) { + node { + id + name + description + category + inherentLikelihood + inherentImpact + residualLikelihood + residualImpact + treatment + createdAt + updatedAt + } + } + } + } +`; + export default function NewRiskDialog({ trigger }: Props) { const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + const { control, handleSubmit, setValue, register, watch, formState } = + useRiskForm(organizationId); + const errors = formState.errors ?? {}; + const [createRisk, isLoading] = useMutation(createRiskMutation); + const { toast } = useToast(); const onTemplateChange = (risk: Risk) => { - console.log(risk); + setValue("name", risk.name); + setValue("description", risk.description); }; + const onSubmit = handleSubmit((data) => { + createRisk({ + variables: { + input: data, + }, + 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", + }); + + setOpen(false); + }, + }); + }); + + const [showNote, toggleNote] = useToggle(false); + const [open, setOpen] = useState(false); + return ( {}} + open={open} + onOpenChange={setOpen} trigger={trigger} title={} > -
+ {/* Main form */}
- + + +
+ + +
{/* Properties form */} -
+
+ + + + + + + + + + + + + + + + +