Add frameworks listing and import

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-01 21:37:11 +02:00
committed by Sacha Al Himdani
parent 7cb84d8ce8
commit 1870e050ce
23 changed files with 1517 additions and 69 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,75 @@
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import { useCallback } from "react";
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
export const frameworksQuery = graphql`
query FrameworkGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
id
frameworks(first: 100)
@connection(key: "FrameworksListQuery_frameworks") {
__id
edges {
node {
id
...FrameworksPageCardFragment
}
}
}
}
}
}
`;
const deleteFrameworkMutation = graphql`
mutation FrameworkGraphDeleteMutation(
$input: DeleteFrameworkInput!
$connections: [ID!]!
) {
deleteFramework(input: $input) {
deletedFrameworkId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteFrameworkMutation = (
framework: { id: string; name: string },
connectionId: string
) => {
const [commitDelete, isDeleting] = useMutationWithToasts(
deleteFrameworkMutation,
{
errorMessage: "Failed to delete framework",
successMessage: "Framework deleted successfully",
}
);
const confirm = useConfirm();
const { __ } = useTranslate();
return useCallback(() => {
confirm(
() => {
return commitDelete({
variables: {
input: {
frameworkId: framework.id!,
},
connections: [connectionId],
},
});
},
{
message: sprintf(
__(
'This will permanently delete framework "%s". This action cannot be undone.'
),
framework.name
),
}
);
}, [framework, connectionId, commitDelete]);
};

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<05d197ce59adad67cfb89dff4c502c58>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteFrameworkInput = {
frameworkId: string;
};
export type FrameworkGraphDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteFrameworkInput;
};
export type FrameworkGraphDeleteMutation$data = {
readonly deleteFramework: {
readonly deletedFrameworkId: string;
};
};
export type FrameworkGraphDeleteMutation = {
response: FrameworkGraphDeleteMutation$data;
variables: FrameworkGraphDeleteMutation$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": "deletedFrameworkId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteFrameworkPayload",
"kind": "LinkedField",
"name": "deleteFramework",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteFrameworkPayload",
"kind": "LinkedField",
"name": "deleteFramework",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedFrameworkId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "761a391f497aa0e2e03d0db958089712",
"id": null,
"metadata": {},
"name": "FrameworkGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation FrameworkGraphDeleteMutation(\n $input: DeleteFrameworkInput!\n) {\n deleteFramework(input: $input) {\n deletedFrameworkId\n }\n}\n"
}
};
})();
(node as any).hash = "6ebefeb003ce3a27bd134651e1b5c94b";
export default node;

View File

@@ -0,0 +1,302 @@
/**
* @generated SignedSource<<a67eb2a88e39d7eaf96910e0318dd1bc>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type FrameworkGraphListQuery$variables = {
organizationId: string;
};
export type FrameworkGraphListQuery$data = {
readonly organization: {
readonly frameworks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"FrameworksPageCardFragment">;
};
}>;
};
readonly id?: string;
};
};
export type FrameworkGraphListQuery = {
response: FrameworkGraphListQuery$data;
variables: FrameworkGraphListQuery$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,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v5 = {
"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
},
v6 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v7 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FrameworkGraphListQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
{
"alias": "frameworks",
"args": null,
"concreteType": "FrameworkConnection",
"kind": "LinkedField",
"name": "__FrameworksListQuery_frameworks_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "FrameworksPageCardFragment"
},
(v3/*: any*/)
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FrameworkGraphListQuery",
"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": (v7/*: any*/),
"concreteType": "FrameworkConnection",
"kind": "LinkedField",
"name": "frameworks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
"storageKey": "frameworks(first:100)"
},
{
"alias": null,
"args": (v7/*: any*/),
"filters": null,
"handle": "connection",
"key": "FrameworksListQuery_frameworks",
"kind": "LinkedHandle",
"name": "frameworks"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "b31afd68395ae800a18885bca3abfa1c",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organization",
"frameworks"
]
}
]
},
"name": "FrameworkGraphListQuery",
"operationKind": "query",
"text": "query FrameworkGraphListQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n frameworks(first: 100) {\n edges {\n node {\n id\n ...FrameworksPageCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n\nfragment FrameworksPageCardFragment on Framework {\n id\n name\n description\n}\n"
}
};
})();
(node as any).hash = "4aaead183328e669aba942992db6f89c";
export default node;

View File

@@ -0,0 +1,219 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Button,
Card,
Dropdown,
DropdownItem,
FileButton,
FrameworkSelector,
IconChevronDown,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
useDialogRef,
} from "@probo/ui";
import {
graphql,
useFragment,
usePreloadedQuery,
type PreloadedQuery,
} from "react-relay";
import type { FrameworkGraphListQuery } from "/hooks/graph/__generated__/FrameworkGraphListQuery.graphql";
import {
frameworksQuery,
useDeleteFrameworkMutation,
} from "/hooks/graph/FrameworkGraph";
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";
type Props = {
queryRef: PreloadedQuery<FrameworkGraphListQuery>;
};
const importFrameworkMutation = graphql`
mutation FrameworksPageImportMutation(
$input: ImportFrameworkInput!
$connections: [ID!]!
) {
importFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
...FrameworksPageCardFragment
}
}
}
}
`;
const availableLogos = new Map(
availableFrameworks.map((framework) => [framework.name, framework.logo])
);
export default function FrameworksPage(props: Props) {
const { __ } = useTranslate();
usePageTitle(__("Frameworks"));
const data = usePreloadedQuery(frameworksQuery, props.queryRef);
const connectionId = data.organization.frameworks!.__id;
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge.node) ?? [];
const [commitImport, isImporting] = useMutationWithToasts(
importFrameworkMutation,
{
successMessage: __("Framework imported successfully"),
errorMessage: __("Failed to import framework"),
}
);
const [isUploading, setUploading] = useState(false);
const dialogRef = useDialogRef();
const importNamedFramework = async (name: string) => {
// For custom framework, open the form
if (name === "custom") {
console.log(name, dialogRef);
dialogRef.current?.open();
return;
}
// Otherwise load the JSON and send the file to the server
try {
setUploading(true);
const fileName = `${name}.json`;
const json = await fetch(`/data/frameworks/${fileName}`).then((res) =>
res.text()
);
const file = new File([json], fileName, {
type: "application/json",
});
await importFile(file);
} finally {
setUploading(false);
}
};
const importFile = (file: File) => {
return commitImport({
variables: {
input: {
organizationId: data.organization.id!,
file: null,
},
connections: [connectionId],
},
uploadables: {
"input.file": file,
},
});
};
const handleUpload: ChangeEventHandler<HTMLInputElement> = (event) => {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) {
return;
}
importFile(file).finally(() => {
input.value = "";
});
};
const isLoading = isUploading || isImporting;
return (
<div className="space-y-6">
<CreateFrameworkDialog
ref={dialogRef}
connectionId={connectionId}
organizationId={data.organization.id!}
/>
<PageHeader
title={__("Frameworks")}
description={__("Manage your compliance frameworks")}
>
<FileButton
variant="secondary"
icon={IconUpload}
onChange={handleUpload}
disabled={isLoading}
>
{__("Import")}
</FileButton>
<FrameworkSelector
onSelect={importNamedFramework}
disabled={isLoading}
/>
</PageHeader>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{frameworks.map((framework) => (
<FrameworkCard
organizationId={data.organization.id!}
connectionId={connectionId}
key={framework.id}
framework={framework}
/>
))}
</div>
</div>
);
}
const frameworkCardFragment = graphql`
fragment FrameworksPageCardFragment on Framework {
id
name
description
}
`;
type FrameworkCardProps = {
organizationId: string;
connectionId: string;
framework: FrameworksPageCardFragment$key;
};
function FrameworkCard(props: FrameworkCardProps) {
const framework = useFragment(frameworkCardFragment, props.framework);
const deleteFramework = useDeleteFrameworkMutation(
framework,
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" />
)}
<ActionDropdown className="z-10 relative">
<DropdownItem
icon={IconTrashCan}
onClick={deleteFramework}
variant="danger"
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
<h2 className="text-xl font-medium">
<Link
className="hover:underline after:absolute after:content-[''] after:inset-0"
to={`/organizations/${props.organizationId}/frameworks/${framework.id}`}
>
{framework.name}
</Link>
</h2>
<p className="text-sm text-txt-secondary">{framework.description}</p>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
/**
* @generated SignedSource<<ec6bd0c6fff930b82216d940e83d8ef0>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type FrameworksPageCardFragment$data = {
readonly description: string;
readonly id: string;
readonly name: string;
readonly " $fragmentType": "FrameworksPageCardFragment";
};
export type FrameworksPageCardFragment$key = {
readonly " $data"?: FrameworksPageCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"FrameworksPageCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "FrameworksPageCardFragment",
"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
}
],
"type": "Framework",
"abstractKey": null
};
(node as any).hash = "4481f380673963e72f88071031e37d14";
export default node;

View File

@@ -0,0 +1,202 @@
/**
* @generated SignedSource<<9436ef052ea63a9785d82ec810e810f1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ImportFrameworkInput = {
file: any;
organizationId: string;
};
export type FrameworksPageImportMutation$variables = {
connections: ReadonlyArray<string>;
input: ImportFrameworkInput;
};
export type FrameworksPageImportMutation$data = {
readonly importFramework: {
readonly frameworkEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"FrameworksPageCardFragment">;
};
};
};
};
export type FrameworksPageImportMutation = {
response: FrameworksPageImportMutation$data;
variables: FrameworksPageImportMutation$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": "FrameworksPageImportMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "ImportFrameworkPayload",
"kind": "LinkedField",
"name": "importFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "FrameworksPageCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworksPageImportMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "ImportFrameworkPayload",
"kind": "LinkedField",
"name": "importFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"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": "description",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "frameworkEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "87a38788cb2e060657b552af69acb707",
"id": null,
"metadata": {},
"name": "FrameworksPageImportMutation",
"operationKind": "mutation",
"text": "mutation FrameworksPageImportMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n ...FrameworksPageCardFragment\n }\n }\n }\n}\n\nfragment FrameworksPageCardFragment on Framework {\n id\n name\n description\n}\n"
}
};
})();
(node as any).hash = "e2ff0f6d6780cf967a021a093542dab9";
export default node;

View File

@@ -0,0 +1,105 @@
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Textarea,
useDialogRef,
type DialogRef,
} from "@probo/ui";
import type { RefObject } from "react";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
const createFrameworkMutation = graphql`
mutation CreateFrameworkDialogMutation(
$input: CreateFrameworkInput!
$connections: [ID!]!
) {
createFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
...FrameworksPageCardFragment
}
}
}
}
`;
type Props = {
connectionId: string;
organizationId: string;
ref: DialogRef;
};
const schema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(255).optional(),
});
export function CreateFrameworkDialog(props: Props) {
const { __ } = useTranslate();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useFormWithSchema(schema, {});
const [commitCreate, isCreating] = useMutationWithToasts(
createFrameworkMutation,
{
successMessage: __("Framework created successfully"),
errorMessage: __("Failed to create framework"),
}
);
const onSubmit = handleSubmit(async (data) => {
await commitCreate({
variables: {
input: {
...data,
organizationId: props.organizationId,
},
connections: [props.connectionId],
},
});
reset();
props.ref.current?.close();
});
return (
<Dialog
defaultOpen
ref={props.ref}
title={<Breadcrumb items={[__("Framework"), __("New Framework")]} />}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<Input
{...register("name")}
variant="title"
required
placeholder={__("Framework title")}
/>
<Textarea
{...register("description")}
variant="ghost"
autogrow
placeholder={__("Add description")}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{__("Create framework")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,203 @@
/**
* @generated SignedSource<<a91c1ee7ea4934d7d6e524f32462832a>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateFrameworkInput = {
description: string;
name: string;
organizationId: string;
};
export type CreateFrameworkDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateFrameworkInput;
};
export type CreateFrameworkDialogMutation$data = {
readonly createFramework: {
readonly frameworkEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"FrameworksPageCardFragment">;
};
};
};
};
export type CreateFrameworkDialogMutation = {
response: CreateFrameworkDialogMutation$data;
variables: CreateFrameworkDialogMutation$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": "CreateFrameworkDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateFrameworkPayload",
"kind": "LinkedField",
"name": "createFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "FrameworksPageCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateFrameworkDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateFrameworkPayload",
"kind": "LinkedField",
"name": "createFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"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": "description",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "frameworkEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "d08bc5a14bbf8d131029da890bdab850",
"id": null,
"metadata": {},
"name": "CreateFrameworkDialogMutation",
"operationKind": "mutation",
"text": "mutation CreateFrameworkDialogMutation(\n $input: CreateFrameworkInput!\n) {\n createFramework(input: $input) {\n frameworkEdge {\n node {\n id\n ...FrameworksPageCardFragment\n }\n }\n }\n}\n\nfragment FrameworksPageCardFragment on Framework {\n id\n name\n description\n}\n"
}
};
})();
(node as any).hash = "957d00afa55875c9a6879292e8fa7bb1";
export default node;

View File

@@ -13,6 +13,8 @@ import {
Button,
IconPlusLarge,
useConfirm,
ActionDropdown,
DropdownItem,
} from "@probo/ui";
import {
useFragment,
@@ -157,14 +159,11 @@ function PolicyRow({
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
}),
{
message: sprintf(
@@ -178,51 +177,52 @@ function PolicyRow({
};
return (
<>
<Tr to={`/organizations/${organizationId}/policies/${policy.id}`}>
<Td>
<div className="flex gap-4 items-center">
<img
src="/policy.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{policy.title}
</div>
</Td>
<Td>
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
</Badge>
</Td>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={policy.owner.fullName} />
{policy.owner.fullName}
</div>
</Td>
<Td>
{dateFormat(policy.updatedAt, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
})}
</Td>
<Td>
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<Button
icon={IconTrashCan}
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
<Tr to={`/organizations/${organizationId}/policies/${policy.id}`}>
<Td>
<div className="flex gap-4 items-center">
<img
src="/policy.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
</Td>
</Tr>
</>
{policy.title}
</div>
</Td>
<Td>
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
</Badge>
</Td>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={policy.owner.fullName} />
{policy.owner.fullName}
</div>
</Td>
<Td>
{dateFormat(policy.updatedAt, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
})}
</Td>
<Td>
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -21,6 +21,7 @@ import { policiesRoutes } from "./routes/policiesRoutes.ts";
import { vendorRoutes } from "./routes/vendorRoutes.ts";
import { organizationViewQuery } from "./hooks/graph/OrganizationGraph.ts";
import { peopleRoutes } from "./routes/peopleRoutes.ts";
import { frameworkRoutes } from "./routes/frameworkRoutes.ts";
function ErrorBoundary() {
const error = useRouteError();
@@ -85,6 +86,7 @@ const routes = [
...policiesRoutes,
...peopleRoutes,
...vendorRoutes,
...frameworkRoutes,
],
},
] satisfies AppRoute[];

View File

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

View File

@@ -0,0 +1,26 @@
export const availableFrameworks = [
{
id: "ISO27001-2022",
name: "ISO 27001 (2022)",
logo: "/logos/iso27001.png",
description: "Information security management systems",
},
{
id: "SOC2",
name: "SOC 2",
logo: "/logos/soc2.png",
description: "System and Organization Controls 2",
},
{
id: "GDPR",
name: "GDPR",
logo: "/logos/gdpr.png",
description: "General Data Protection Regulation",
},
{
id: "SOX",
name: "SOX",
logo: "/logos/sox.png",
description: "Sarbanes-Oxley Act",
},
];

View File

@@ -12,3 +12,4 @@ export { randomInt } from "./number";
export { getMeasureStateLabel, measureStates } from "./measure";
export { getRole, getRoles, peopleRoles } from "./people";
export { certificationCategoryLabel, certifications } from "./certifications";
export { availableFrameworks } from "./frameworks";

View File

@@ -1,23 +1,33 @@
import clsx from "clsx";
import { tv } from "tailwind-variants";
type Props = {
name: string;
src?: string | null;
size?: "s" | "m" | "l" | "xl";
className?: string;
};
export function Avatar({ name, src, size = "m" }: Props) {
const className = clsx(
"bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center flex-none",
size === "s" && "size-5 text-xss",
size === "m" && "size-6 text-xxs",
size === "l" && "size-8 text-sm",
size === "xl" && "size-16 text-3xl",
);
if (src) {
return <img className={className} src={src} alt={name} />;
const avatar = tv({
base: "bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center flex-none",
variants: {
size: {
s: "size-5 text-xss",
m: "size-6 text-xxs",
l: "size-8 text-sm",
xl: "size-16 text-3xl",
},
},
defaultVariants: {
size: "m",
},
});
export function Avatar(props: Props) {
const className = avatar(props);
if (props.src) {
return <img className={className} src={props.src} alt={props.name} />;
}
return <div className={className}>{extractInitials(name ?? "")}</div>;
return <div className={className}>{extractInitials(props.name ?? "")}</div>;
}
function extractInitials(name: string) {

View File

@@ -74,7 +74,7 @@ export function ActionDropdown(
{...props}
toggle={
<Button
className="inline-flex"
className="inline-flex isolate z-5"
variant={props.variant ?? "tertiary"}
icon={IconDotGrid1x3Horizontal}
/>

View File

@@ -37,17 +37,22 @@ export const dialog = tv({
},
});
export type DialogRef = RefObject<{
open: () => void;
close: () => void;
} | null>;
type Props = {
trigger?: ReactNode;
title?: ReactNode;
children?: ReactNode;
defaultOpen?: boolean;
className?: string;
ref?: RefObject<{ open: () => void; close: () => void } | null>;
ref?: DialogRef;
};
export const useDialogRef = () => {
return useRef<{ open: () => void; close: () => void } | null>(null);
export const useDialogRef = (): DialogRef => {
return useRef(null);
};
export function Dialog({

View File

@@ -0,0 +1,12 @@
import { FrameworkSelector } from "./FrameworkSelector";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Molecules/FrameworkSelector",
component: FrameworkSelector,
argTypes: {},
} satisfies Meta<typeof FrameworkSelector>;
type Story = StoryObj<typeof FrameworkSelector>;
export const Default: Story = {};

View File

@@ -0,0 +1,76 @@
import { Dropdown, DropdownItem } from "../../Atoms/Dropdown/Dropdown";
import { IconChevronDown, IconPlusLarge } from "../../Atoms/Icons";
import { Button } from "../../Atoms/Button/Button";
import { availableFrameworks } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
type Framework = (typeof availableFrameworks)[number];
type Props = {
disabled?: boolean;
onSelect: (frameworkId: string) => void;
};
export function FrameworkSelector({ disabled, onSelect }: Props) {
const { __ } = useTranslate();
return (
<Dropdown
toggle={
<Button
icon={IconPlusLarge}
iconAfter={IconChevronDown}
disabled={disabled}
>
{__("New framework")}
</Button>
}
>
<FrameworkItem onClick={() => onSelect("custom")} />
{availableFrameworks.map((framework) => (
<FrameworkItem
key={framework.id}
framework={framework}
onClick={() => onSelect(framework.id)}
/>
))}
</Dropdown>
);
}
function FrameworkItem(props: { framework?: Framework; onClick: () => void }) {
const { __ } = useTranslate();
if (!props.framework) {
return (
<DropdownItem onClick={props.onClick} className="">
<div className="rounded-full size-8 bg-highlight text-txt-primary flex items-center justify-center">
<IconPlusLarge size={16} />
</div>
<div className="space-y-[2px]">
<div className="text-sm font-medium">
{__("Custom framework")}
</div>
<div className="text-xs text-txt-secondary">
{__("Start from scratch")}
</div>
</div>
</DropdownItem>
);
}
return (
<DropdownItem onClick={props.onClick} className="">
<img
src={props.framework.logo}
alt={props.framework.name}
className="size-8"
/>
<div className="space-y-[2px]">
<div className="text-sm font-medium">
{props.framework.name}
</div>
<div className="text-xs text-txt-secondary">
{props.framework.description}
</div>
</div>
</DropdownItem>
);
}

View File

@@ -47,6 +47,7 @@ export {
DialogFooter,
DialogTitle,
useDialogRef,
type DialogRef,
} from "./Molecules/Dialog/Dialog";
export { useConfirm } from "./Molecules/Dialog/ConfirmDialog";
export { RiskBadge } from "./Molecules/Badge/RiskBadge";
@@ -58,6 +59,7 @@ export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox";
export { FileButton } from "./Molecules/FileButton/FileButton";
export { MeasureBadge } from "./Molecules/Badge/MeasureBadge";
export { MeasureImplementation } from "./Molecules/MeasureImplementation/MeasureImplementation";
export { FrameworkSelector } from "./Molecules/FrameworkSelector/FrameworkSelector";
// Hooks
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";