From 85bd1405991e7a6a595e492b284f021a52e1ae5b Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 17 Jul 2025 14:34:14 +0200 Subject: [PATCH] Exclude controls Signed-off-by: Sacha Al Himdani --- .../console/src/hooks/graph/FrameworkGraph.ts | 2 + .../FrameworkGraphControlNodeQuery.graphql.ts | 87 ++++--- .../FrameworkGraphNodeQuery.graphql.ts | 35 ++- .../frameworks/FrameworkControlPage.tsx | 56 ++-- .../frameworks/FrameworkDetailPage.tsx | 8 +- .../FrameworkDetailPageFragment.graphql.ts | 38 ++- .../dialogs/FrameworkControlDialog.tsx | 42 ++- ...workControlDialogCreateMutation.graphql.ts | 23 +- .../FrameworkControlDialogFragment.graphql.ts | 21 +- ...workControlDialogUpdateMutation.graphql.ts | 23 +- .../ui/src/Atoms/ControlItem/ControlItem.tsx | 23 +- pkg/coredata/control.go | 93 +++++-- pkg/coredata/control_status.go | 52 ++++ pkg/coredata/migrations/20250717T122222Z.sql | 5 + pkg/probo/control_service.go | 18 +- pkg/probo/framework_service.go | 30 ++- pkg/server/api/console/v1/schema.graphql | 18 ++ pkg/server/api/console/v1/schema/schema.go | 242 +++++++++++++++++- pkg/server/api/console/v1/types/control.go | 14 +- pkg/server/api/console/v1/types/types.go | 40 +-- pkg/server/api/console/v1/v1_resolver.go | 10 +- pkg/soagen/field_config.go | 2 +- pkg/soagen/generator.go | 2 +- pkg/soagen/soa_data.go | 2 +- 24 files changed, 736 insertions(+), 150 deletions(-) create mode 100644 pkg/coredata/control_status.go create mode 100644 pkg/coredata/migrations/20250717T122222Z.sql diff --git a/apps/console/src/hooks/graph/FrameworkGraph.ts b/apps/console/src/hooks/graph/FrameworkGraph.ts index 9db2e0b7a..9c6793da2 100644 --- a/apps/console/src/hooks/graph/FrameworkGraph.ts +++ b/apps/console/src/hooks/graph/FrameworkGraph.ts @@ -97,6 +97,8 @@ export const frameworkControlNodeQuery = graphql` name sectionTitle description + status + exclusionJustification ...FrameworkControlDialogFragment measures(first: 100) @connection(key: "FrameworkGraphControl_measures") { diff --git a/apps/console/src/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql.ts index 6f61473a9..507b5b875 100644 --- a/apps/console/src/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<9972eafa19f49d7beea0458c5b9c06d9>> + * @generated SignedSource<<95d2adf687d1cae6938863ba004db9a8>> * @lightSyntaxTransform * @nogrep */ @@ -10,6 +10,7 @@ import { ConcreteRequest } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; +export type ControlStatus = "EXCLUDED" | "INCLUDED"; export type FrameworkGraphControlNodeQuery$variables = { controlId: string; }; @@ -25,6 +26,7 @@ export type FrameworkGraphControlNodeQuery$data = { }; }>; }; + readonly exclusionJustification?: string | null | undefined; readonly id?: string; readonly measures?: { readonly __id: string; @@ -37,6 +39,7 @@ export type FrameworkGraphControlNodeQuery$data = { }; readonly name?: string; readonly sectionTitle?: string; + readonly status?: ControlStatus; readonly " $fragmentSpreads": FragmentRefs<"FrameworkControlDialogFragment">; }; }; @@ -92,17 +95,31 @@ v6 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "__typename", + "name": "status", "storageKey": null }, v7 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "cursor", + "name": "exclusionJustification", "storageKey": null }, v8 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v9 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null +}, +v10 = { "alias": null, "args": null, "concreteType": "PageInfo", @@ -127,7 +144,7 @@ v8 = { ], "storageKey": null }, -v9 = { +v11 = { "kind": "ClientExtension", "selections": [ { @@ -139,7 +156,7 @@ v9 = { } ] }, -v10 = [ +v12 = [ { "kind": "Literal", "name": "first", @@ -168,6 +185,8 @@ return { (v3/*: any*/), (v4/*: any*/), (v5/*: any*/), + (v6/*: any*/), + (v7/*: any*/), { "args": null, "kind": "FragmentSpread", @@ -203,16 +222,16 @@ return { "kind": "FragmentSpread", "name": "LinkedMeasuresCardFragment" }, - (v6/*: any*/) + (v8/*: any*/) ], "storageKey": null }, - (v7/*: any*/) + (v9/*: any*/) ], "storageKey": null }, - (v8/*: any*/), - (v9/*: any*/) + (v10/*: any*/), + (v11/*: any*/) ], "storageKey": null }, @@ -246,16 +265,16 @@ return { "kind": "FragmentSpread", "name": "LinkedDocumentsCardFragment" }, - (v6/*: any*/) + (v8/*: any*/) ], "storageKey": null }, - (v7/*: any*/) + (v9/*: any*/) ], "storageKey": null }, - (v8/*: any*/), - (v9/*: any*/) + (v10/*: any*/), + (v11/*: any*/) ], "storageKey": null } @@ -284,7 +303,7 @@ return { "name": "node", "plural": false, "selections": [ - (v6/*: any*/), + (v8/*: any*/), (v2/*: any*/), { "kind": "InlineFragment", @@ -292,9 +311,11 @@ return { (v3/*: any*/), (v4/*: any*/), (v5/*: any*/), + (v6/*: any*/), + (v7/*: any*/), { "alias": null, - "args": (v10/*: any*/), + "args": (v12/*: any*/), "concreteType": "MeasureConnection", "kind": "LinkedField", "name": "measures", @@ -325,22 +346,22 @@ return { "name": "state", "storageKey": null }, - (v6/*: any*/) + (v8/*: any*/) ], "storageKey": null }, - (v7/*: any*/) + (v9/*: any*/) ], "storageKey": null }, - (v8/*: any*/), - (v9/*: any*/) + (v10/*: any*/), + (v11/*: any*/) ], "storageKey": "measures(first:100)" }, { "alias": null, - "args": (v10/*: any*/), + "args": (v12/*: any*/), "filters": null, "handle": "connection", "key": "FrameworkGraphControl_measures", @@ -349,7 +370,7 @@ return { }, { "alias": null, - "args": (v10/*: any*/), + "args": (v12/*: any*/), "concreteType": "DocumentConnection", "kind": "LinkedField", "name": "documents", @@ -424,13 +445,7 @@ return { "plural": false, "selections": [ (v2/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "status", - "storageKey": null - } + (v6/*: any*/) ], "storageKey": null } @@ -440,22 +455,22 @@ return { ], "storageKey": "versions(first:1)" }, - (v6/*: any*/) + (v8/*: any*/) ], "storageKey": null }, - (v7/*: any*/) + (v9/*: any*/) ], "storageKey": null }, - (v8/*: any*/), - (v9/*: any*/) + (v10/*: any*/), + (v11/*: any*/) ], "storageKey": "documents(first:100)" }, { "alias": null, - "args": (v10/*: any*/), + "args": (v12/*: any*/), "filters": null, "handle": "connection", "key": "FrameworkGraphControl_documents", @@ -472,7 +487,7 @@ return { ] }, "params": { - "cacheID": "165ee5f4a3579e54fb7acafd6fd7ab12", + "cacheID": "ed223aa33ef15ba2e170e8aeae990f8e", "id": null, "metadata": { "connection": [ @@ -498,11 +513,11 @@ return { }, "name": "FrameworkGraphControlNodeQuery", "operationKind": "query", - "text": "query FrameworkGraphControlNodeQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n ... on Control {\n id\n name\n sectionTitle\n description\n ...FrameworkControlDialogFragment\n measures(first: 100) {\n edges {\n node {\n id\n ...LinkedMeasuresCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n documents(first: 100) {\n edges {\n node {\n id\n ...LinkedDocumentsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n\nfragment FrameworkControlDialogFragment on Control {\n id\n name\n description\n sectionTitle\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n" + "text": "query FrameworkGraphControlNodeQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n ... on Control {\n id\n name\n sectionTitle\n description\n status\n exclusionJustification\n ...FrameworkControlDialogFragment\n measures(first: 100) {\n edges {\n node {\n id\n ...LinkedMeasuresCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n documents(first: 100) {\n edges {\n node {\n id\n ...LinkedDocumentsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n\nfragment FrameworkControlDialogFragment on Control {\n id\n name\n description\n sectionTitle\n status\n exclusionJustification\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n" } }; })(); -(node as any).hash = "a2b0b5449af380ea2843aab6ce187b75"; +(node as any).hash = "d5c8f5ce17bd227c83fec31d63a82262"; export default node; diff --git a/apps/console/src/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql.ts index 43140c051..94d5a9d8f 100644 --- a/apps/console/src/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<8901b5c750861080551542d26c2681d6>> * @lightSyntaxTransform * @nogrep */ @@ -123,6 +123,19 @@ return { "name": "description", "storageKey": null }, + { + "alias": null, + "args": null, + "concreteType": "Organization", + "kind": "LinkedField", + "name": "organization", + "plural": false, + "selections": [ + (v3/*: any*/), + (v2/*: any*/) + ], + "storageKey": null + }, { "alias": null, "args": [ @@ -169,7 +182,21 @@ return { "name": "sectionTitle", "storageKey": null }, - (v3/*: any*/) + (v3/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "status", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "exclusionJustification", + "storageKey": null + } ], "storageKey": null } @@ -201,12 +228,12 @@ return { ] }, "params": { - "cacheID": "cf02a37946ff1ec0d7bb2b9a24f26c2a", + "cacheID": "29d6f638feb1bb633169abcfe083f5d2", "id": null, "metadata": {}, "name": "FrameworkGraphNodeQuery", "operationKind": "query", - "text": "query FrameworkGraphNodeQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n ... on Framework {\n id\n name\n ...FrameworkDetailPageFragment\n }\n id\n }\n}\n\nfragment FrameworkDetailPageFragment on Framework {\n id\n name\n description\n controls(first: 250, orderBy: {field: SECTION_TITLE, direction: ASC}) {\n edges {\n node {\n id\n sectionTitle\n name\n }\n }\n }\n}\n" + "text": "query FrameworkGraphNodeQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n ... on Framework {\n id\n name\n ...FrameworkDetailPageFragment\n }\n id\n }\n}\n\nfragment FrameworkDetailPageFragment on Framework {\n id\n name\n description\n organization {\n name\n id\n }\n controls(first: 250, orderBy: {field: SECTION_TITLE, direction: ASC}) {\n edges {\n node {\n id\n sectionTitle\n name\n status\n exclusionJustification\n }\n }\n }\n}\n" } }; })(); diff --git a/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx b/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx index 87fe169f8..fd60f8530 100644 --- a/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx +++ b/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx @@ -146,8 +146,10 @@ export default function FrameworkControlPage({ queryRef }: Props) { return (
-
- {control.sectionTitle} +
+
+ {control.sectionTitle} +
-
{control.name}
- edge.node) ?? []} - params={{ controlId: control.id }} - connectionId={control.measures?.__id!} - onAttach={attachMeasure} - onDetach={detachMeasure} - disabled={isAttachingMeasure || isDetachingMeasure} - /> - edge.node) ?? []} - params={{ controlId: control.id }} - connectionId={control.documents?.__id!} - onAttach={attachDocument} - onDetach={detachDocument} - disabled={isAttachingDocument || isDetachingDocument} + {control.status === "EXCLUDED" && ( +
+
+
+ {__("This control is excluded")} +
+
+
+ {__("Justification:")} {control.exclusionJustification || __("No justification provided")} +
+
+ )} +
+
{control.name}
+ edge.node) ?? []} + params={{ controlId: control.id }} + connectionId={control.measures?.__id!} + onAttach={attachMeasure} + onDetach={detachMeasure} + disabled={isAttachingMeasure || isDetachingMeasure} + /> + edge.node) ?? []} + params={{ controlId: control.id }} + connectionId={control.documents?.__id!} + onAttach={attachDocument} + onDetach={detachDocument} + disabled={isAttachingDocument || isDetachingDocument} /> +
); } diff --git a/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx b/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx index 53180bc18..159c540c5 100644 --- a/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx +++ b/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx @@ -36,6 +36,9 @@ const frameworkDetailFragment = graphql` id name description + organization { + name + } controls(first: 250, orderBy: { field: SECTION_TITLE, direction: ASC }) { __id edges { @@ -43,6 +46,8 @@ const frameworkDetailFragment = graphql` id sectionTitle name + status + exclusionJustification } } } @@ -140,7 +145,7 @@ export default function FrameworkDetailPage(props: Props) { if (data.generateFrameworkStateOfApplicability?.data) { const link = window.document.createElement("a"); link.href = data.generateFrameworkStateOfApplicability.data; - link.download = `${framework.name}-SOA.xlsx`; + link.download = `${framework.organization.name}-${framework.name}-SOA.xlsx`; window.document.body.appendChild(link); link.click(); window.document.body.removeChild(link); @@ -169,6 +174,7 @@ export default function FrameworkDetailPage(props: Props) { key={control.id} id={control.sectionTitle} description={control.name} + excluded={control.status === "EXCLUDED"} to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/${control.id}`} active={selectedControl?.id === control.id} /> diff --git a/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageFragment.graphql.ts b/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageFragment.graphql.ts index 2f899768e..868529c41 100644 --- a/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageFragment.graphql.ts +++ b/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -9,21 +9,27 @@ // @ts-nocheck import { ReaderFragment } from 'relay-runtime'; +export type ControlStatus = "EXCLUDED" | "INCLUDED"; import { FragmentRefs } from "relay-runtime"; export type FrameworkDetailPageFragment$data = { readonly controls: { readonly __id: string; readonly edges: ReadonlyArray<{ readonly node: { + readonly exclusionJustification: string | null | undefined; readonly id: string; readonly name: string; readonly sectionTitle: string; + readonly status: ControlStatus; }; }>; }; readonly description: string; readonly id: string; readonly name: string; + readonly organization: { + readonly name: string; + }; readonly " $fragmentType": "FrameworkDetailPageFragment"; }; export type FrameworkDetailPageFragment$key = { @@ -61,6 +67,18 @@ return { "name": "description", "storageKey": null }, + { + "alias": null, + "args": null, + "concreteType": "Organization", + "kind": "LinkedField", + "name": "organization", + "plural": false, + "selections": [ + (v1/*: any*/) + ], + "storageKey": null + }, { "alias": null, "args": [ @@ -107,7 +125,21 @@ return { "name": "sectionTitle", "storageKey": null }, - (v1/*: any*/) + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "status", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "exclusionJustification", + "storageKey": null + } ], "storageKey": null } @@ -135,6 +167,6 @@ return { }; })(); -(node as any).hash = "ad59b1c61a01fdcb5afbf0ee920a46bb"; +(node as any).hash = "c31fd5c0f675d2524f736d69c4ebebe3"; export default node; diff --git a/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx b/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx index 25520553e..8ab9f7aa4 100644 --- a/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx +++ b/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx @@ -6,6 +6,7 @@ import { DialogFooter, Input, Textarea, + Option, useDialogRef, } from "@probo/ui"; import type { ReactNode } from "react"; @@ -16,6 +17,7 @@ import type { FrameworkControlDialogFragment$key } from "./__generated__/Framewo import { useFormWithSchema } from "/hooks/useFormWithSchema"; import { z } from "zod"; import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; +import { ControlledSelect } from "/components/form/ControlledField"; type Props = { children: ReactNode; @@ -30,6 +32,8 @@ const controlFragment = graphql` name description sectionTitle + status + exclusionJustification } `; @@ -62,6 +66,16 @@ const schema = z.object({ name: z.string(), description: z.string(), sectionTitle: z.string(), + status: z.enum(["INCLUDED", "EXCLUDED"]), + exclusionJustification: z.string().optional(), +}).refine((data) => { + if (data.status === "EXCLUDED") { + return data.exclusionJustification && data.exclusionJustification.trim().length > 0; + } + return true; +}, { + message: "Exclusion justification is required when status is excluded", + path: ["exclusionJustification"], }); export function FrameworkControlDialog(props: Props) { @@ -77,14 +91,19 @@ export function FrameworkControlDialog(props: Props) { successMessage: __("Control created successfully."), errorMessage: __("Failed to create control. Please try again."), }); - const { control, handleSubmit, register, reset } = useFormWithSchema(schema, { + const { control, handleSubmit, register, reset, watch } = useFormWithSchema(schema, { defaultValues: { name: frameworkControl?.name ?? "", description: frameworkControl?.description ?? "", sectionTitle: frameworkControl?.sectionTitle ?? "", + status: frameworkControl?.status ?? "INCLUDED", + exclusionJustification: frameworkControl?.exclusionJustification ?? "", }, }); + const statusValue = watch("status"); + const showExclusionJustification = statusValue === "EXCLUDED"; + const onSubmit = handleSubmit(async (data) => { if (frameworkControl) { // Update the control @@ -95,6 +114,8 @@ export function FrameworkControlDialog(props: Props) { name: data.name, description: data.description, sectionTitle: data.sectionTitle, + status: data.status, + exclusionJustification: data.status === "EXCLUDED" ? data.exclusionJustification : null, }, }, }); @@ -107,6 +128,8 @@ export function FrameworkControlDialog(props: Props) { name: data.name, description: data.description, sectionTitle: data.sectionTitle, + status: data.status, + exclusionJustification: data.status === "EXCLUDED" ? data.exclusionJustification : null, }, connections: [props.connectionId!], }, @@ -148,11 +171,26 @@ export function FrameworkControlDialog(props: Props) {