Add pre-configured framework templates

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-04-09 11:28:55 -07:00
parent 92ed01a822
commit 85a5f3a514
6 changed files with 928 additions and 133 deletions

View File

@@ -1,22 +1,16 @@
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
ConnectionHandler,
} from "react-relay";
import { Card } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import type { FrameworkListViewQuery as FrameworkListViewQueryType } from "./__generated__/FrameworkListViewQuery.graphql";
import { Button } from "@/components/ui/button";
import { Plus, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Input } from "@/components/ui/input";
import { FrameworkListViewImportFrameworkMutation as FrameworkListViewImportFrameworkMutationType } from "./__generated__/FrameworkListViewImportFrameworkMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkListViewSkeleton } from "./FrameworkListPage";
import { FrameworkImportDropdown } from "./ImportFrameworkDialog";
const FrameworkListViewQuery = graphql`
query FrameworkListViewQuery($organizationId: ID!) {
@@ -36,124 +30,21 @@ const FrameworkListViewQuery = graphql`
}
`;
const FrameworkListViewImportFrameworkMutation = graphql`
mutation FrameworkListViewImportFrameworkMutation(
$input: ImportFrameworkInput!
$connections: [ID!]!
) {
importFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
}
}
}
}
`;
function FrameworkListViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<FrameworkListViewQueryType>;
}) {
const data = usePreloadedQuery<FrameworkListViewQueryType>(
FrameworkListViewQuery,
queryRef
);
const data = usePreloadedQuery(FrameworkListViewQuery, queryRef);
const { organizationId } = useParams();
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge?.node) ?? [];
const [importFramework] =
useMutation<FrameworkListViewImportFrameworkMutationType>(
FrameworkListViewImportFrameworkMutation
);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const handleImportClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
importFramework({
variables: {
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListView_frameworks"
),
],
input: {
organizationId: organizationId!,
file: null,
},
},
uploadables: {
"input.file": file,
},
onCompleted: () => {
setIsUploading(false);
toast({
title: "Framework imported",
description: "Framework has been imported successfully.",
variant: "default",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
onError: (error) => {
setIsUploading(false);
toast({
title: "Error importing framework",
description: error.message,
variant: "destructive",
});
},
});
};
data.organization?.frameworks?.edges?.map((edge) => edge?.node) ?? [];
return (
<PageTemplate
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4">
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
className="hidden"
/>
<Button
variant="secondary"
onClick={handleImportClick}
disabled={isUploading}
>
<Upload className="mr-2 h-4 w-4" />
Import
</Button>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
</div>
}
actions={<FrameworkImportDropdown />}
>
<div className="space-y-6">
<Card>
@@ -196,17 +87,13 @@ function FrameworkListViewContent({
}
export default function FrameworkListView() {
const { organizationId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<FrameworkListViewQueryType>(
FrameworkListViewQuery
);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
loadQuery({ organizationId: organizationId! });
return <FrameworkListViewSkeleton />;
}

View File

@@ -0,0 +1,159 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Plus, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { ConnectionHandler, graphql, useMutation } from "react-relay";
import { useToast } from "@/hooks/use-toast";
import { ImportFrameworkDialogImportFrameworkMutation } from "./__generated__/ImportFrameworkDialogImportFrameworkMutation.graphql";
import { useState } from "react";
const AVAILABLE_FRAMEWORKS = [
{ id: "ISO27001-2022", name: "ISO/IEC 27001:2022" },
{ id: "SOC2", name: "SOC 2" },
];
const importFrameworkMutation = graphql`
mutation ImportFrameworkDialogImportFrameworkMutation(
$input: ImportFrameworkInput!
$connections: [ID!]!
) {
importFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
}
}
}
}
`;
export function FrameworkImportDropdown() {
const { organizationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [loadingFramework, setLoadingFramework] = useState<string | null>(null);
const [commit, isInFlight] =
useMutation<ImportFrameworkDialogImportFrameworkMutation>(
importFrameworkMutation
);
const handleImport = async (frameworkId: string) => {
setLoadingFramework(frameworkId);
try {
const frameworkTemplate = await import(
`./../../../../../data/frameworks/${frameworkId}.json`,
{ with: { type: "json" } }
);
const file = new File(
[JSON.stringify(frameworkTemplate.default)],
`${frameworkId}.json`,
{
type: "application/json",
}
);
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListView_frameworks"
);
commit({
variables: {
input: {
organizationId: organizationId!,
file: null,
},
connections: [connectionId],
},
uploadables: {
"input.file": file,
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to import framework",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Framework imported successfully",
});
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to import framework",
variant: "destructive",
});
},
});
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error
? error.message
: "Failed to load framework template",
variant: "destructive",
});
} finally {
setLoadingFramework(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button disabled={isInFlight || loadingFramework !== null}>
{isInFlight || loadingFramework !== null ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isInFlight ? "Importing..." : "Loading..."}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
navigate(`/organizations/${organizationId}/frameworks/new`)
}
>
Custom Framework
</DropdownMenuItem>
{AVAILABLE_FRAMEWORKS.map((framework) => (
<DropdownMenuItem
key={framework.id}
onClick={() => handleImport(framework.id)}
disabled={loadingFramework === framework.id}
>
{loadingFramework === framework.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
framework.name
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c9bf877d405180bb5fe0211022bd846f>>
* @generated SignedSource<<2b831625a3ae5e59ef58f138f56c9bda>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,11 +13,11 @@ export type ImportFrameworkInput = {
file: any;
organizationId: string;
};
export type FrameworkListViewImportFrameworkMutation$variables = {
export type ImportFrameworkDialogImportFrameworkMutation$variables = {
connections: ReadonlyArray<string>;
input: ImportFrameworkInput;
};
export type FrameworkListViewImportFrameworkMutation$data = {
export type ImportFrameworkDialogImportFrameworkMutation$data = {
readonly importFramework: {
readonly frameworkEdge: {
readonly node: {
@@ -27,9 +27,9 @@ export type FrameworkListViewImportFrameworkMutation$data = {
};
};
};
export type FrameworkListViewImportFrameworkMutation = {
response: FrameworkListViewImportFrameworkMutation$data;
variables: FrameworkListViewImportFrameworkMutation$variables;
export type ImportFrameworkDialogImportFrameworkMutation = {
response: ImportFrameworkDialogImportFrameworkMutation$data;
variables: ImportFrameworkDialogImportFrameworkMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -94,7 +94,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkListViewImportFrameworkMutation",
"name": "ImportFrameworkDialogImportFrameworkMutation",
"selections": [
{
"alias": null,
@@ -119,7 +119,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkListViewImportFrameworkMutation",
"name": "ImportFrameworkDialogImportFrameworkMutation",
"selections": [
{
"alias": null,
@@ -152,16 +152,16 @@ return {
]
},
"params": {
"cacheID": "04a42349410d12247fcbf3bd99b0b625",
"cacheID": "3ff0540d8fa361dfc521ef8267c3d853",
"id": null,
"metadata": {},
"name": "FrameworkListViewImportFrameworkMutation",
"name": "ImportFrameworkDialogImportFrameworkMutation",
"operationKind": "mutation",
"text": "mutation FrameworkListViewImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n }\n }\n }\n}\n"
"text": "mutation ImportFrameworkDialogImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "9fb0921f16ca755b8130fcd8603e8933";
(node as any).hash = "14ca8dd8ef307731893baaf35eaaba65";
export default node;