Add frameworks detail page

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-06-02 14:24:22 +02:00
committed by Sacha Al Himdani
parent b6aec0b14a
commit 74c136f6c0
36 changed files with 3117 additions and 646 deletions

View File

@@ -0,0 +1,15 @@
import { availableFrameworks } from "@probo/helpers";
import { Avatar } from "@probo/ui";
const availableLogos = new Map(
availableFrameworks.map((framework) => [framework.name, framework.logo])
);
export function FrameworkLogo({ name }: { name: string }) {
const logo = availableLogos.get(name);
return logo ? (
<img src={logo} alt="" className="size-12" />
) : (
<Avatar name={name} size="l" className="size-12" />
);
}

View File

@@ -0,0 +1,138 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
Input,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type { DocumentLinkDialogQuery } from "./__generated__/DocumentLinkDialogQuery.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
const documentsQuery = graphql`
query DocumentLinkDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
documents(first: 100) @connection(key: "Organization__documents") {
edges {
node {
id
title
}
}
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedDocuments?: { id: string }[];
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
export function DocumentLinkDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link documents")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<DocumentLinkDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function DocumentLinkDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<DocumentLinkDialogQuery>(documentsQuery, {
organizationId,
});
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const documents =
data.organization?.documents?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedDocuments?.map((m) => m.id) ?? []);
}, [props.linkedDocuments]);
const filteredDocuments = useMemo(() => {
return documents.filter((document) =>
document.title.toLowerCase().includes(search.toLowerCase())
);
}, [documents, search]);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search documents...")}
onValueChange={setSearch}
/>
</div>
<div className="divide-y divide-border-low">
{filteredDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
linkedDocuments={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
</div>
</>
);
}
type RowProps = {
document: { title: string; id: string };
linkedDocuments: Set<string>;
disabled?: boolean;
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
function DocumentRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedDocuments.has(props.document.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.document.id)}
>
{props.document.title}
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,189 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
IconTrashCan,
DocumentVersionBadge,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedDocumentsCardFragment$key } from "./__generated__/LinkedDocumentsCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { DocumentLinkDialog } from "./DocumentLinkDialog";
const linkedDocumentFragment = graphql`
fragment LinkedDocumentsCardFragment on Document {
id
title
createdAt
versions(first: 1) {
edges {
node {
id
status
}
}
}
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
documentId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Documents linked to the element
documents: (LinkedDocumentsCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a document (will receive {documentId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a document (will receive {documentId, ...params})
onDetach: Mutation<Params>;
};
/**
* Reusable component that displays a list of linked documents
*/
export function LinkedDocumentsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const documents = useMemo(() => {
return limit ? props.documents.slice(0, limit) : props.documents;
}, [props.documents, limit]);
const showMoreButton = limit !== null && props.documents.length > limit;
const onAttach = (documentId: string) => {
props.onAttach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (documentId: string) => {
props.onDetach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<Card padded className="space-y-[10px]">
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Documents")}</div>
<DocumentLinkDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link document")}
</Button>
</DocumentLinkDialog>
</div>
{documents.length > 0 ? (
<Table className="bg-invert">
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{documents.map((document) => (
<DocumentRow
key={document.id}
document={document}
onClick={onDetach}
/>
))}
</Tbody>
</Table>
) : (
<div className="text-center text-sm text-txt-secondary">
{__("No documents linked")}
</div>
)}
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.documents.length - limit)}
</Button>
)}
</Card>
);
}
function DocumentRow(props: {
document: LinkedDocumentsCardFragment$key & { id: string };
onClick: (documentId: string) => void;
}) {
const document = useFragment(linkedDocumentFragment, props.document);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
<Td>
<div className="flex gap-4 items-center">
<img
src="/document.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{document.title}
</div>
</Td>
<Td>
<DocumentVersionBadge state={document.versions.edges[0].node.status} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(document.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,246 @@
/**
* @generated SignedSource<<dfc2f49ef0ca7d3a3e62809aef61dfe6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DocumentLinkDialogQuery$variables = {
organizationId: string;
};
export type DocumentLinkDialogQuery$data = {
readonly organization: {
readonly documents?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly title: string;
};
}>;
};
readonly id: string;
};
};
export type DocumentLinkDialogQuery = {
response: DocumentLinkDialogQuery$data;
variables: DocumentLinkDialogQuery$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 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "DocumentLinkDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": "documents",
"args": null,
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "__Organization__documents_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "DocumentLinkDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "documents",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "documents(first:100)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "Organization__documents",
"kind": "LinkedHandle",
"name": "documents"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "fa671324c6ab818f40ccf9f83ca9af8a",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organization",
"documents"
]
}
]
},
"name": "DocumentLinkDialogQuery",
"operationKind": "query",
"text": "query DocumentLinkDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n documents(first: 100) {\n edges {\n node {\n id\n title\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "70a09260215ae0f74ccf06a0c0872e75";
export default node;

View File

@@ -0,0 +1,117 @@
/**
* @generated SignedSource<<da0435f63c82413be14bb2bb444aad49>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentStatus = "DRAFT" | "PUBLISHED";
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsCardFragment$data = {
readonly createdAt: any;
readonly id: string;
readonly title: string;
readonly versions: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly status: DocumentStatus;
};
}>;
};
readonly " $fragmentType": "LinkedDocumentsCardFragment";
};
export type LinkedDocumentsCardFragment$key = {
readonly " $data"?: LinkedDocumentsCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsCardFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "DocumentVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"type": "Document",
"abstractKey": null
};
})();
(node as any).hash = "907fbdc23df596e4f76e396a11878706";
export default node;

View File

@@ -0,0 +1,170 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
MeasureBadge,
IconTrashCan,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedMeasuresCardFragment$key } from "./__generated__/LinkedMeasuresCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { MeasureLinkDialog } from "./MeasureLinkDialog";
const linkedMeasureFragment = graphql`
fragment LinkedMeasuresCardFragment on Measure {
id
name
state
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
measureId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Measures linked to the element
measures: (LinkedMeasuresCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a measure (will receive {measureId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a measure (will receive {measureId, ...params})
onDetach: Mutation<Params>;
};
/**
* Reusable component that displays a list of linked measures
*/
export function LinkedMeasuresCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const measures = useMemo(() => {
return limit ? props.measures.slice(0, limit) : props.measures;
}, [props.measures, limit]);
const showMoreButton = limit !== null && props.measures.length > limit;
const onAttach = (measureId: string) => {
props.onAttach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (measureId: string) => {
props.onDetach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<Card padded className="space-y-[10px]">
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Measures")}</div>
<MeasureLinkDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link measure")}
</Button>
</MeasureLinkDialog>
</div>
{measures.length > 0 ? (
<Table className="bg-invert">
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{measures.map((measure) => (
<MeasureRow
key={measure.id}
measure={measure}
onClick={onDetach}
/>
))}
</Tbody>
</Table>
) : (
<div className="text-center text-sm text-txt-secondary">
{__("No measures linked")}
</div>
)}
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.measures.length - limit)}
</Button>
)}
</Card>
);
}
function MeasureRow(props: {
measure: LinkedMeasuresCardFragment$key & { id: string };
onClick: (measureId: string) => void;
}) {
const measure = useFragment(linkedMeasureFragment, props.measure);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/measures/${measure.id}`}>
<Td>{measure.name}</Td>
<Td>
<MeasureBadge state={measure.state} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(measure.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,166 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
Input,
Option,
Select,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type { MeasureLinkDialogQuery } from "./__generated__/MeasureLinkDialogQuery.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
const measuresQuery = graphql`
query MeasureLinkDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
measures(first: 100) @connection(key: "Organization__measures") {
edges {
node {
id
name
state
description
category
}
}
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedMeasures?: { id: string }[];
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
export function MeasureLinkDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link measures")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<MeasureLinkDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function MeasureLinkDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<MeasureLinkDialogQuery>(measuresQuery, {
organizationId,
});
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const measures =
data.organization?.measures?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedMeasures?.map((m) => m.id) ?? []);
}, [props.linkedMeasures]);
const filteredMeasures = useMemo(() => {
return measures.filter(
(measure) =>
(category === null || measure.category === category) &&
(measure.name.toLowerCase().includes(search.toLowerCase()) ||
measure.description?.toLowerCase().includes(search.toLowerCase()))
);
}, [measures, search, category]);
const categories = useMemo(
() => Array.from(new Set(measures.map((m) => m.category))),
[measures]
);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={setSearch}
/>
<Select
value={category ?? ""}
placeholder={__("All categories")}
onValueChange={setCategory}
className="max-w-[180px]"
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
</div>
<div className="divide-y divide-border-low">
{filteredMeasures.map((measure) => (
<MeasureRow
key={measure.id}
measure={measure}
linkedMeasures={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
</div>
</>
);
}
type RowProps = {
measure: { name: string; category: string; id: string };
linkedMeasures: Set<string>;
disabled?: boolean;
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
function MeasureRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedMeasures.has(props.measure.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.measure.id)}
>
{props.measure.name}
<Badge variant="neutral">{props.measure.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,59 @@
/**
* @generated SignedSource<<b7482b43ce0f4dfab470c35e806acf50>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
import { FragmentRefs } from "relay-runtime";
export type LinkedMeasuresCardFragment$data = {
readonly id: string;
readonly name: string;
readonly state: MeasureState;
readonly " $fragmentType": "LinkedMeasuresCardFragment";
};
export type LinkedMeasuresCardFragment$key = {
readonly " $data"?: LinkedMeasuresCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedMeasuresCardFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"type": "Measure",
"abstractKey": null
};
(node as any).hash = "741a216c02732c1ff97b265ac8dbf39b";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6a9b2c4428ed2255dda81771894c0c6c>>
* @generated SignedSource<<0b725ea8709fbf5da3f17545fcd2b8c4>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -88,6 +88,13 @@ v4 = [
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -102,13 +109,6 @@ v4 = [
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
@@ -244,7 +244,7 @@ return {
]
},
"params": {
"cacheID": "13eb493b76ecc90bc9dd27963b5dba84",
"cacheID": "54fff8a0c07ebe02ef34f3bb35e7582e",
"id": null,
"metadata": {
"connection": [
@@ -261,11 +261,11 @@ return {
},
"name": "MeasureLinkDialogQuery",
"operationKind": "query",
"text": "query MeasureLinkDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n measures(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
"text": "query MeasureLinkDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n measures(first: 100) {\n edges {\n node {\n id\n name\n state\n description\n category\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "468d35ade53ae7655cd0fbab242b7d1a";
(node as any).hash = "cf01fa3a766badd4f6150e1a544403ba";
export default node;

View File

@@ -1,48 +1,4 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
Input,
Option,
Select,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery, useMutation } from "react-relay";
import type {
MeasureLinkDialogQuery,
MeasureLinkDialogQuery$data,
} from "./__generated__/MeasureLinkDialogQuery.graphql";
import type { NodeOf } from "/types";
const measuresQuery = graphql`
query MeasureLinkDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
measures(first: 100) @connection(key: "Organization__measures") {
edges {
node {
id
name
description
category
state
}
}
}
}
}
}
`;
/*
const attachMeasureMutation = graphql`
mutation MeasureLinkDialogCreateMutation(
$input: CreateRiskMeasureMappingInput!
@@ -72,132 +28,4 @@ export const detachMeasureMutation = graphql`
}
}
`;
type Props = {
trigger: ReactNode;
organizationId: string;
connectionId: string;
riskId: string;
linkedIds?: Set<string>;
};
export function MeasureLinkDialog({ trigger, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={trigger} title={__("Manage Risk Measures")}>
<DialogContent className="px-6">
<p className="text-sm text-txt-secondary mt-6">
{__("Link or unlink measures to manage this risk.")}
</p>
<Suspense fallback={<Spinner centered />}>
<MeasureLinkDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function MeasureLinkDialogContent(props: Omit<Props, "trigger">) {
const data = useLazyLoadQuery<MeasureLinkDialogQuery>(measuresQuery, {
organizationId: props.organizationId,
});
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const measures =
data.organization?.measures?.edges?.map((edge) => edge.node) ?? [];
const filteredMeasures = useMemo(() => {
return measures.filter(
(measure) =>
(category === null || measure.category === category) &&
(measure.name.toLowerCase().includes(search.toLowerCase()) ||
measure.description?.toLowerCase().includes(search.toLowerCase()))
);
}, [measures, search, category]);
const categories = useMemo(
() => Array.from(new Set(measures.map((m) => m.category))),
[measures]
);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={setSearch}
/>
<Select
value={category ?? ""}
placeholder={__("All categories")}
onValueChange={setCategory}
className="max-w-[180px]"
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
</div>
<div className="divide-y divide-border-low">
{filteredMeasures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} {...props} />
))}
</div>
</>
);
}
type RowProps = {
measure: NodeOf<
Required<MeasureLinkDialogQuery$data["organization"]>["measures"]
>;
} & Omit<Props, "trigger">;
function MeasureRow(props: RowProps) {
const isLinked = props.linkedIds?.has(props.measure.id) ?? false;
const { __ } = useTranslate();
const [attachMeasure, isFetchingAttach] = useMutation(attachMeasureMutation);
const [detachMeasure, isFetchingDetach] = useMutation(detachMeasureMutation);
const isFetching = isFetchingAttach || isFetchingDetach;
const onClick = () => {
if (isFetching) {
return;
}
const action = isLinked ? detachMeasure : attachMeasure;
action({
variables: {
input: {
riskId: props.riskId,
measureId: props.measure.id,
},
connections: [props.connectionId],
},
});
};
return (
<div
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer"
onClick={onClick}
>
{props.measure.name}
<Badge variant="neutral">{props.measure.category}</Badge>
<Button
disabled={isFetching}
icon={isLinked ? IconTrashCan : IconPlusLarge}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
>
{isLinked ? __("Unlink") : __("Link")}
</Button>
</div>
);
}
*/

View File

@@ -1,192 +0,0 @@
/**
* @generated SignedSource<<043e8cc5beda1ac8949a7c02adcff4bf>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type CreateRiskMeasureMappingInput = {
measureId: string;
riskId: string;
};
export type MeasureLinkDialogCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateRiskMeasureMappingInput;
};
export type MeasureLinkDialogCreateMutation$data = {
readonly createRiskMeasureMapping: {
readonly measureEdge: {
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: MeasureState;
};
};
};
};
export type MeasureLinkDialogCreateMutation = {
response: MeasureLinkDialogCreateMutation$data;
variables: MeasureLinkDialogCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "measureEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "MeasureLinkDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskMeasureMappingPayload",
"kind": "LinkedField",
"name": "createRiskMeasureMapping",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "MeasureLinkDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskMeasureMappingPayload",
"kind": "LinkedField",
"name": "createRiskMeasureMapping",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "measureEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "309f3c90d91577e2f1c95ea69848ce5a",
"id": null,
"metadata": {},
"name": "MeasureLinkDialogCreateMutation",
"operationKind": "mutation",
"text": "mutation MeasureLinkDialogCreateMutation(\n $input: CreateRiskMeasureMappingInput!\n) {\n createRiskMeasureMapping(input: $input) {\n measureEdge {\n node {\n id\n name\n description\n category\n state\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "bd012f06e39d5a04f4c1a2d2026f497e";
export default node;

View File

@@ -70,3 +70,15 @@ export const useDeleteFrameworkMutation = (
);
}, [framework, connectionId, commitDelete]);
};
export const frameworkNodeQuery = graphql`
query FrameworkGraphNodeQuery($frameworkId: ID!) {
node(id: $frameworkId) {
... on Framework {
id
name
...FrameworkDetailPageFragment
}
}
}
`;

View File

@@ -0,0 +1,407 @@
/**
* @generated SignedSource<<64acca659bff60ca637937d933fc839f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type FrameworkGraphNodeQuery$variables = {
frameworkId: string;
};
export type FrameworkGraphNodeQuery$data = {
readonly node: {
readonly id?: string;
readonly name?: string;
readonly " $fragmentSpreads": FragmentRefs<"FrameworkDetailPageFragment">;
};
};
export type FrameworkGraphNodeQuery = {
response: FrameworkGraphNodeQuery$data;
variables: FrameworkGraphNodeQuery$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": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v6 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
v9 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FrameworkGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "FrameworkDetailPageFragment"
}
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FrameworkGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v4/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": (v6/*: any*/),
"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": "referenceId",
"storageKey": null
},
(v3/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": "MeasureConnection",
"kind": "LinkedField",
"name": "measures",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/),
(v9/*: any*/)
],
"storageKey": "measures(first:100)"
},
{
"alias": null,
"args": (v6/*: any*/),
"filters": null,
"handle": "connection",
"key": "FrameworkDetailPage_measures",
"kind": "LinkedHandle",
"name": "measures"
},
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "DocumentVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
},
(v4/*: any*/)
],
"storageKey": null
},
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/),
(v9/*: any*/)
],
"storageKey": "documents(first:100)"
},
{
"alias": null,
"args": (v6/*: any*/),
"filters": null,
"handle": "connection",
"key": "FrameworkDetailPage_documents",
"kind": "LinkedHandle",
"name": "documents"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "controls(first:100)"
}
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "b10b304da4ff5b00e15b881ee486e8fe",
"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: 100) {\n edges {\n node {\n id\n referenceId\n name\n description\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 }\n }\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\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 = "69a32dc8fbcaefbbaeb02dd2b89752c4";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<18341e1d2a7f480ad73ef32506121c5e>>
* @generated SignedSource<<dfb9e4b341ccc895fce60e2de5287ec1>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -108,14 +108,7 @@ v8 = {
"name": "__typename",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
v10 = [
v9 = [
{
"kind": "Literal",
"name": "first",
@@ -195,7 +188,13 @@ return {
(v4/*: any*/),
(v6/*: any*/),
(v7/*: any*/),
(v9/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -240,7 +239,7 @@ return {
},
{
"alias": null,
"args": (v10/*: any*/),
"args": (v9/*: any*/),
"concreteType": "MeasureConnection",
"kind": "LinkedField",
"name": "measures",
@@ -264,15 +263,6 @@ return {
"selections": [
(v5/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v9/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -336,7 +326,7 @@ return {
},
{
"alias": null,
"args": (v10/*: any*/),
"args": (v9/*: any*/),
"filters": null,
"handle": "connection",
"key": "Risk__measures",
@@ -353,12 +343,12 @@ return {
]
},
"params": {
"cacheID": "fc9da3522dfacf8b5f887a0ebbd3e21e",
"cacheID": "295ecc0e99f46b7790fe04db0deed1d8",
"id": null,
"metadata": {},
"name": "RiskGraphNodeQuery",
"operationKind": "query",
"text": "query RiskGraphNodeQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n ... on Risk {\n name\n description\n treatment\n owner {\n id\n fullName\n }\n note\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n ...RiskMeasuresTabFragment\n }\n id\n }\n}\n\nfragment RiskMeasuresTabFragment on Risk {\n id\n measures(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n createdAt\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n"
"text": "query RiskGraphNodeQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n ... on Risk {\n name\n description\n treatment\n owner {\n id\n fullName\n }\n note\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n ...RiskMeasuresTabFragment\n }\n id\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n\nfragment RiskMeasuresTabFragment on Risk {\n id\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}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n"
}
};
})();

View File

@@ -0,0 +1,211 @@
import {
useFragment,
useMutation,
usePreloadedQuery,
type PreloadedQuery,
} from "react-relay";
import { usePageTitle } from "@probo/hooks";
import { graphql } from "relay-runtime";
import { ControlItem, PageHeader } from "@probo/ui";
import { FrameworkLogo } from "/components/FrameworkLogo";
import { frameworkNodeQuery } from "/hooks/graph/FrameworkGraph";
import { useTranslate } from "@probo/i18n";
import { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard";
import { useParams } from "react-router";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { FrameworkGraphNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql";
import type { FrameworkDetailPageFragment$key } from "./__generated__/FrameworkDetailPageFragment.graphql";
import { LinkedDocumentsCard } from "/components/documents/LinkedDocumentsCard";
const frameworkDetailFragment = graphql`
fragment FrameworkDetailPageFragment on Framework {
id
name
description
controls(first: 100) {
edges {
node {
id
referenceId
name
description
measures(first: 100)
@connection(key: "FrameworkDetailPage_measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
}
}
}
documents(first: 100)
@connection(key: "FrameworkDetailPage_documents") {
__id
edges {
node {
id
...LinkedDocumentsCardFragment
}
}
}
}
}
}
}
`;
const attachMeasureMutation = graphql`
mutation FrameworkDetailPageAttachMutation(
$input: CreateControlMeasureMappingInput!
$connections: [ID!]!
) {
createControlMeasureMapping(input: $input) {
measureEdge @prependEdge(connections: $connections) {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
`;
const detachMeasureMutation = graphql`
mutation FrameworkDetailPageDetachMutation(
$input: DeleteControlMeasureMappingInput!
$connections: [ID!]!
) {
deleteControlMeasureMapping(input: $input) {
deletedMeasureId @deleteEdge(connections: $connections)
}
}
`;
const attachDocumentMutation = graphql`
mutation FrameworkDetailPageAttachDocumentMutation(
$input: CreateControlDocumentMappingInput!
$connections: [ID!]!
) {
createControlDocumentMapping(input: $input) {
documentEdge @prependEdge(connections: $connections) {
node {
id
...LinkedDocumentsCardFragment
}
}
}
}
`;
const detachDocumentMutation = graphql`
mutation FrameworkDetailPageDetachDocumentMutation(
$input: DeleteControlDocumentMappingInput!
$connections: [ID!]!
) {
deleteControlDocumentMapping(input: $input) {
deletedDocumentId @deleteEdge(connections: $connections)
}
}
`;
type Props = {
queryRef: PreloadedQuery<FrameworkGraphNodeQuery>;
};
export default function FrameworkDetailPage(props: Props) {
const { __ } = useTranslate();
const { controlId } = useParams<{ controlId?: string }>();
const organizationId = useOrganizationId();
const data = usePreloadedQuery(frameworkNodeQuery, props.queryRef);
const framework = useFragment<FrameworkDetailPageFragment$key>(
frameworkDetailFragment,
data.node
);
const [detachMeasure, isDetachingMeasure] = useMutation(
detachMeasureMutation
);
const [attachMeasure, isAttachingMeasure] = useMutation(
attachMeasureMutation
);
const [detachDocument, isDetachingDocument] = useMutation(
detachDocumentMutation
);
const [attachDocument, isAttachingDocument] = useMutation(
attachDocumentMutation
);
const controls = framework.controls.edges.map((edge) => edge.node);
const selectedControl = controlId
? controls.find((control) => control.id === controlId)
: controls[0];
usePageTitle(`${framework.name} | ${selectedControl?.referenceId}`);
return (
<div className="space-y-6">
<PageHeader
title={
<>
<FrameworkLogo {...framework} />
{framework.name}
</>
}
/>
<div className="text-lg font-semibold">
{__("Requirement categories")}
</div>
<div className="divide-x divide-border-low grid grid-cols-[264px_1fr]">
<div
className="space-y-1 overflow-y-auto pr-6 mr-6 sticky top-0"
style={{ maxHeight: "calc(100vh - 48px)" }}
>
{controls.map((control) => (
<ControlItem
key={control.id}
id={control.referenceId}
description={control.name ?? control.description}
to={`/organizations/${organizationId}/frameworks/${framework.id}/${control.id}`}
active={selectedControl?.id === control.id}
/>
))}
</div>
{selectedControl ? (
<div className="space-y-6">
<div className="text-xl font-medium px-[6px] py-[2px] border border-border-low rounded-lg w-max bg-active mb-3">
{selectedControl.referenceId}
</div>
<div className="text-base">{selectedControl.name}</div>
<LinkedMeasuresCard
measures={
selectedControl?.measures.edges.map((edge) => edge.node) ?? []
}
params={{ controlId: selectedControl.id }}
connectionId={selectedControl.measures.__id!}
onAttach={attachMeasure}
onDetach={detachMeasure}
disabled={isAttachingMeasure || isDetachingMeasure}
/>
<LinkedDocumentsCard
documents={
selectedControl?.documents.edges.map((edge) => edge.node) ?? []
}
params={{ controlId: selectedControl.id }}
connectionId={selectedControl.documents.__id!}
onAttach={attachDocument}
onDetach={detachDocument}
disabled={isAttachingDocument || isDetachingDocument}
/>
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-sm text-txt-secondary">
{__("No control selected")}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Card,
DropdownItem,
FileButton,
@@ -27,8 +26,8 @@ import { Link } from "react-router";
import type { FrameworksPageCardFragment$key } from "./__generated__/FrameworksPageCardFragment.graphql";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useState, type ChangeEventHandler } from "react";
import { availableFrameworks } from "@probo/helpers";
import { CreateFrameworkDialog } from "./dialogs/CreateFrameworkDialog";
import { FrameworkLogo } from "/components/FrameworkLogo";
type Props = {
queryRef: PreloadedQuery<FrameworkGraphListQuery>;
@@ -50,10 +49,6 @@ const importFrameworkMutation = graphql`
}
`;
const availableLogos = new Map(
availableFrameworks.map((framework) => [framework.name, framework.logo])
);
export default function FrameworksPage(props: Props) {
const { __ } = useTranslate();
usePageTitle(__("Frameworks"));
@@ -182,15 +177,10 @@ function FrameworkCard(props: FrameworkCardProps) {
props.connectionId
);
const { __ } = useTranslate();
const logo = availableLogos.get(framework.name);
return (
<Card padded className="p-6 bg-white rounded shadow relative">
<div className="flex justify-between mb-3">
{logo ? (
<img src={logo} alt="" className="size-12" />
) : (
<Avatar name={framework.name} size="l" className="size-12" />
)}
<FrameworkLogo {...framework} />
<ActionDropdown className="z-10 relative">
<DropdownItem
icon={IconTrashCan}

View File

@@ -0,0 +1,249 @@
/**
* @generated SignedSource<<174d7c77d0d858ecc59261174b424af2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateControlDocumentMappingInput = {
controlId: string;
documentId: string;
};
export type FrameworkDetailPageAttachDocumentMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateControlDocumentMappingInput;
};
export type FrameworkDetailPageAttachDocumentMutation$data = {
readonly createControlDocumentMapping: {
readonly documentEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsCardFragment">;
};
};
};
};
export type FrameworkDetailPageAttachDocumentMutation = {
response: FrameworkDetailPageAttachDocumentMutation$data;
variables: FrameworkDetailPageAttachDocumentMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkDetailPageAttachDocumentMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateControlDocumentMappingPayload",
"kind": "LinkedField",
"name": "createControlDocumentMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "documentEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedDocumentsCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkDetailPageAttachDocumentMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateControlDocumentMappingPayload",
"kind": "LinkedField",
"name": "createControlDocumentMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "documentEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "DocumentVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "documentEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "9100dabaa4861920eab52dc47e551b06",
"id": null,
"metadata": {},
"name": "FrameworkDetailPageAttachDocumentMutation",
"operationKind": "mutation",
"text": "mutation FrameworkDetailPageAttachDocumentMutation(\n $input: CreateControlDocumentMappingInput!\n) {\n createControlDocumentMapping(input: $input) {\n documentEdge {\n node {\n id\n ...LinkedDocumentsCardFragment\n }\n }\n }\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "26c53db439feba2b2d65c3e3a9a973c7";
export default node;

View File

@@ -0,0 +1,202 @@
/**
* @generated SignedSource<<81d008fc4116a78a92e2d925f8d6daa7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateControlMeasureMappingInput = {
controlId: string;
measureId: string;
};
export type FrameworkDetailPageAttachMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateControlMeasureMappingInput;
};
export type FrameworkDetailPageAttachMutation$data = {
readonly createControlMeasureMapping: {
readonly measureEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
};
};
};
export type FrameworkDetailPageAttachMutation = {
response: FrameworkDetailPageAttachMutation$data;
variables: FrameworkDetailPageAttachMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkDetailPageAttachMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateControlMeasureMappingPayload",
"kind": "LinkedField",
"name": "createControlMeasureMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "measureEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedMeasuresCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkDetailPageAttachMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateControlMeasureMappingPayload",
"kind": "LinkedField",
"name": "createControlMeasureMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "measureEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "measureEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "9fcb599cd78f3108d6d71aa571d7005d",
"id": null,
"metadata": {},
"name": "FrameworkDetailPageAttachMutation",
"operationKind": "mutation",
"text": "mutation FrameworkDetailPageAttachMutation(\n $input: CreateControlMeasureMappingInput!\n) {\n createControlMeasureMapping(input: $input) {\n measureEdge {\n node {\n id\n ...LinkedMeasuresCardFragment\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n"
}
};
})();
(node as any).hash = "d3834b8c2656d347e13189469894be61";
export default node;

View File

@@ -0,0 +1,133 @@
/**
* @generated SignedSource<<6f2850042c0413963bd82f90236fe220>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteControlDocumentMappingInput = {
controlId: string;
documentId: string;
};
export type FrameworkDetailPageDetachDocumentMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteControlDocumentMappingInput;
};
export type FrameworkDetailPageDetachDocumentMutation$data = {
readonly deleteControlDocumentMapping: {
readonly deletedDocumentId: string;
};
};
export type FrameworkDetailPageDetachDocumentMutation = {
response: FrameworkDetailPageDetachDocumentMutation$data;
variables: FrameworkDetailPageDetachDocumentMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedDocumentId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkDetailPageDetachDocumentMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteControlDocumentMappingPayload",
"kind": "LinkedField",
"name": "deleteControlDocumentMapping",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkDetailPageDetachDocumentMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteControlDocumentMappingPayload",
"kind": "LinkedField",
"name": "deleteControlDocumentMapping",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedDocumentId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "53618baa1678e3a1b496a08597bdd364",
"id": null,
"metadata": {},
"name": "FrameworkDetailPageDetachDocumentMutation",
"operationKind": "mutation",
"text": "mutation FrameworkDetailPageDetachDocumentMutation(\n $input: DeleteControlDocumentMappingInput!\n) {\n deleteControlDocumentMapping(input: $input) {\n deletedDocumentId\n }\n}\n"
}
};
})();
(node as any).hash = "233b182104744a67a21d21d60b6df67a";
export default node;

View File

@@ -0,0 +1,133 @@
/**
* @generated SignedSource<<ff996c0902be9e7b8c23c9dd3d0f3759>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteControlMeasureMappingInput = {
controlId: string;
measureId: string;
};
export type FrameworkDetailPageDetachMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteControlMeasureMappingInput;
};
export type FrameworkDetailPageDetachMutation$data = {
readonly deleteControlMeasureMapping: {
readonly deletedMeasureId: string;
};
};
export type FrameworkDetailPageDetachMutation = {
response: FrameworkDetailPageDetachMutation$data;
variables: FrameworkDetailPageDetachMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedMeasureId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkDetailPageDetachMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteControlMeasureMappingPayload",
"kind": "LinkedField",
"name": "deleteControlMeasureMapping",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkDetailPageDetachMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteControlMeasureMappingPayload",
"kind": "LinkedField",
"name": "deleteControlMeasureMapping",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedMeasureId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "0bede5dc29caa4e2aba9b0dd96e58014",
"id": null,
"metadata": {},
"name": "FrameworkDetailPageDetachMutation",
"operationKind": "mutation",
"text": "mutation FrameworkDetailPageDetachMutation(\n $input: DeleteControlMeasureMappingInput!\n) {\n deleteControlMeasureMapping(input: $input) {\n deletedMeasureId\n }\n}\n"
}
};
})();
(node as any).hash = "96945bf8e909e7425af2297f7ee6ea0a";
export default node;

View File

@@ -0,0 +1,288 @@
/**
* @generated SignedSource<<15cc2290d512a0cdde5241edd045d3f5>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type FrameworkDetailPageFragment$data = {
readonly controls: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly description: string;
readonly documents: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsCardFragment">;
};
}>;
};
readonly id: string;
readonly measures: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
}>;
};
readonly name: string;
readonly referenceId: string;
};
}>;
};
readonly description: string;
readonly id: string;
readonly name: string;
readonly " $fragmentType": "FrameworkDetailPageFragment";
};
export type FrameworkDetailPageFragment$key = {
readonly " $data"?: FrameworkDetailPageFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"FrameworkDetailPageFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"count": null,
"cursor": null,
"direction": "forward",
"path": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
v7 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"connection": [
(v0/*: any*/),
(v0/*: any*/)
]
},
"name": "FrameworkDetailPageFragment",
"selections": [
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"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": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "referenceId",
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/),
{
"alias": "measures",
"args": null,
"concreteType": "MeasureConnection",
"kind": "LinkedField",
"name": "__FrameworkDetailPage_measures_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedMeasuresCardFragment"
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/)
],
"storageKey": null
},
(v6/*: any*/),
(v7/*: any*/)
],
"storageKey": null
},
{
"alias": "documents",
"args": null,
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "__FrameworkDetailPage_documents_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedDocumentsCardFragment"
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/)
],
"storageKey": null
},
(v6/*: any*/),
(v7/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "controls(first:100)"
}
],
"type": "Framework",
"abstractKey": null
};
})();
(node as any).hash = "15b523ca06d1a1e1a80379f6d51b92e2";
export default node;

View File

@@ -67,7 +67,6 @@ export function CreateFrameworkDialog(props: Props) {
return (
<Dialog
defaultOpen
ref={props.ref}
title={<Breadcrumb items={[__("Framework"), __("New Framework")]} />}
>

View File

@@ -1,28 +1,7 @@
import { graphql, useFragment, useMutation } from "react-relay";
import type {
RiskMeasuresTabFragment$data,
RiskMeasuresTabFragment$key,
} from "./__generated__/RiskMeasuresTabFragment.graphql";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconPlusLarge,
Table,
Thead,
Tbody,
Td,
Th,
Tr,
IconTrashCan,
} from "@probo/ui";
import {
detachMeasureMutation,
MeasureLinkDialog,
} from "/components/risks/MeasureLinkDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { RiskMeasuresTabFragment$key } from "./__generated__/RiskMeasuresTabFragment.graphql";
import { useOutletContext } from "react-router";
import type { NodeOf } from "/types";
import { useMemo } from "react";
import { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard";
const measuresFragment = graphql`
fragment RiskMeasuresTabFragment on Risk {
@@ -32,127 +11,60 @@ const measuresFragment = graphql`
edges {
node {
id
name
description
category
createdAt
state
...LinkedMeasuresCardFragment
}
}
}
}
`;
const attachMeasureMutation = graphql`
mutation RiskMeasuresTabCreateMutation(
$input: CreateRiskMeasureMappingInput!
$connections: [ID!]!
) {
createRiskMeasureMapping(input: $input) {
measureEdge @prependEdge(connections: $connections) {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
`;
export const detachMeasureMutation = graphql`
mutation RiskMeasuresTabDetachMutation(
$input: DeleteRiskMeasureMappingInput!
$connections: [ID!]!
) {
deleteRiskMeasureMapping(input: $input) {
deletedMeasureId @deleteEdge(connections: $connections)
}
}
`;
export default function RiskMeasuresTab() {
const { risk } = useOutletContext<{
risk: RiskMeasuresTabFragment$key & { id: string };
}>();
const data = useFragment(measuresFragment, risk);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const connectionId = data.measures.__id;
const measures = data.measures?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(
() => new Set(measures.map((measure) => measure.id)),
[measures]
);
if (measures.length === 0) {
return (
<div className="text-sm text-txt-secondary text-center flex flex-col gap-4 items-center justify-center py-10">
{__("No measures associated with this risk.")}
<MeasureLinkDialog
linkedIds={linkedIds}
connectionId={connectionId}
organizationId={organizationId}
riskId={data.id}
trigger={
<Button icon={IconPlusLarge} variant="quaternary">
{__("Link measure")}
</Button>
}
/>
</div>
);
}
const [detachMeasure, isDetaching] = useMutation(detachMeasureMutation);
const [attachMeasure, isAttaching] = useMutation(attachMeasureMutation);
const isLoading = isDetaching || isAttaching;
return (
<div className="relative">
<MeasureLinkDialog
linkedIds={linkedIds}
connectionId={connectionId}
organizationId={organizationId}
riskId={data.id}
trigger={
<Button
icon={IconPlusLarge}
variant="primary"
className="absolute -top-18 right-0"
>
{__("Link measure")}
</Button>
}
/>
<Table>
<Thead>
<Tr>
<Th>{__("Measure")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{measures.map((measure) => (
<MeasureRow
key={measure.id}
measure={measure}
riskId={data.id}
connectionId={connectionId}
/>
))}
</Tbody>
</Table>
</div>
);
}
function MeasureRow({
measure,
riskId,
connectionId,
}: {
measure: NodeOf<RiskMeasuresTabFragment$data["measures"]>;
riskId: string;
connectionId: string;
}) {
const { __ } = useTranslate();
const [detachMeasure, isFetching] = useMutation(detachMeasureMutation);
const onClick = () => {
detachMeasure({
variables: {
input: {
riskId: riskId,
measureId: measure.id,
},
connections: [connectionId],
},
});
};
return (
<Tr>
<Td>{measure.name}</Td>
<Td>
<Button
disabled={isFetching}
icon={IconTrashCan}
className="ml-auto"
variant="quaternary"
onClick={onClick}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
<LinkedMeasuresCard
disabled={isLoading}
measures={measures}
onAttach={attachMeasure}
onDetach={detachMeasure}
params={{ riskId: data.id }}
connectionId={connectionId}
/>
);
}

View File

@@ -0,0 +1,202 @@
/**
* @generated SignedSource<<6e27a069ae5675b9c83e369566a91d01>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateRiskMeasureMappingInput = {
measureId: string;
riskId: string;
};
export type RiskMeasuresTabCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateRiskMeasureMappingInput;
};
export type RiskMeasuresTabCreateMutation$data = {
readonly createRiskMeasureMapping: {
readonly measureEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
};
};
};
export type RiskMeasuresTabCreateMutation = {
response: RiskMeasuresTabCreateMutation$data;
variables: RiskMeasuresTabCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "RiskMeasuresTabCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskMeasureMappingPayload",
"kind": "LinkedField",
"name": "createRiskMeasureMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "measureEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedMeasuresCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "RiskMeasuresTabCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskMeasureMappingPayload",
"kind": "LinkedField",
"name": "createRiskMeasureMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "measureEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "measureEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "3b141d4117959b1f6efed67e8aa655cf",
"id": null,
"metadata": {},
"name": "RiskMeasuresTabCreateMutation",
"operationKind": "mutation",
"text": "mutation RiskMeasuresTabCreateMutation(\n $input: CreateRiskMeasureMappingInput!\n) {\n createRiskMeasureMapping(input: $input) {\n measureEdge {\n node {\n id\n ...LinkedMeasuresCardFragment\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n"
}
};
})();
(node as any).hash = "982d31266eda233518c48f1fd0cd2f46";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<327cdb825c0029c21d8812b782ecf14f>>
* @generated SignedSource<<c34aab708f8713173639ae4b7aacdf10>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,18 +13,18 @@ export type DeleteRiskMeasureMappingInput = {
measureId: string;
riskId: string;
};
export type MeasureLinkDialogDetachMutation$variables = {
export type RiskMeasuresTabDetachMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteRiskMeasureMappingInput;
};
export type MeasureLinkDialogDetachMutation$data = {
export type RiskMeasuresTabDetachMutation$data = {
readonly deleteRiskMeasureMapping: {
readonly deletedMeasureId: string;
};
};
export type MeasureLinkDialogDetachMutation = {
response: MeasureLinkDialogDetachMutation$data;
variables: MeasureLinkDialogDetachMutation$variables;
export type RiskMeasuresTabDetachMutation = {
response: RiskMeasuresTabDetachMutation$data;
variables: RiskMeasuresTabDetachMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -60,7 +60,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "MeasureLinkDialogDetachMutation",
"name": "RiskMeasuresTabDetachMutation",
"selections": [
{
"alias": null,
@@ -85,7 +85,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "MeasureLinkDialogDetachMutation",
"name": "RiskMeasuresTabDetachMutation",
"selections": [
{
"alias": null,
@@ -118,16 +118,16 @@ return {
]
},
"params": {
"cacheID": "8a54f580a892f60f24c65ec927c7340c",
"cacheID": "81260f49b2f31c4a12f87b38bab7a683",
"id": null,
"metadata": {},
"name": "MeasureLinkDialogDetachMutation",
"name": "RiskMeasuresTabDetachMutation",
"operationKind": "mutation",
"text": "mutation MeasureLinkDialogDetachMutation(\n $input: DeleteRiskMeasureMappingInput!\n) {\n deleteRiskMeasureMapping(input: $input) {\n deletedMeasureId\n }\n}\n"
"text": "mutation RiskMeasuresTabDetachMutation(\n $input: DeleteRiskMeasureMappingInput!\n) {\n deleteRiskMeasureMapping(input: $input) {\n deletedMeasureId\n }\n}\n"
}
};
})();
(node as any).hash = "e02a17e00e2c07881dcae17fef9513eb";
(node as any).hash = "c5adc6d900f0da15ec3acf7db304d410";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<62ef6617862f528f26edaffe3adc3069>>
* @generated SignedSource<<5a37b1ce3c0c17ce36fc3e1157eadf8e>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,6 @@
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
import { FragmentRefs } from "relay-runtime";
export type RiskMeasuresTabFragment$data = {
readonly id: string;
@@ -17,12 +16,8 @@ export type RiskMeasuresTabFragment$data = {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly createdAt: any;
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: MeasureState;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
}>;
};
@@ -85,39 +80,9 @@ return {
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
"kind": "FragmentSpread",
"name": "LinkedMeasuresCardFragment"
},
{
"alias": null,
@@ -185,6 +150,6 @@ return {
};
})();
(node as any).hash = "e04c5962a40b48aade0d1e4a31f0c583";
(node as any).hash = "e928c83f7fea74daa5663d6f80518419";
export default node;

View File

@@ -1,7 +1,10 @@
import { loadQuery } from "react-relay";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { relayEnvironment } from "/providers/RelayProviders";
import { frameworksQuery } from "/hooks/graph/FrameworkGraph";
import {
frameworksQuery,
frameworkNodeQuery,
} from "/hooks/graph/FrameworkGraph";
import type { AppRoute } from "/routes";
import { lazy } from "react";
@@ -15,4 +18,13 @@ export const frameworkRoutes = [
() => import("/pages/organizations/frameworks/FrameworksPage")
),
},
{
path: "frameworks/:frameworkId/:controlId?",
fallback: PageSkeleton,
queryLoader: ({ frameworkId }) =>
loadQuery(relayEnvironment, frameworkNodeQuery, { frameworkId }),
Component: lazy(
() => import("/pages/organizations/frameworks/FrameworkDetailPage")
),
},
] satisfies AppRoute[];