From 4b34157ba3b731291f9d59ca5380899731d340a3 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 5 Jun 2025 15:25:37 -0700 Subject: [PATCH] Add controls crud Signed-off-by: Sacha Al Himdani --- .../organizations/OrganizationBreadcrumb.tsx | 36 +- .../src/pages/organizations/Routes.tsx | 4 + ...readcrumbBreadcrumbControlQuery.graphql.ts | 12 +- .../frameworks/FrameworkLayoutView.tsx | 13 +- .../FrameworkLayoutView/ControlList.tsx | 88 +- .../__generated__/ControlList_List.graphql.ts | 12 +- .../FrameworkLayoutViewQuery.graphql.ts | 20 +- .../FrameworkViewQuery.graphql.ts | 8 +- .../frameworks/controls/Control.tsx | 1055 +++++++++-------- .../frameworks/controls/ControlPage.tsx | 30 +- .../frameworks/controls/EditControlPage.tsx | 14 + .../frameworks/controls/EditControlView.tsx | 201 ++++ .../frameworks/controls/NewControlPage.tsx | 14 + .../frameworks/controls/NewControlView.tsx | 202 ++++ .../ControlDeleteMutation.graphql.ts | 132 +++ .../ControlFragment_Control.graphql.ts | 8 +- .../__generated__/ControlViewQuery.graphql.ts | 8 +- ...ontrolPageUpdateControlMutation.graphql.ts | 132 +++ .../EditControlViewQuery.graphql.ts | 154 +++ ...ontrolViewUpdateControlMutation.graphql.ts | 132 +++ ...ontrolViewCreateControlMutation.graphql.ts | 145 +++ .../organizations/measures/MeasureView.tsx | 10 +- .../MeasureViewFrameworksQuery.graphql.ts | 12 +- .../MeasureViewLinkedControlsQuery.graphql.ts | 12 +- .../organizations/risks/ShowRiskView.tsx | 4 +- .../ShowRiskViewQuery.graphql.ts | 12 +- pkg/coredata/control.go | 78 +- pkg/coredata/control_order_field.go | 12 +- pkg/coredata/migrations/20250605T233149Z.sql | 34 + pkg/probo/control_service.go | 38 +- pkg/probo/framework_service.go | 28 +- pkg/probo/measure_service.go | 2 +- pkg/server/api/console/v1/schema.graphql | 41 +- pkg/server/api/console/v1/schema/schema.go | 887 +++++++++++++- pkg/server/api/console/v1/types/control.go | 12 +- pkg/server/api/console/v1/types/types.go | 48 +- pkg/server/api/console/v1/v1_resolver.go | 53 + 37 files changed, 2988 insertions(+), 715 deletions(-) create mode 100644 apps/console/src/pages/organizations/frameworks/controls/EditControlPage.tsx create mode 100644 apps/console/src/pages/organizations/frameworks/controls/EditControlView.tsx create mode 100644 apps/console/src/pages/organizations/frameworks/controls/NewControlPage.tsx create mode 100644 apps/console/src/pages/organizations/frameworks/controls/NewControlView.tsx create mode 100644 apps/console/src/pages/organizations/frameworks/controls/__generated__/ControlDeleteMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/controls/__generated__/EditControlPageUpdateControlMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/controls/__generated__/EditControlViewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/controls/__generated__/EditControlViewUpdateControlMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/controls/__generated__/NewControlViewCreateControlMutation.graphql.ts create mode 100644 pkg/coredata/migrations/20250605T233149Z.sql diff --git a/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx b/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx index be0a802d5..904461e3a 100644 --- a/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx +++ b/apps/console/src/pages/organizations/OrganizationBreadcrumb.tsx @@ -375,7 +375,23 @@ function BreadcrumbMeasureView() { ); } -function BreadcrumbControl() { +function BreadcrumbControlNew() { + const { organizationId, frameworkId } = useParams(); + return ( + <> + + + + New Control + + + + ); +} + +function BreadcrumbControlExisting() { const { organizationId, frameworkId, controlId } = useParams(); const data = useLazyLoadQuery( graphql` @@ -383,7 +399,7 @@ function BreadcrumbControl() { control: node(id: $controlId) { id ... on Control { - referenceId + sectionTitle } } } @@ -399,13 +415,27 @@ function BreadcrumbControl() { - {data.control?.referenceId} + {data.control?.sectionTitle} ); } +function BreadcrumbControl() { + const { controlId } = useParams(); + + if (controlId === "new") { + return ; + } + + return ( + + + + ); +} + function BreadcrumbRiskList() { const { organizationId } = useParams(); return ( diff --git a/apps/console/src/pages/organizations/Routes.tsx b/apps/console/src/pages/organizations/Routes.tsx index c24975b4e..6f1ee55d3 100644 --- a/apps/console/src/pages/organizations/Routes.tsx +++ b/apps/console/src/pages/organizations/Routes.tsx @@ -11,6 +11,8 @@ import { FrameworkListPage } from "./frameworks/FrameworkListPage"; import { FrameworkPage } from "./frameworks/FrameworkPage"; import { NewFrameworkPage } from "./frameworks/NewFrameworkPage"; import { ControlPage } from "./frameworks/controls/ControlPage"; +import { EditControlPage } from "./frameworks/controls/EditControlPage"; +import { NewControlPage } from "./frameworks/controls/NewControlPage"; import { EditMeasurePage } from "./measures/EditMeasurePage"; import { MeasureListPage } from "./measures/MeasureListPage"; import { MeasurePage } from "./measures/MeasurePage"; @@ -50,7 +52,9 @@ export function OrganizationsRoutes() { }> } /> + } /> } /> + } /> } /> } /> diff --git a/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbControlQuery.graphql.ts b/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbControlQuery.graphql.ts index 24c8dd0f3..63624773a 100644 --- a/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbControlQuery.graphql.ts +++ b/apps/console/src/pages/organizations/__generated__/OrganizationBreadcrumbBreadcrumbControlQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<07652bb5a37a4cdf28c0df2f627ada0e>> * @lightSyntaxTransform * @nogrep */ @@ -15,7 +15,7 @@ export type OrganizationBreadcrumbBreadcrumbControlQuery$variables = { export type OrganizationBreadcrumbBreadcrumbControlQuery$data = { readonly control: { readonly id: string; - readonly referenceId?: string; + readonly sectionTitle?: string; }; }; export type OrganizationBreadcrumbBreadcrumbControlQuery = { @@ -52,7 +52,7 @@ v3 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "referenceId", + "name": "sectionTitle", "storageKey": null } ], @@ -112,16 +112,16 @@ return { ] }, "params": { - "cacheID": "d46d6f288bc94595c84a4c028ba15f83", + "cacheID": "6d3e964652b3f13d0524f2db09668d7d", "id": null, "metadata": {}, "name": "OrganizationBreadcrumbBreadcrumbControlQuery", "operationKind": "query", - "text": "query OrganizationBreadcrumbBreadcrumbControlQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n referenceId\n }\n }\n}\n" + "text": "query OrganizationBreadcrumbBreadcrumbControlQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n sectionTitle\n }\n }\n}\n" } }; })(); -(node as any).hash = "dd4a9837ee450c35961ee67d3213c017"; +(node as any).hash = "04f863d003032971d0490bf0d0c90fce"; export default node; diff --git a/apps/console/src/pages/organizations/frameworks/FrameworkLayoutView.tsx b/apps/console/src/pages/organizations/frameworks/FrameworkLayoutView.tsx index d23196f99..d5648f850 100644 --- a/apps/console/src/pages/organizations/frameworks/FrameworkLayoutView.tsx +++ b/apps/console/src/pages/organizations/frameworks/FrameworkLayoutView.tsx @@ -24,6 +24,7 @@ import { PageTemplate } from "@/components/PageTemplate"; import { FrameworkLayoutViewSkeleton } from "./FrameworkLayout"; import { ControlList } from "./FrameworkLayoutView/ControlList"; import { FrameworkLayoutViewExportAuditMutation } from "./__generated__/FrameworkLayoutViewExportAuditMutation.graphql"; +import { Plus } from "lucide-react"; const FrameworkLayoutViewQuery = graphql` query FrameworkLayoutViewQuery($frameworkId: ID!) { @@ -35,12 +36,12 @@ const FrameworkLayoutViewQuery = graphql` ...ControlList_List firstControl: controls( first: 1 - orderBy: { field: CREATED_AT, direction: ASC } + orderBy: { field: SECTION_TITLE, direction: ASC } ) @connection(key: "FrameworkLayoutView_firstControl") { edges { node { id - referenceId + sectionTitle name } } @@ -153,6 +154,14 @@ function FrameworkLayoutViewContent({ description={framework.description || ""} actions={
+ - - - - - {/* Linked Measures List */} -
-
-

- Security measures -

- + ) : ( +
+ No security measures linked to this control yet. Click + "Link Security Measures" to connect some. +
+ )}
- - {isLoadingMeasures ? ( -
- - Loading security measures... -
- ) : linkedMeasuresData?.control?.measures?.edges && - linkedMeasuresData.control.measures.edges.length > 0 ? ( -
- - - - - - - - - - {getLinkedMeasures().map((measure) => ( - - - - - - ))} - -
NameState - Actions -
-
{measure.name}
- {measure.description && ( -
- {measure.description} -
- )} -
-
- {formatState(measure.state)} -
-
-
- - -
-
-
- ) : ( -
- No security measures linked to this control yet. Click - "Link Security Measures" to connect some. -
- )}
-
- {/* Documents Section */} -
- {/* Document Mapping Dialog */} - - - - Link Documents to Control - - Search and select documents to link to this control. This helps - track which documents address this control. - - + {/* Documents Section */} +
+ {/* Document Mapping Dialog */} + + + + Link Documents to Control + + Search and select documents to link to this control. This helps + track which documents address this control. + + -
-
-
- - setDocumentSearchQuery(e.target.value)} - className="w-full pl-10" - /> +
+
+
+ + setDocumentSearchQuery(e.target.value)} + className="w-full pl-10" + /> +
-
-
- {isLoadingDocuments ? ( -
- - Loading documents... -
- ) : ( -
- {filteredDocuments().length === 0 ? ( -
- No documents found. Try adjusting your search. -
- ) : ( - - - - - - - - - - {filteredDocuments().map((document) => { - const isLinked = isDocumentLinked(document.id); - return ( - - + + + + ); + })} + +
Name - Review Date - - Actions -
-
- {document.title} -
- {document.description && ( -
- {document.description} +
+ {isLoadingDocuments ? ( +
+ + Loading documents... +
+ ) : ( +
+ {filteredDocuments().length === 0 ? ( +
+ No documents found. Try adjusting your search. +
+ ) : ( + + + + + + + + + + {filteredDocuments().map((document) => { + const isLinked = isDocumentLinked(document.id); + return ( + + - - - - ); - })} - -
Name + Review Date + + Actions +
+
+ {document.title}
- )} -
- {document.updatedAt - ? new Date( - document.updatedAt - ).toLocaleDateString() - : "Not set"} - - {isLinked ? ( - - ) : ( - - )} -
- )} -
- )} + {document.description && ( +
+ {document.description} +
+ )} +
+ {document.updatedAt + ? new Date( + document.updatedAt + ).toLocaleDateString() + : "Not set"} + + {isLinked ? ( + + ) : ( + + )} +
+ )} +
+ )} +
+ + + + + +
+ + {/* Linked Documents List */} +
+
+

Documents

+
- - - - -
+ {isLoadingDocuments ? ( +
+ + Loading documents... +
+ ) : linkedDocumentsData?.control?.documents?.edges && + linkedDocumentsData.control.documents.edges.length > 0 ? ( +
+ + + + + + + + + + {getLinkedDocuments().map((document) => ( + + + + + + ))} + +
NameReview Date + Actions +
+
{document.title}
+ {document.description && ( +
+ {document.description} +
+ )} +
+ {document.updatedAt + ? new Date(document.updatedAt).toLocaleDateString() + : "Not set"} + +
+ + +
+
+
+ ) : ( +
+ No documents linked to this control yet. Click "Link + Documents" to connect some. +
+ )} +
+ - {/* Linked Documents List */} -
-
-

Documents

+ {/* Delete Control Section */} +
+
+
- - {isLoadingDocuments ? ( -
- - Loading documents... -
- ) : linkedDocumentsData?.control?.documents?.edges && - linkedDocumentsData.control.documents.edges.length > 0 ? ( -
- - - - - - - - - - {getLinkedDocuments().map((document) => ( - - - - - - ))} - -
NameReview Date - Actions -
-
{document.title}
- {document.description && ( -
- {document.description} -
- )} -
- {document.updatedAt - ? new Date(document.updatedAt).toLocaleDateString() - : "Not set"} - -
- - -
-
-
- ) : ( -
- No documents linked to this control yet. Click "Link - Documents" to connect some. -
- )}
- + + {/* Delete Confirmation Dialog */} + + + + Delete Control + + Are you sure you want to delete the control "{control.name}"? This action cannot be undone. + + + + + + + + + ); } diff --git a/apps/console/src/pages/organizations/frameworks/controls/ControlPage.tsx b/apps/console/src/pages/organizations/frameworks/controls/ControlPage.tsx index 04d5d9ae3..f3929cf94 100644 --- a/apps/console/src/pages/organizations/frameworks/controls/ControlPage.tsx +++ b/apps/console/src/pages/organizations/frameworks/controls/ControlPage.tsx @@ -1,38 +1,20 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { Suspense } from "react"; -import { useLocation } from "react-router"; -import { lazy } from "@probo/react-lazy"; -import ErrorBoundary from "@/components/ErrorBoundary"; +import { Loader2 } from "lucide-react"; +import { lazy, Suspense } from "react"; const ControlView = lazy(() => import("./ControlView")); export function ControlViewSkeleton() { return ( -
- {[1, 2, 3].map((i) => ( - - -
-
-
-
-
-
- - - ))} +
+
); } export function ControlPage() { - const location = useLocation(); - return ( - }> - - - + }> + ); } diff --git a/apps/console/src/pages/organizations/frameworks/controls/EditControlPage.tsx b/apps/console/src/pages/organizations/frameworks/controls/EditControlPage.tsx new file mode 100644 index 000000000..1b3a524b1 --- /dev/null +++ b/apps/console/src/pages/organizations/frameworks/controls/EditControlPage.tsx @@ -0,0 +1,14 @@ +import { Loader2 } from "lucide-react"; +import EditControlView from "./EditControlView"; + +export function EditControlViewSkeleton() { + return ( +
+ +
+ ); +} + +export function EditControlPage() { + return ; +} diff --git a/apps/console/src/pages/organizations/frameworks/controls/EditControlView.tsx b/apps/console/src/pages/organizations/frameworks/controls/EditControlView.tsx new file mode 100644 index 000000000..8ced76928 --- /dev/null +++ b/apps/console/src/pages/organizations/frameworks/controls/EditControlView.tsx @@ -0,0 +1,201 @@ +import { Suspense, useEffect, useState } from "react"; +import { + graphql, + PreloadedQuery, + useMutation, + usePreloadedQuery, + useQueryLoader, +} from "react-relay"; +import { useParams, useNavigate } from "react-router"; +import { useToast } from "@/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2 } from "lucide-react"; +import { EditControlViewSkeleton } from "./EditControlPage"; +import { EditControlViewQuery } from "./__generated__/EditControlViewQuery.graphql"; + +const editControlViewQuery = graphql` + query EditControlViewQuery($controlId: ID!) { + control: node(id: $controlId) { + ... on Control { + id + name + description + sectionTitle + } + } + } +`; + +const updateControlMutation = graphql` + mutation EditControlViewUpdateControlMutation($input: UpdateControlInput!) { + updateControl(input: $input) { + control { + id + name + description + sectionTitle + } + } + } +`; + +function EditControlViewContent({ + queryRef, +}: { + queryRef: PreloadedQuery; +}) { + const { organizationId, frameworkId } = useParams<{ + organizationId: string; + frameworkId: string; + }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + const data = usePreloadedQuery(editControlViewQuery, queryRef); + const [isLoading, setIsLoading] = useState(false); + const [commitUpdateControl] = useMutation(updateControlMutation); + + if (!data.control) { + return ; + } + + const [formData, setFormData] = useState({ + name: data.control.name || "", + description: data.control.description || "", + sectionTitle: data.control.sectionTitle || "", + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + commitUpdateControl({ + variables: { + input: { + id: data.control.id, + name: formData.name, + description: formData.description, + sectionTitle: formData.sectionTitle, + }, + }, + onCompleted: (_, errors) => { + setIsLoading(false); + + if (errors) { + console.error("Error updating control:", errors); + toast({ + title: "Error", + description: "Failed to update control. Please try again.", + variant: "destructive", + }); + return; + } + + toast({ + title: "Success", + description: "Control updated successfully.", + }); + + navigate(`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${data.control.id}`); + }, + onError: (error) => { + setIsLoading(false); + console.error("Error updating control:", error); + toast({ + title: "Error", + description: "Failed to update control. Please try again.", + variant: "destructive", + }); + }, + }); + }; + + return ( +
+
+

Edit Control

+ +
+
+ + + setFormData((prev) => ({ ...prev, sectionTitle: e.target.value })) + } + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + required + /> +
+ +
+ +