diff --git a/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts b/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts index ca9633707..42d8cfdec 100644 --- a/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts +++ b/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -9,7 +9,7 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; -export type SnapshotsType = "ASSETS" | "CONTINUAL_IMPROVEMENTS" | "DATA" | "NONCONFORMITIES" | "OBLIGATIONS" | "PROCESSING_ACTIVITIES" | "RISKS" | "VENDORS"; +export type SnapshotsType = "ASSETS" | "CONTINUAL_IMPROVEMENTS" | "DATA" | "NONCONFORMITIES" | "OBLIGATIONS" | "PROCESSING_ACTIVITIES" | "RISKS" | "STATES_OF_APPLICABILITY" | "VENDORS"; export type SnapshotBannerQuery$variables = { snapshotId: string; }; diff --git a/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx b/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx new file mode 100644 index 000000000..e37ff243d --- /dev/null +++ b/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx @@ -0,0 +1,518 @@ +import { useTranslate } from "@probo/i18n"; +import { + Field, + Select, + Option, + Checkbox, + Textarea, + Spinner, + Button, + IconChevronDown, + IconChevronUp, + IconTrashCan, + IconPlusLarge, +} from "@probo/ui"; +import { Suspense, useState, useMemo, useEffect } from "react"; +import { Controller, type Control, type UseFormSetValue, type FieldValues, type Path, type PathValue } from "react-hook-form"; +import { useLazyLoadQuery } from "react-relay"; +import { graphql } from "relay-runtime"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import type { StateOfApplicabilityControlsFieldFrameworksQuery } from "./__generated__/StateOfApplicabilityControlsFieldFrameworksQuery.graphql"; +import type { StateOfApplicabilityControlsFieldFrameworkControlsQuery } from "./__generated__/StateOfApplicabilityControlsFieldFrameworkControlsQuery.graphql"; + +const frameworksQuery = graphql` + query StateOfApplicabilityControlsFieldFrameworksQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + frameworks(first: 100) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +const frameworkControlsQuery = graphql` + query StateOfApplicabilityControlsFieldFrameworkControlsQuery( + $frameworkId: ID! + ) { + framework: node(id: $frameworkId) { + ... on Framework { + id + controls(first: 500, orderBy: { field: SECTION_TITLE, direction: ASC }) { + edges { + node { + id + sectionTitle + name + } + } + } + } + } + } +`; + +type ControlSelection = { + controlId: string; + state: "EXCLUDED" | "IMPLEMENTED" | "NOT_IMPLEMENTED"; + exclusionJustification?: string; +}; + +type FrameworkData = { + id: string; + name: string; + controls: Array<{ + id: string; + sectionTitle: string; + name: string; + }>; +}; + +type Props = { + control: Control; + setValue: UseFormSetValue; + name: string; + initialControls?: ControlSelection[]; + initialFrameworkIds?: Set; +}; + +export function StateOfApplicabilityControlsField({ control, setValue, name, initialControls, initialFrameworkIds }: Props) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + // Initialize form value with initialControls + useEffect(() => { + if (initialControls && initialControls.length > 0) { + setValue(name as Path, initialControls as PathValue>); + } + }, [initialControls, setValue, name]); + + // Initialize framework selection from initialFrameworkIds + const [selectedFrameworkIds, setSelectedFrameworkIds] = useState>(initialFrameworkIds || new Set()); + const [expandedFrameworks, setExpandedFrameworks] = useState>(initialFrameworkIds || new Set()); + const [frameworkDataMap, setFrameworkDataMap] = useState>(new Map()); + const [newFrameworkId, setNewFrameworkId] = useState(""); + + // Update framework selection when initialFrameworkIds changes + useEffect(() => { + if (initialFrameworkIds) { + setSelectedFrameworkIds(initialFrameworkIds); + setExpandedFrameworks(initialFrameworkIds); + } + }, [initialFrameworkIds]); + + const addFramework = (frameworkId: string) => { + if (!frameworkId || selectedFrameworkIds.has(frameworkId)) return; + setSelectedFrameworkIds(new Set([...selectedFrameworkIds, frameworkId])); + setExpandedFrameworks(new Set([...expandedFrameworks, frameworkId])); + setNewFrameworkId(""); + }; + + const removeFramework = (frameworkId: string) => { + const newSet = new Set(selectedFrameworkIds); + newSet.delete(frameworkId); + setSelectedFrameworkIds(newSet); + + const newExpanded = new Set(expandedFrameworks); + newExpanded.delete(frameworkId); + setExpandedFrameworks(newExpanded); + + const newMap = new Map(frameworkDataMap); + newMap.delete(frameworkId); + setFrameworkDataMap(newMap); + }; + + const toggleFramework = (frameworkId: string) => { + const newExpanded = new Set(expandedFrameworks); + if (newExpanded.has(frameworkId)) { + newExpanded.delete(frameworkId); + } else { + newExpanded.add(frameworkId); + } + setExpandedFrameworks(newExpanded); + }; + + return ( +
+
+

+ {__("Select Controls")} +

+ +
+ + }> + + + + + {Array.from(selectedFrameworkIds).map((frameworkId) => ( + + +
+ } + > + toggleFramework(frameworkId)} + onRemove={() => removeFramework(frameworkId)} + control={control} + name={name} + frameworkDataMap={frameworkDataMap} + setFrameworkDataMap={setFrameworkDataMap} + /> + + ))} +
+
+ + ); +} + +function FrameworkSelect({ + organizationId, + selectedFrameworkIds, + value, + onValueChange, + onAdd, +}: { + organizationId: string; + selectedFrameworkIds: Set; + value: string; + onValueChange: (value: string) => void; + onAdd: (frameworkId: string) => void; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + frameworksQuery, + { organizationId }, + { fetchPolicy: "network-only" } + ); + const frameworks: Array<{ id: string; name: string }> = + (data?.organization && "frameworks" in data.organization && data.organization.frameworks?.edges + ?.map((edge) => edge.node) + .filter((node): node is NonNullable => node !== null)) || []; + + const availableFrameworks = frameworks.filter( + (framework: { id: string; name: string }) => !selectedFrameworkIds.has(framework.id) + ); + + return ( +
+ + +
+ ); +} + +function FrameworkSection({ + frameworkId, + isExpanded, + onToggle, + onRemove, + control, + name, + frameworkDataMap, + setFrameworkDataMap, +}: { + frameworkId: string; + isExpanded: boolean; + onToggle: () => void; + onRemove: () => void; + control: Control; + name: string; + frameworkDataMap: Map; + setFrameworkDataMap: React.Dispatch>>; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + frameworkControlsQuery, + { frameworkId }, + { fetchPolicy: "network-only" } + ); + + const framework = data?.framework && "controls" in data.framework ? data.framework : null; + const frameworkName: string = framework && "name" in framework && typeof framework.name === "string" ? framework.name : ""; + const controls: Array<{ id: string; sectionTitle: string; name: string }> = useMemo( + () => + (framework?.controls?.edges + ?.map((edge) => edge.node) + .filter((node): node is NonNullable => node !== null)) ?? [], + [framework] + ); + + useEffect(() => { + if (framework && !frameworkDataMap.has(frameworkId)) { + setFrameworkDataMap((prev) => { + const newMap = new Map(prev); + newMap.set(frameworkId, { + id: frameworkId, + name: frameworkName, + controls, + }); + return newMap; + }); + } + }, [framework, frameworkId, frameworkName, controls, frameworkDataMap, setFrameworkDataMap]); + + const cachedData = frameworkDataMap.get(frameworkId); + const displayName: string = cachedData?.name || frameworkName; + const displayControls: Array<{ id: string; sectionTitle: string; name: string }> = cachedData?.controls || controls; + + return ( +
+
+ + +
+ + {isExpanded && ( +
+ } + render={({ field }) => { + const selectedControls: ControlSelection[] = (Array.isArray(field.value) ? field.value : []) as ControlSelection[]; + const selectedControlIds = new Set( + selectedControls.map((c: ControlSelection) => c.controlId) + ); + + const toggleControl = (controlId: string) => { + const isSelected = selectedControlIds.has(controlId); + if (isSelected) { + field.onChange( + selectedControls.filter((c: ControlSelection) => c.controlId !== controlId) + ); + } else { + field.onChange([ + ...selectedControls, + { + controlId, + state: "IMPLEMENTED" as const, + exclusionJustification: undefined, + }, + ]); + } + }; + + const updateControlState = ( + controlId: string, + state: "EXCLUDED" | "IMPLEMENTED" | "NOT_IMPLEMENTED" + ) => { + field.onChange( + selectedControls.map((c: ControlSelection) => + c.controlId === controlId ? { ...c, state } : c + ) + ); + }; + + const updateJustification = ( + controlId: string, + exclusionJustification: string + ) => { + field.onChange( + selectedControls.map((c: ControlSelection) => + c.controlId === controlId + ? { ...c, exclusionJustification } + : c + ) + ); + }; + + const getControlState = (controlId: string) => { + const selected = selectedControls.find((c: ControlSelection) => c.controlId === controlId); + return selected?.state || "IMPLEMENTED"; + }; + + const getExclusionJustification = (controlId: string) => { + const selected = selectedControls.find((c: ControlSelection) => c.controlId === controlId); + return selected?.exclusionJustification || ""; + }; + + const selectAll = () => { + const newSelectedControls: ControlSelection[] = [...selectedControls]; + displayControls.forEach((ctrl) => { + if (!selectedControlIds.has(ctrl.id)) { + newSelectedControls.push({ + controlId: ctrl.id, + state: "IMPLEMENTED" as const, + exclusionJustification: undefined, + }); + } + }); + field.onChange(newSelectedControls); + }; + + const deselectAll = () => { + const controlIdsToRemove = new Set(displayControls.map((ctrl) => ctrl.id)); + const newSelectedControls = selectedControls.filter( + (c: ControlSelection) => !controlIdsToRemove.has(c.controlId) + ); + field.onChange(newSelectedControls); + }; + + const allSelected = displayControls.length > 0 && displayControls.every((ctrl) => selectedControlIds.has(ctrl.id)); + + return ( +
+ {displayControls.length > 0 && ( +
+ +
+ )} +
+ {displayControls.length === 0 ? ( +
+ {__("No controls found in this framework")} +
+ ) : ( +
+ {displayControls.map((ctrl) => { + const isSelected = selectedControlIds.has(ctrl.id); + const state = getControlState(ctrl.id); + const exclusionJustification = getExclusionJustification( + ctrl.id + ); + + return ( +
+
+ toggleControl(ctrl.id)} + /> +
+
+ {ctrl.sectionTitle}: {ctrl.name} +
+
+
+ + {isSelected && ( +
+ + + + + {state === "EXCLUDED" || state === "NOT_IMPLEMENTED" && ( + +