Change state of applicability

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-01-02 15:04:11 +01:00
parent c60e19a7a8
commit 4ed3f5a067
105 changed files with 18351 additions and 284 deletions

View File

@@ -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<T extends FieldValues = FieldValues> = {
control: Control<T>;
setValue: UseFormSetValue<T>;
name: string;
initialControls?: ControlSelection[];
initialFrameworkIds?: Set<string>;
};
export function StateOfApplicabilityControlsField<T extends FieldValues = FieldValues>({ control, setValue, name, initialControls, initialFrameworkIds }: Props<T>) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
// Initialize form value with initialControls
useEffect(() => {
if (initialControls && initialControls.length > 0) {
setValue(name as Path<T>, initialControls as PathValue<T, Path<T>>);
}
}, [initialControls, setValue, name]);
// Initialize framework selection from initialFrameworkIds
const [selectedFrameworkIds, setSelectedFrameworkIds] = useState<Set<string>>(initialFrameworkIds || new Set());
const [expandedFrameworks, setExpandedFrameworks] = useState<Set<string>>(initialFrameworkIds || new Set());
const [frameworkDataMap, setFrameworkDataMap] = useState<Map<string, FrameworkData>>(new Map());
const [newFrameworkId, setNewFrameworkId] = useState<string>("");
// 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 (
<div className="space-y-4">
<div>
<h4 className="font-medium text-txt-primary mb-4">
{__("Select Controls")}
</h4>
<div className="space-y-3">
<Field label={__("Add Framework")}>
<Suspense fallback={<Select variant="editor" disabled placeholder={__("Loading...")} />}>
<FrameworkSelect
organizationId={organizationId}
selectedFrameworkIds={selectedFrameworkIds}
value={newFrameworkId}
onValueChange={setNewFrameworkId}
onAdd={addFramework}
/>
</Suspense>
</Field>
{Array.from(selectedFrameworkIds).map((frameworkId) => (
<Suspense
key={frameworkId}
fallback={
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
}
>
<FrameworkSection
frameworkId={frameworkId}
isExpanded={expandedFrameworks.has(frameworkId)}
onToggle={() => toggleFramework(frameworkId)}
onRemove={() => removeFramework(frameworkId)}
control={control}
name={name}
frameworkDataMap={frameworkDataMap}
setFrameworkDataMap={setFrameworkDataMap}
/>
</Suspense>
))}
</div>
</div>
</div>
);
}
function FrameworkSelect({
organizationId,
selectedFrameworkIds,
value,
onValueChange,
onAdd,
}: {
organizationId: string;
selectedFrameworkIds: Set<string>;
value: string;
onValueChange: (value: string) => void;
onAdd: (frameworkId: string) => void;
}) {
const { __ } = useTranslate();
const data = useLazyLoadQuery<StateOfApplicabilityControlsFieldFrameworksQuery>(
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<typeof node> => node !== null)) || [];
const availableFrameworks = frameworks.filter(
(framework: { id: string; name: string }) => !selectedFrameworkIds.has(framework.id)
);
return (
<div className="flex gap-2">
<Select
variant="editor"
placeholder={__("Select a framework")}
onValueChange={onValueChange}
value={value}
className="flex-1"
>
{availableFrameworks.map((framework: { id: string; name: string }) => (
<Option key={framework.id} value={framework.id}>
{framework.name}
</Option>
))}
</Select>
<Button
type="button"
variant="secondary"
icon={IconPlusLarge}
onClick={() => onAdd(value)}
disabled={!value || selectedFrameworkIds.has(value)}
>
{__("Add")}
</Button>
</div>
);
}
function FrameworkSection<T extends FieldValues = FieldValues>({
frameworkId,
isExpanded,
onToggle,
onRemove,
control,
name,
frameworkDataMap,
setFrameworkDataMap,
}: {
frameworkId: string;
isExpanded: boolean;
onToggle: () => void;
onRemove: () => void;
control: Control<T>;
name: string;
frameworkDataMap: Map<string, FrameworkData>;
setFrameworkDataMap: React.Dispatch<React.SetStateAction<Map<string, FrameworkData>>>;
}) {
const { __ } = useTranslate();
const data = useLazyLoadQuery<StateOfApplicabilityControlsFieldFrameworkControlsQuery>(
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<typeof node> => 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 (
<div className="border border-border-low rounded-lg">
<div className="flex items-center justify-between p-4">
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 flex-1 text-left hover:bg-subtle -m-4 p-4 rounded-lg"
>
{isExpanded ? (
<IconChevronUp size={16} className="text-txt-tertiary" />
) : (
<IconChevronDown size={16} className="text-txt-tertiary" />
)}
<span className="font-medium text-txt-primary">{displayName}</span>
<span className="text-sm text-txt-tertiary">
({displayControls.length} {__("controls")})
</span>
</button>
<Button
type="button"
variant="secondary"
icon={IconTrashCan}
onClick={onRemove}
className="ml-2"
>
{__("Remove")}
</Button>
</div>
{isExpanded && (
<div className="border-t border-border-low">
<Controller
control={control}
name={name as Path<T>}
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 (
<div className="p-4 space-y-4">
{displayControls.length > 0 && (
<div className="flex justify-end">
<Button
type="button"
variant="quaternary"
onClick={allSelected ? deselectAll : selectAll}
className="text-xs h-7 min-h-7"
>
{allSelected ? __("Deselect All") : __("Select All")}
</Button>
</div>
)}
<div className="border border-border-low rounded-lg max-h-96 overflow-y-auto">
{displayControls.length === 0 ? (
<div className="p-4 text-center text-txt-tertiary">
{__("No controls found in this framework")}
</div>
) : (
<div className="divide-y divide-border-low">
{displayControls.map((ctrl) => {
const isSelected = selectedControlIds.has(ctrl.id);
const state = getControlState(ctrl.id);
const exclusionJustification = getExclusionJustification(
ctrl.id
);
return (
<div key={ctrl.id} className="p-4 space-y-3">
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onChange={() => toggleControl(ctrl.id)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium">
{ctrl.sectionTitle}: {ctrl.name}
</div>
</div>
</div>
{isSelected && (
<div className="ml-7 space-y-2">
<Field label={__("State")}>
<Select
variant="editor"
value={state}
onValueChange={(value) =>
updateControlState(
ctrl.id,
value as
| "EXCLUDED"
| "IMPLEMENTED"
| "NOT_IMPLEMENTED"
)
}
className="w-full"
>
<Option value="IMPLEMENTED">
{__("Implemented")}
</Option>
<Option value="NOT_IMPLEMENTED">
{__("Not Implemented")}
</Option>
<Option value="EXCLUDED">
{__("Excluded")}
</Option>
</Select>
</Field>
{state === "EXCLUDED" || state === "NOT_IMPLEMENTED" && (
<Field label={__("Justification")}>
<Textarea
value={exclusionJustification}
onChange={(e) =>
updateJustification(
ctrl.id,
e.target.value
)
}
placeholder={__(
"Reason for exclusion"
)}
autogrow
/>
</Field>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,197 @@
/**
* @generated SignedSource<<760a4c9ef58d487ab996d36a6cf1aa21>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type StateOfApplicabilityControlsFieldFrameworkControlsQuery$variables = {
frameworkId: string;
};
export type StateOfApplicabilityControlsFieldFrameworkControlsQuery$data = {
readonly framework: {
readonly controls?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
readonly sectionTitle: string;
};
}>;
};
readonly id?: string;
};
};
export type StateOfApplicabilityControlsFieldFrameworkControlsQuery = {
response: StateOfApplicabilityControlsFieldFrameworkControlsQuery$data;
variables: StateOfApplicabilityControlsFieldFrameworkControlsQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "frameworkId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "frameworkId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 500
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "SECTION_TITLE"
}
}
],
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "controls(first:500,orderBy:{\"direction\":\"ASC\",\"field\":\"SECTION_TITLE\"})"
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "StateOfApplicabilityControlsFieldFrameworkControlsQuery",
"selections": [
{
"alias": "framework",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "StateOfApplicabilityControlsFieldFrameworkControlsQuery",
"selections": [
{
"alias": "framework",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/)
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "4e57853f9452f6167ef9dd2521f0b998",
"id": null,
"metadata": {},
"name": "StateOfApplicabilityControlsFieldFrameworkControlsQuery",
"operationKind": "query",
"text": "query StateOfApplicabilityControlsFieldFrameworkControlsQuery(\n $frameworkId: ID!\n) {\n framework: node(id: $frameworkId) {\n __typename\n ... on Framework {\n id\n controls(first: 500, orderBy: {field: SECTION_TITLE, direction: ASC}) {\n edges {\n node {\n id\n sectionTitle\n name\n }\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "2817a6951ce5ad1efdee9951636916ff";
export default node;

View File

@@ -0,0 +1,172 @@
/**
* @generated SignedSource<<09147ad036a4ae9295cd5fc5242f6ee7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type StateOfApplicabilityControlsFieldFrameworksQuery$variables = {
organizationId: string;
};
export type StateOfApplicabilityControlsFieldFrameworksQuery$data = {
readonly organization: {
readonly frameworks?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
};
}>;
};
};
};
export type StateOfApplicabilityControlsFieldFrameworksQuery = {
response: StateOfApplicabilityControlsFieldFrameworksQuery$data;
variables: StateOfApplicabilityControlsFieldFrameworksQuery$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
}
],
"concreteType": "FrameworkConnection",
"kind": "LinkedField",
"name": "frameworks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "frameworks(first:100)"
}
],
"type": "Organization",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "StateOfApplicabilityControlsFieldFrameworksQuery",
"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": "StateOfApplicabilityControlsFieldFrameworksQuery",
"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": "88fe75a1aeca2c9971693ed79bf82afe",
"id": null,
"metadata": {},
"name": "StateOfApplicabilityControlsFieldFrameworksQuery",
"operationKind": "query",
"text": "query StateOfApplicabilityControlsFieldFrameworksQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n frameworks(first: 100) {\n edges {\n node {\n id\n name\n }\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "5a0adc0c46445f40980fdb8edb8122ce";
export default node;