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

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c2504ebd6a90f696d2068c03294d0c53>>
* @generated SignedSource<<a62993ad367f0d977f346e507dd6b572>>
* @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;
};

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;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<0b0392559fe2c7b5b92bec1b1131f9c6>>
* @generated SignedSource<<18ad235cee2891b222acb016b8986050>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,7 @@
// @ts-nocheck
import { ReaderFragment } 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";
import { FragmentRefs } from "relay-runtime";
export type LinkedSnapshotsCardFragment$data = {
readonly createdAt: any;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<21edb2424074940efad82c63929642c8>>
* @generated SignedSource<<4b2fe0335336a1c3888d8da35ffd8e58>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,7 @@
// @ts-nocheck
import { ReaderFragment } 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";
import { FragmentRefs } from "relay-runtime";
export type LinkedSnapshotsDialogFragment$data = {
readonly id: string;

View File

@@ -0,0 +1,248 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
IconTrashCan,
TrButton,
Badge,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedStatesOfApplicabilityCardFragment$key } from "./__generated__/LinkedStatesOfApplicabilityCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState, useEffect } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedStatesOfApplicabilityDialog } from "./LinkedStatesOfApplicabilityDialog";
import clsx from "clsx";
const linkedStateOfApplicabilityFragment = graphql`
fragment LinkedStatesOfApplicabilityCardFragment on StateOfApplicabilityControl {
id
stateOfApplicabilityId
controlId
stateOfApplicability {
id
name
}
applicability
justification
}
`;
type AttachMutation<Params> = (p: {
variables: {
input: {
stateOfApplicabilityId: string;
applicability: boolean;
justification: string | null;
} & Params;
connections: string[];
};
}) => void;
type DetachMutation = (p: {
variables: {
input: {
stateOfApplicabilityId: string;
controlId: string;
};
connections: string[];
};
}) => void;
type Props<Params> = {
statesOfApplicability: readonly (LinkedStatesOfApplicabilityCardFragment$key & { id: string })[];
params: Params;
disabled?: boolean;
connectionId: string;
onAttach: AttachMutation<Params>;
onDetach: DetachMutation;
variant?: "card" | "table";
readOnly?: boolean;
};
export function LinkedStatesOfApplicabilityCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(
props.variant === "card" ? 4 : null
);
const [linkedInfo, setLinkedInfo] = useState<{ stateOfApplicabilityId: string; controlId: string }[]>([]);
const statesOfApplicability = useMemo(() => {
return limit ? props.statesOfApplicability.slice(0, limit) : props.statesOfApplicability;
}, [props.statesOfApplicability, limit]);
const showMoreButton = limit !== null && props.statesOfApplicability.length > limit;
const variant = props.variant ?? "table";
const linkedData = linkedInfo;
const onAttach = (stateOfApplicabilityId: string, applicability: boolean, justification: string | null) => {
props.onAttach({
variables: {
input: {
stateOfApplicabilityId,
applicability,
justification,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (stateOfApplicabilityId: string, controlId: string) => {
props.onDetach({
variables: {
input: {
stateOfApplicabilityId,
controlId,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
return (
<Wrapper padded className="space-y-[10px]">
{props.statesOfApplicability.map((soa, idx) => (
<LinkedInfoExtractor
key={idx}
fragment={soa}
onExtracted={(info) => {
setLinkedInfo(prev => {
const exists = prev.some(p =>
p.stateOfApplicabilityId === info.stateOfApplicabilityId &&
p.controlId === info.controlId
);
return exists ? prev : [...prev, info];
});
}}
/>
))}
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("States of Applicability")}</div>
{!props.readOnly && (
<LinkedStatesOfApplicabilityDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedStatesOfApplicability={linkedData}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link state of applicability")}
</Button>
</LinkedStatesOfApplicabilityDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Applicability")}</Th>
<Th>{__("Justification")}</Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{statesOfApplicability.length === 0 && (
<Tr>
<Td colSpan={props.readOnly ? 3 : 4} className="text-center text-txt-secondary">
{__("No states of applicability linked")}
</Td>
</Tr>
)}
{statesOfApplicability.map((soa) => (
<StateOfApplicabilityRow key={soa.id} stateOfApplicability={soa} onClick={onDetach} readOnly={props.readOnly} />
))}
{variant === "table" && !props.readOnly && (
<LinkedStatesOfApplicabilityDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedStatesOfApplicability={linkedData}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4} icon={IconPlusLarge}>
{__("Link state of applicability")}
</TrButton>
</LinkedStatesOfApplicabilityDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
icon={IconChevronDown}
onClick={() => setLimit(null)}
>
{sprintf(__("Show %d more"), props.statesOfApplicability.length - limit!)}
</Button>
)}
</Wrapper>
);
}
function LinkedInfoExtractor(props: {
fragment: LinkedStatesOfApplicabilityCardFragment$key;
onExtracted: (info: { stateOfApplicabilityId: string; controlId: string }) => void;
}) {
const data = useFragment(linkedStateOfApplicabilityFragment, props.fragment);
useEffect(() => {
props.onExtracted({
stateOfApplicabilityId: data.stateOfApplicabilityId,
controlId: data.controlId,
});
}, [data.stateOfApplicabilityId, data.controlId, props.onExtracted]);
return null;
}
function StateOfApplicabilityRow(props: {
stateOfApplicability: LinkedStatesOfApplicabilityCardFragment$key & { id: string };
onClick: (stateOfApplicabilityId: string, controlId: string) => void;
readOnly?: boolean;
}) {
const soa = useFragment(linkedStateOfApplicabilityFragment, props.stateOfApplicability);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/states-of-applicability/${soa.stateOfApplicabilityId}`}>
<Td>{soa.stateOfApplicability.name}</Td>
<Td>
<Badge variant={soa.applicability ? "success" : "danger"}>
{soa.applicability ? __("Applicable") : __("Not Applicable")}
</Badge>
</Td>
<Td>{soa.justification || "-"}</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(soa.stateOfApplicabilityId, soa.controlId)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -0,0 +1,180 @@
import { useTranslate } from "@probo/i18n";
import { Dialog, DialogContent, DialogFooter, Button, Checkbox, Textarea, Badge } from "@probo/ui";
import { Suspense, useState, useRef } from "react";
import type { ReactNode } from "react";
import { useLazyLoadQuery } from "react-relay";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { LinkedStatesOfApplicabilityDialogQuery } from "./__generated__/LinkedStatesOfApplicabilityDialogQuery.graphql";
const query = graphql`
query LinkedStatesOfApplicabilityDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
statesOfApplicability(first: 100) {
edges {
node {
id
name
}
}
}
}
}
}
`;
type LinkedSOAInfo = {
stateOfApplicabilityId: string;
controlId: string;
};
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedStatesOfApplicability: readonly LinkedSOAInfo[];
onLink: (stateOfApplicabilityId: string, applicability: boolean, justification: string | null) => void;
onUnlink: (stateOfApplicabilityId: string, controlId: string) => void;
};
export function LinkedStatesOfApplicabilityDialog({ children, ...props }: Props) {
const dialogRef = useRef<{ open: () => void; close: () => void }>(null);
return (
<Dialog ref={dialogRef} trigger={children} title="Link State of Applicability">
<Suspense fallback={<div>Loading...</div>}>
<LinkedStatesOfApplicabilityDialogContent {...props} onClose={() => dialogRef.current?.close()} />
</Suspense>
</Dialog>
);
}
function LinkedStatesOfApplicabilityDialogContent(props: Omit<Props, "children"> & { onClose: () => void }) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [selectedSOA, setSelectedSOA] = useState<{ id: string; name: string } | null>(null);
const [applicability, setApplicability] = useState(true);
const [justification, setJustification] = useState("");
const data = useLazyLoadQuery<LinkedStatesOfApplicabilityDialogQuery>(query, {
organizationId,
}, { fetchPolicy: "network-only" });
const linkedSOAIds = new Set(props.linkedStatesOfApplicability.map((soa) => soa.stateOfApplicabilityId));
const linkedSOAMap = new Map(props.linkedStatesOfApplicability.map((soa) => [soa.stateOfApplicabilityId, soa]));
const statesOfApplicability = data.organization?.statesOfApplicability?.edges.map((edge) => edge.node) ?? [];
const handleSelectSOA = (soa: { id: string; name: string }) => {
setSelectedSOA(soa);
setApplicability(true);
setJustification("");
};
const handleLink = () => {
if (selectedSOA) {
props.onLink(selectedSOA.id, applicability, justification.trim() || null);
props.onClose();
}
};
const handleUnlink = (stateOfApplicabilityId: string) => {
const linkedSOA = linkedSOAMap.get(stateOfApplicabilityId);
if (linkedSOA) {
props.onUnlink(linkedSOA.stateOfApplicabilityId, linkedSOA.controlId);
}
};
return (
<>
<DialogContent padded className="space-y-4">
{statesOfApplicability.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-txt-secondary text-base mb-2">
{__("No states of applicability available")}
</div>
<div className="text-txt-tertiary text-sm">
{__("Create a state of applicability first to link it to this control")}
</div>
</div>
) : !selectedSOA ? (
<div className="space-y-2">
<div className="text-sm font-medium mb-2">{__("Select a state of applicability:")}</div>
{statesOfApplicability.map((soa) => {
const isLinked = linkedSOAIds.has(soa.id);
return (
<div
key={soa.id}
className={`border border-border-low rounded-lg p-3 flex items-center justify-between ${!isLinked ? 'hover:bg-hover cursor-pointer' : ''}`}
onClick={() => !isLinked && handleSelectSOA(soa)}
>
<div className="font-medium">{soa.name}</div>
{isLinked ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Badge variant="success">{__("Linked")}</Badge>
<Button
variant="danger"
onClick={() => handleUnlink(soa.id)}
disabled={props.disabled}
>
{__("Unlink")}
</Button>
</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-txt-secondary mb-1">{__("Selected:")}</div>
<div className="text-lg font-medium">{selectedSOA.name}</div>
</div>
<Button variant="tertiary" onClick={() => setSelectedSOA(null)}>
{__("Change")}
</Button>
</div>
<div className="border-t border-border-low pt-4 space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={applicability}
onChange={(checked) => setApplicability(checked)}
/>
<span className="font-medium">{__("Applicable")}</span>
</label>
<div>
<label className="text-sm font-medium mb-1 block">
{__("Justification (optional)")}
</label>
<Textarea
placeholder={__("Add a justification...")}
value={justification}
onChange={(e) => setJustification(e.target.value)}
rows={3}
/>
</div>
</div>
</div>
)}
</DialogContent>
<DialogFooter exitLabel={__("Close")}>
{selectedSOA ? (
<>
<Button variant="secondary" onClick={() => setSelectedSOA(null)}>
{__("Back")}
</Button>
<Button variant="primary" onClick={handleLink} disabled={props.disabled}>
{__("Link")}
</Button>
</>
) : (
<></>
)}
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,100 @@
/**
* @generated SignedSource<<fa19c3e1b91d31188625e48b8b6db7e9>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedStatesOfApplicabilityCardFragment$data = {
readonly applicability: boolean;
readonly controlId: string;
readonly id: string;
readonly justification: string | null | undefined;
readonly stateOfApplicability: {
readonly id: string;
readonly name: string;
};
readonly stateOfApplicabilityId: string;
readonly " $fragmentType": "LinkedStatesOfApplicabilityCardFragment";
};
export type LinkedStatesOfApplicabilityCardFragment$key = {
readonly " $data"?: LinkedStatesOfApplicabilityCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedStatesOfApplicabilityCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedStatesOfApplicabilityCardFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "stateOfApplicabilityId",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "controlId",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "StateOfApplicability",
"kind": "LinkedField",
"name": "stateOfApplicability",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "applicability",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "justification",
"storageKey": null
}
],
"type": "StateOfApplicabilityControl",
"abstractKey": null
};
})();
(node as any).hash = "b9a9922b27f277a769025f11b0829bdc";
export default node;

View File

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