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[];

View File

@@ -0,0 +1,27 @@
import { ControlItem } from "./ControlItem";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/ControlItem",
component: ControlItem,
argTypes: {},
} satisfies Meta<typeof ControlItem>;
type Story = StoryObj<typeof ControlItem>;
export const Default: Story = {
args: {
id: "CC1.1",
description:
"The entity obtains privacy commitments from vendors and other third parties who have access to personal information to meet the entity’s objectives related to privacy. The entity assesses those parties’ compliance on a periodic and as-needed basis and takes corrective action, if necessary.",
},
render: (args) => (
<div className="p-4 space-y-2" style={{ width: "240px" }}>
<ControlItem {...args} />
<ControlItem {...args} active />
<ControlItem {...args} />
<ControlItem {...args} />
<ControlItem {...args} />
</div>
),
};

View File

@@ -0,0 +1,49 @@
import type { HTMLAttributes } from "react";
import { Link } from "react-router";
import { tv } from "tailwind-variants";
type Props = {
active?: boolean;
id: string;
description?: string;
to: string;
} & HTMLAttributes<HTMLAnchorElement>;
const classNames = tv({
slots: {
wrapper: "block p-4 space-y-[6px] rounded-xl cursor-pointer text-start",
id: "px-[6px] py-[2px] text-base font-medium border border-border-low rounded-lg w-max",
description: "text-sm text-txt-tertiary line-clamp-3",
},
variants: {
active: {
true: {
wrapper: "bg-tertiary-pressed",
id: "bg-active",
},
false: {
wrapper: "hover:bg-tertiary-hover",
id: "bg-highlight",
},
},
},
defaultVariants: {
active: false,
},
});
export function ControlItem({ active, id, description, to, ...props }: Props) {
const {
wrapper,
id: idCls,
description: descriptionCls,
} = classNames({
active,
});
return (
<Link className={wrapper()} to={to} {...props}>
<div className={idCls()}>{id}</div>
<div className={descriptionCls()}>{description}</div>
</Link>
);
}

View File

@@ -11,7 +11,7 @@ type Story = StoryObj<typeof Markdown>;
export const Default: Story = {
args: {
children: `# Siquis facies post
content: `# Siquis facies post
## Venit Ianigenam egressus a tamen terra frater

View File

@@ -18,13 +18,6 @@ export const Default: Story = {
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};
export const WithError: Story = {
args: {
...Default.args,

View File

@@ -12,9 +12,6 @@ export default {
type Story = StoryObj<typeof Combobox>;
export const Default: Story = {
args: {
open: true,
},
render: () => {
const [items, setItems] = useState(["a", "b", "c"] as string[]);
const onSearch = (query: string) => {
@@ -22,10 +19,6 @@ export const Default: Story = {
};
return (
<>
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
Nostrum labore repellat facere voluptatum, in voluptatem eaque
quidem nam quod nesciunt repudiandae a illo non placeat nulla.
Ratione ipsa at sint?
<Combobox onSearch={onSearch}>
{items.map((item) => (
<ComboboxItem key={item}>{item}</ComboboxItem>

View File

@@ -1,34 +0,0 @@
import { Button } from "../../Atoms/Button/Button";
import { ConfirmDialog } from "./ConfirmDialog";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/ConfirmDialog",
component: ConfirmDialog,
argTypes: {},
} satisfies Meta<typeof ConfirmDialog>;
type Story = StoryObj<typeof ConfirmDialog>;
export const Default: Story = {
args: {
message:
'This will permanently delete the risk "Demo". This action cannot be undone.',
},
render() {
return (
<ConfirmDialog
message="Are you sure you want to delete this risk?"
onConfirm={() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
}}
>
<Button variant="danger">Delete this</Button>
</ConfirmDialog>
);
},
};

View File

@@ -15,7 +15,6 @@ export const Default: Story = {
return (
<Dialog
{...args}
onClose={() => {}}
trigger={<Button>Open dialog</Button>}
title="Edit profile"
>

View File

@@ -9,7 +9,9 @@ export function PageHeader({ title, description, children }: Props) {
return (
<div className="flex justify-between items-start w-full">
<div className=" space-y-1">
<h1 className="text-2xl ">{title}</h1>
<h1 className="text-2xl flex gap-4 font-semibold items-center">
{title}
</h1>
{description && (
<p className="text-sm text-txt-secondary">{description}</p>
)}

View File

@@ -33,6 +33,7 @@ export { Table, Thead, Tr, Tbody, Td, Th } from "./Atoms/Table/Table";
export { Tabs, TabLink } from "./Atoms/Tabs/Tabs";
export { Markdown } from "./Atoms/Markdown/Markdown";
export { Dropzone } from "./Atoms/Dropzone/Dropzone";
export { ControlItem } from "./Atoms/ControlItem/ControlItem";
// Molecules
export {