Fix some lazy suspense fallbacks

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-21 17:22:16 +04:00
parent 2f2414d8bb
commit 16e1bd9c27
15 changed files with 2193 additions and 892 deletions

View File

@@ -5,8 +5,6 @@ import { ControlOverviewPageSkeleton } from "./frameworks/controls/ControlOvervi
import { CreateControlPageSkeleton } from "./frameworks/controls/CreateControlPage";
import { UpdateControlPageSkeleton } from "./frameworks/controls/UpdateControlPage";
import { CreateFrameworkPageSkeleton } from "./frameworks/CreateFrameworkPage";
import { FrameworkListPageSkeleton } from "./frameworks/FrameworkListPage";
import { FrameworkOverviewPageSkeleton } from "./frameworks/FrameworkOverviewPage";
import { UpdateFrameworkPageSkeleton } from "./frameworks/UpdateFrameworkPage";
import "./HomePage";
import { CreatePeoplePageSkeleton } from "./people/CreatePeoplePage";
@@ -17,11 +15,13 @@ import { PolicyListPageSkeleton } from "./policies/PolicyListPage";
import { PolicyOverviewPageSkeleton } from "./policies/PolicyOverviewPage";
import { UpdatePolicyPageSkeleton } from "./policies/UpdatePolicyPage";
import { SettingsPageSkeleton } from "./SettingsPage";
import { VendorListPageSkeleton } from "./vendors/VendorListPage";
import { VendorListPage } from "./vendors/VendorListPage";
import { VendorOverviewPageSkeleton } from "./vendors/VendorOverviewPage";
import { ErrorBoundaryWithLocation } from "./ErrorBoundary";
import OrganizationLayout from "./OrganizationLayout";
import NoOrganizationLayout from "./NoOrganizationLayout";
import { FrameworkListPage } from "./frameworks/FrameworkListPage";
import { FrameworkPage } from "./frameworks/FrameworkPage";
const CreateOrganizationPage = lazy(() => import("./CreateOrganizationPage"));
const ControlOverviewPage = lazy(
@@ -36,10 +36,6 @@ const UpdateControlPage = lazy(
const CreateFrameworkPage = lazy(
() => import("./frameworks/CreateFrameworkPage")
);
const FrameworkListPage = lazy(() => import("./frameworks/FrameworkListPage"));
const FrameworkOverviewPage = lazy(
() => import("./frameworks/FrameworkOverviewPage")
);
const UpdateFrameworkPage = lazy(
() => import("./frameworks/UpdateFrameworkPage")
);
@@ -52,7 +48,7 @@ const PolicyListPage = lazy(() => import("./policies/PolicyListPage"));
const PolicyOverviewPage = lazy(() => import("./policies/PolicyOverviewPage"));
const UpdatePolicyPage = lazy(() => import("./policies/UpdatePolicyPage"));
const SettingsPage = lazy(() => import("./SettingsPage"));
const VendorListPage = lazy(() => import("./vendors/VendorListPage"));
const VendorOverviewPage = lazy(() => import("./vendors/VendorOverviewPage"));
export function OrganizationsRoutes() {
@@ -99,26 +95,8 @@ export function OrganizationsRoutes() {
</Suspense>
}
/>
<Route
path="vendors"
element={
<Suspense fallback={<VendorListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks"
element={
<Suspense fallback={<FrameworkListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route path="vendors" element={<VendorListPage />} />
<Route path="frameworks" element={<FrameworkListPage />} />
<Route
path="frameworks/create"
element={
@@ -129,16 +107,7 @@ export function OrganizationsRoutes() {
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId"
element={
<Suspense fallback={<FrameworkOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route path="frameworks/:frameworkId" element={<FrameworkPage />} />
<Route
path="frameworks/:frameworkId/update"
element={

View File

@@ -1,285 +1,12 @@
import { Suspense, useEffect, useRef, useState } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
ConnectionHandler,
} from "react-relay";
import { lazy, Suspense } from "react";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
import { useLocation } from "react-router";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import type { FrameworkListPageQuery as FrameworkListPageQueryType } from "./__generated__/FrameworkListPageQuery.graphql";
import { Button } from "@/components/ui/button";
import { Plus, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { FrameworkListPageImportFrameworkMutation as FrameworkListPageImportFrameworkMutationType } from "./__generated__/FrameworkListPageImportFrameworkMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const FrameworkListPageQuery = graphql`
query FrameworkListPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
frameworks(first: 100)
@connection(key: "FrameworkListPage_frameworks") {
edges {
node {
id
name
description
controls(first: 100) {
edges {
node {
id
state
}
}
}
createdAt
updatedAt
}
}
}
}
}
}
`;
const FrameworkListView = lazy(() => import("./FrameworkListView"));
const FrameworkListPageImportFrameworkMutation = graphql`
mutation FrameworkListPageImportFrameworkMutation(
$input: ImportFrameworkInput!
$connections: [ID!]!
) {
importFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
description
controls(first: 100) {
edges {
node {
id
state
}
}
}
createdAt
updatedAt
}
}
}
}
`;
function FrameworkCard({
title,
description,
icon,
status,
progress,
}: {
title: string;
description: string;
icon: React.ReactNode;
status?: string;
progress?: string;
}) {
return (
<Card className="relative overflow-hidden border bg-card p-6">
<div className="flex flex-col gap-4">
<div className="size-16">{icon}</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{title}</h3>
{status && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
{status}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{progress && (
<div className="flex items-center gap-2 text-sm">
<span className="size-2 rounded-full bg-yellow-400" />
{progress}
</div>
)}
</div>
</Card>
);
}
function FrameworkListPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<FrameworkListPageQueryType>;
}) {
const data = usePreloadedQuery<FrameworkListPageQueryType>(
FrameworkListPageQuery,
queryRef
);
const { organizationId } = useParams();
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge?.node) ?? [];
const [importFramework] =
useMutation<FrameworkListPageImportFrameworkMutationType>(
FrameworkListPageImportFrameworkMutation
);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
importFramework({
variables: {
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListPage_frameworks"
),
],
input: {
organizationId: organizationId!,
file: null,
},
},
uploadables: {
"input.file": file,
},
onCompleted: () => {
setIsUploading(false);
setIsImportDialogOpen(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",
});
},
});
};
return (
<PageTemplate
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4">
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Import Framework
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Framework</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="framework-file">Upload Framework File</Label>
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
/>
<p className="text-sm text-muted-foreground">
Upload a JSON file containing your framework definition.
</p>
</div>
</div>
</DialogContent>
</Dialog>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter(
(edge) => edge?.node?.state === "IMPLEMENTED"
).length;
const totalControls = framework.controls.edges.length;
return (
<Link
key={framework.id}
to={`/organizations/${organizationId}/frameworks/${framework.id}`}
>
<FrameworkCard
title={framework.name}
description={framework.description}
icon={
<div className="flex size-full items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-900">
{framework.name.split(" ")[0]}
</span>
</div>
}
status={
validatedControls === totalControls
? "Compliant"
: undefined
}
progress={
validatedControls === totalControls
? "All controls validated"
: `${validatedControls}/${totalControls} Controls validated`
}
/>
</Link>
);
})}
</div>
</div>
</PageTemplate>
);
}
export function FrameworkListPageSkeleton() {
export function FrameworkListSkeleton() {
return (
<PageTemplateSkeleton
title="Frameworks"
@@ -309,24 +36,14 @@ export function FrameworkListPageSkeleton() {
);
}
export default function FrameworkListPage() {
const [queryRef, loadQuery] = useQueryLoader<FrameworkListPageQueryType>(
FrameworkListPageQuery
);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <FrameworkListPageSkeleton />;
}
export function FrameworkListPage() {
const location = useLocation();
return (
<Suspense fallback={<FrameworkListPageSkeleton />}>
{queryRef && <FrameworkListPageContent queryRef={queryRef} />}
<Suspense key={location.pathname} fallback={<FrameworkListSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,303 @@
import { Suspense, useEffect, useRef, useState } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { FrameworkListViewImportFrameworkMutation as FrameworkListViewImportFrameworkMutationType } from "./__generated__/FrameworkListViewImportFrameworkMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkListSkeleton } from "./FrameworkListPage";
const FrameworkListViewQuery = graphql`
query FrameworkListViewQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
frameworks(first: 100)
@connection(key: "FrameworkListView_frameworks") {
edges {
node {
id
name
description
controls(first: 100) {
edges {
node {
id
state
}
}
}
createdAt
updatedAt
}
}
}
}
}
}
`;
const FrameworkListViewImportFrameworkMutation = graphql`
mutation FrameworkListViewImportFrameworkMutation(
$input: ImportFrameworkInput!
$connections: [ID!]!
) {
importFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
description
controls(first: 100) {
edges {
node {
id
state
}
}
}
createdAt
updatedAt
}
}
}
}
`;
function FrameworkCard({
title,
description,
icon,
status,
progress,
}: {
title: string;
description: string;
icon: React.ReactNode;
status?: string;
progress?: string;
}) {
return (
<Card className="relative overflow-hidden border bg-card p-6">
<div className="flex flex-col gap-4">
<div className="size-16">{icon}</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{title}</h3>
{status && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
{status}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{progress && (
<div className="flex items-center gap-2 text-sm">
<span className="size-2 rounded-full bg-yellow-400" />
{progress}
</div>
)}
</div>
</Card>
);
}
function FrameworkListViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<FrameworkListViewQueryType>;
}) {
const data = usePreloadedQuery<FrameworkListViewQueryType>(
FrameworkListViewQuery,
queryRef
);
const { organizationId } = useParams();
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge?.node) ?? [];
const [importFramework] =
useMutation<FrameworkListViewImportFrameworkMutationType>(
FrameworkListViewImportFrameworkMutation
);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
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);
setIsImportDialogOpen(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",
});
},
});
};
return (
<PageTemplate
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4">
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Import Framework
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Framework</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="framework-file">Upload Framework File</Label>
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
/>
<p className="text-sm text-muted-foreground">
Upload a JSON file containing your framework definition.
</p>
</div>
</div>
</DialogContent>
</Dialog>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter(
(edge) => edge?.node?.state === "IMPLEMENTED"
).length;
const totalControls = framework.controls.edges.length;
return (
<Link
key={framework.id}
to={`/organizations/${organizationId}/frameworks/${framework.id}`}
>
<FrameworkCard
title={framework.name}
description={framework.description}
icon={
<div className="flex size-full items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-900">
{framework.name.split(" ")[0]}
</span>
</div>
}
status={
validatedControls === totalControls
? "Compliant"
: undefined
}
progress={
validatedControls === totalControls
? "All controls validated"
: `${validatedControls}/${totalControls} Controls validated`
}
/>
</Link>
);
})}
</div>
</div>
</PageTemplate>
);
}
export default function FrameworkListView() {
const [queryRef, loadQuery] = useQueryLoader<FrameworkListViewQueryType>(
FrameworkListViewQuery
);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <FrameworkListSkeleton />;
}
return (
<Suspense fallback={<FrameworkListSkeleton />}>
{queryRef && <FrameworkListViewContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -0,0 +1,40 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent } from "@/components/ui/card";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const FrameworkView = lazy(() => import("./FrameworkView"));
export function FrameworkViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="relative mb-6">
<div className="bg-muted w-24 h-24 rounded-full animate-pulse mb-4" />
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-20 w-full bg-muted animate-pulse rounded" />
</div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</PageTemplateSkeleton>
);
}
export function FrameworkPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<FrameworkViewSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -19,18 +19,18 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import type { FrameworkViewQuery as FrameworkViewQueryType } from "./__generated__/FrameworkViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkViewSkeleton } from "./FrameworkPage";
const FrameworkOverviewPageQuery = graphql`
query FrameworkOverviewPageQuery($frameworkId: ID!) {
const FrameworkViewQuery = graphql`
query FrameworkViewQuery($frameworkId: ID!) {
node(id: $frameworkId) {
id
... on Framework {
name
description
controls(first: 100)
@connection(key: "FrameworkOverviewPage_controls") {
controls(first: 100) @connection(key: "FrameworkView_controls") {
edges {
node {
id
@@ -66,12 +66,12 @@ interface Category {
controls: Control[];
}
function FrameworkOverviewPageContent({
function FrameworkViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<FrameworkOverviewPageQueryType>;
queryRef: PreloadedQuery<FrameworkViewQueryType>;
}) {
const data = usePreloadedQuery(FrameworkOverviewPageQuery, queryRef);
const data = usePreloadedQuery(FrameworkViewQuery, queryRef);
const framework = data.node;
const controls = framework.controls?.edges.map((edge) => edge?.node) ?? [];
const navigate = useNavigate();
@@ -326,44 +326,22 @@ function FrameworkOverviewPageContent({
);
}
export function FrameworkOverviewPageSkeleton() {
return (
<PageTemplateSkeleton>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="relative mb-6">
<div className="bg-muted w-24 h-24 rounded-full animate-pulse mb-4" />
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-20 w-full bg-muted animate-pulse rounded" />
</div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</PageTemplateSkeleton>
);
}
export default function FrameworkOverviewPage() {
export default function FrameworkView() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<FrameworkOverviewPageQueryType>(
FrameworkOverviewPageQuery
);
const [queryRef, loadQuery] =
useQueryLoader<FrameworkViewQueryType>(FrameworkViewQuery);
useEffect(() => {
loadQuery({ frameworkId: frameworkId! });
}, [loadQuery, frameworkId]);
if (!queryRef) {
return <FrameworkOverviewPageSkeleton />;
return <FrameworkViewSkeleton />;
}
return (
<Suspense fallback={<FrameworkOverviewPageSkeleton />}>
{queryRef && <FrameworkOverviewPageContent queryRef={queryRef} />}
<Suspense fallback={<FrameworkViewSkeleton />}>
{queryRef && <FrameworkViewContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f8b972f4147ecb52da49f44ef9c62df1>>
* @generated SignedSource<<0d36f3f459adaca2f0abab59d9208bf9>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,11 +14,11 @@ export type ImportFrameworkInput = {
file: any;
organizationId: string;
};
export type FrameworkListPageImportFrameworkMutation$variables = {
export type FrameworkListViewImportFrameworkMutation$variables = {
connections: ReadonlyArray<string>;
input: ImportFrameworkInput;
};
export type FrameworkListPageImportFrameworkMutation$data = {
export type FrameworkListViewImportFrameworkMutation$data = {
readonly importFramework: {
readonly frameworkEdge: {
readonly node: {
@@ -39,9 +39,9 @@ export type FrameworkListPageImportFrameworkMutation$data = {
};
};
};
export type FrameworkListPageImportFrameworkMutation = {
response: FrameworkListPageImportFrameworkMutation$data;
variables: FrameworkListPageImportFrameworkMutation$variables;
export type FrameworkListViewImportFrameworkMutation = {
response: FrameworkListViewImportFrameworkMutation$data;
variables: FrameworkListViewImportFrameworkMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -175,7 +175,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkListPageImportFrameworkMutation",
"name": "FrameworkListViewImportFrameworkMutation",
"selections": [
{
"alias": null,
@@ -200,7 +200,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkListPageImportFrameworkMutation",
"name": "FrameworkListViewImportFrameworkMutation",
"selections": [
{
"alias": null,
@@ -233,16 +233,16 @@ return {
]
},
"params": {
"cacheID": "946e840d5f7bb3a33e7ee1d0e2180bb5",
"cacheID": "b225cace8b9956333d0175a7e0a4c04d",
"id": null,
"metadata": {},
"name": "FrameworkListPageImportFrameworkMutation",
"name": "FrameworkListViewImportFrameworkMutation",
"operationKind": "mutation",
"text": "mutation FrameworkListPageImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n}\n"
"text": "mutation FrameworkListViewImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "76c55c89cce3718241b2ee63f026197b";
(node as any).hash = "fa1a8aa9d16bc50eac99392d3de59f6c";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<25802163c3ca6cc24524ebbc1388cd95>>
* @generated SignedSource<<c2ec2b3948d67ed6b924494adcae63aa>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,10 +10,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type FrameworkListPageQuery$variables = {
export type FrameworkListViewQuery$variables = {
organizationId: string;
};
export type FrameworkListPageQuery$data = {
export type FrameworkListViewQuery$data = {
readonly organization: {
readonly frameworks?: {
readonly edges: ReadonlyArray<{
@@ -36,9 +36,9 @@ export type FrameworkListPageQuery$data = {
};
};
};
export type FrameworkListPageQuery = {
response: FrameworkListPageQuery$data;
variables: FrameworkListPageQuery$variables;
export type FrameworkListViewQuery = {
response: FrameworkListViewQuery$data;
variables: FrameworkListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -209,7 +209,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FrameworkListPageQuery",
"name": "FrameworkListViewQuery",
"selections": [
{
"alias": "organization",
@@ -227,7 +227,7 @@ return {
"args": null,
"concreteType": "FrameworkConnection",
"kind": "LinkedField",
"name": "__FrameworkListPage_frameworks_connection",
"name": "__FrameworkListView_frameworks_connection",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": null
@@ -247,7 +247,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FrameworkListPageQuery",
"name": "FrameworkListViewQuery",
"selections": [
{
"alias": "organization",
@@ -276,7 +276,7 @@ return {
"args": (v3/*: any*/),
"filters": null,
"handle": "connection",
"key": "FrameworkListPage_frameworks",
"key": "FrameworkListView_frameworks",
"kind": "LinkedHandle",
"name": "frameworks"
}
@@ -291,7 +291,7 @@ return {
]
},
"params": {
"cacheID": "b55da571816341f2576684823b6d67d0",
"cacheID": "88bb77e2d10398380353d5a90ba5d6d6",
"id": null,
"metadata": {
"connection": [
@@ -306,13 +306,13 @@ return {
}
]
},
"name": "FrameworkListPageQuery",
"name": "FrameworkListViewQuery",
"operationKind": "query",
"text": "query FrameworkListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n frameworks(first: 100) {\n edges {\n node {\n id\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
"text": "query FrameworkListViewQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n frameworks(first: 100) {\n edges {\n node {\n id\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "2070e0cdfaf5b1e5b4dfbb262127218b";
(node as any).hash = "e5d315fb00e4fee74b53685b6441ed54";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<d673a8265dbda865ed008ca8d8d91c38>>
* @generated SignedSource<<c36de2ad1593c2dfefe28d7d3bbbddba>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,10 +11,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type ControlImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type FrameworkOverviewPageQuery$variables = {
export type FrameworkViewQuery$variables = {
frameworkId: string;
};
export type FrameworkOverviewPageQuery$data = {
export type FrameworkViewQuery$data = {
readonly node: {
readonly controls?: {
readonly edges: ReadonlyArray<{
@@ -33,9 +33,9 @@ export type FrameworkOverviewPageQuery$data = {
readonly name?: string;
};
};
export type FrameworkOverviewPageQuery = {
response: FrameworkOverviewPageQuery$data;
variables: FrameworkOverviewPageQuery$variables;
export type FrameworkViewQuery = {
response: FrameworkViewQuery$data;
variables: FrameworkViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -174,7 +174,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FrameworkOverviewPageQuery",
"name": "FrameworkViewQuery",
"selections": [
{
"alias": null,
@@ -195,7 +195,7 @@ return {
"args": null,
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "__FrameworkOverviewPage_controls_connection",
"name": "__FrameworkView_controls_connection",
"plural": false,
"selections": (v6/*: any*/),
"storageKey": null
@@ -215,7 +215,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FrameworkOverviewPageQuery",
"name": "FrameworkViewQuery",
"selections": [
{
"alias": null,
@@ -247,7 +247,7 @@ return {
"args": (v7/*: any*/),
"filters": null,
"handle": "connection",
"key": "FrameworkOverviewPage_controls",
"key": "FrameworkView_controls",
"kind": "LinkedHandle",
"name": "controls"
}
@@ -261,7 +261,7 @@ return {
]
},
"params": {
"cacheID": "d1033e296ed81f7dd74c61e04dbc7d1f",
"cacheID": "f8edefde219c5fa1a5c7c85e287d846a",
"id": null,
"metadata": {
"connection": [
@@ -276,13 +276,13 @@ return {
}
]
},
"name": "FrameworkOverviewPageQuery",
"name": "FrameworkViewQuery",
"operationKind": "query",
"text": "query FrameworkOverviewPageQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n category\n importance\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
"text": "query FrameworkViewQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n description\n controls(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n category\n importance\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "0f64849d3b9455a880811d65870243e0";
(node as any).hash = "9e8a9b37d956ec4c9dfba88e7b1120f3";
export default node;

View File

@@ -1,462 +1,11 @@
import { Suspense, useEffect, useState, useTransition } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
usePaginationFragment,
} from "react-relay";
import { useSearchParams, useParams } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Store, ChevronRight, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import Fuse from "fuse.js";
import type { VendorListPageQuery as VendorListPageQueryType } from "./__generated__/VendorListPageQuery.graphql";
import { VendorListPageCreateVendorMutation } from "./__generated__/VendorListPageCreateVendorMutation.graphql";
import { VendorListPageDeleteVendorMutation } from "./__generated__/VendorListPageDeleteVendorMutation.graphql";
import { VendorListPagePaginationQuery } from "./__generated__/VendorListPagePaginationQuery.graphql";
import { VendorListPage_vendors$key } from "./__generated__/VendorListPage_vendors.graphql";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const ITEMS_PER_PAGE = 25;
const VendorListView = lazy(() => import("./VendorListView"));
const vendorListPageQuery = graphql`
query VendorListPageQuery(
$organizationId: ID!
$first: Int
$after: CursorKey
$last: Int
$before: CursorKey
) {
organization: node(id: $organizationId) {
id
...VendorListPage_vendors
@arguments(first: $first, after: $after, last: $last, before: $before)
}
}
`;
const vendorListFragment = graphql`
fragment VendorListPage_vendors on Organization
@refetchable(queryName: "VendorListPagePaginationQuery")
@argumentDefinitions(
first: { type: "Int" }
after: { type: "CursorKey" }
last: { type: "Int" }
before: { type: "CursorKey" }
) {
vendors(
first: $first
after: $after
last: $last
before: $before
orderBy: { direction: ASC, field: NAME }
) @connection(key: "VendorListPage_vendors") {
__id
edges {
node {
id
name
description
createdAt
updatedAt
riskTier
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const createVendorMutation = graphql`
mutation VendorListPageCreateVendorMutation(
$input: CreateVendorInput!
$connections: [ID!]!
) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
description
createdAt
updatedAt
riskTier
}
}
}
}
`;
const deleteVendorMutation = graphql`
mutation VendorListPageDeleteVendorMutation(
$input: DeleteVendorInput!
$connections: [ID!]!
) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
// Define a proper type for the vendor items
interface VendorItem {
id: string;
name: string;
createdAt: string;
}
// TODO: Remove this once we have a real list of vendors
const vendorsList: VendorItem[] = [
{ id: "1", name: "Amazon Web Services", createdAt: new Date().toISOString() },
{
id: "2",
name: "Google Cloud Platform",
createdAt: new Date().toISOString(),
},
{ id: "3", name: "Microsoft Azure", createdAt: new Date().toISOString() },
{ id: "4", name: "Salesforce", createdAt: new Date().toISOString() },
{ id: "5", name: "Slack", createdAt: new Date().toISOString() },
{ id: "6", name: "Zoom", createdAt: new Date().toISOString() },
{ id: "7", name: "Dropbox", createdAt: new Date().toISOString() },
{ id: "8", name: "Trello", createdAt: new Date().toISOString() },
{ id: "9", name: "Asana", createdAt: new Date().toISOString() },
{ id: "10", name: "Notion", createdAt: new Date().toISOString() },
{ id: "11", name: "GitHub", createdAt: new Date().toISOString() },
{ id: "12", name: "GitLab", createdAt: new Date().toISOString() },
{ id: "13", name: "Bitbucket", createdAt: new Date().toISOString() },
{ id: "14", name: "Docker", createdAt: new Date().toISOString() },
{ id: "15", name: "Kubernetes", createdAt: new Date().toISOString() },
{ id: "16", name: "Jenkins", createdAt: new Date().toISOString() },
{ id: "17", name: "CircleCI", createdAt: new Date().toISOString() },
];
function LoadAboveButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load above"}
</Button>
</div>
);
}
function LoadBelowButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load below"}
</Button>
</div>
);
}
function VendorListContent({
queryRef,
}: {
queryRef: PreloadedQuery<VendorListPageQueryType>;
}) {
const { toast } = useToast();
const data = usePreloadedQuery<VendorListPageQueryType>(
vendorListPageQuery,
queryRef
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState("");
const [filteredVendors, setFilteredVendors] = useState<VendorItem[]>([]);
const [createVendor] =
useMutation<VendorListPageCreateVendorMutation>(createVendorMutation);
const [deleteVendor] =
useMutation<VendorListPageDeleteVendorMutation>(deleteVendorMutation);
const { organizationId } = useParams();
const {
data: vendorsConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<
VendorListPagePaginationQuery,
VendorListPage_vendors$key
>(vendorListFragment, data.organization);
const vendors =
vendorsConnection.vendors.edges.map((edge) => edge.node) ?? [];
const pageInfo = vendorsConnection.vendors.pageInfo;
const fuse = new Fuse(vendorsList, {
keys: ["name"],
threshold: 0.3,
});
return (
<PageTemplate
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center gap-2 mb-4">
<Store className="h-5 w-5" />
<h3 className="font-medium">Add a vendor</h3>
</div>
<div className="flex gap-2 relative">
<Input
type="text"
placeholder="Type vendor's name"
value={searchTerm}
style={{ borderRadius: "0.3rem" }}
onChange={(e) => {
const value = e.target.value;
setSearchTerm(value);
if (value.trim() === "") {
setFilteredVendors([]);
} else {
const results = fuse.search(value).map((result) => result.item);
setFilteredVendors(results);
}
}}
/>
{searchTerm.trim() !== "" && (
<>
{filteredVendors.length > 0 ? (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] max-h-48 overflow-y-auto border bg-popover shadow-md z-10"
>
{filteredVendors.map((vendor: VendorItem) => (
<button
key={vendor.id}
className="w-full px-3 py-2 text-left hover:bg-accent"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: vendor.name,
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor added",
description:
"The vendor has been added successfully",
});
},
});
}}
>
{vendor.name}
</button>
))}
</div>
) : (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] border bg-popover shadow-md z-10"
>
<button
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: searchTerm.trim(),
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor created",
description:
"The new vendor has been created successfully",
});
},
});
}}
>
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
</button>
</div>
)}
</>
)}
</div>
</div>
<div className="space-y-2">
{vendors.map((vendor) => (
<Link
key={vendor?.id}
to={`/organizations/${organizationId}/vendors/${vendor?.id}`}
className="block"
>
<div className="flex items-center justify-between p-4 rounded-xl border bg-card hover:bg-accent/5 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{vendor?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<p className="font-medium">{vendor?.name}</p>
{vendor?.description && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{vendor.description}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
vendor.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 rounded-full px-3 py-0.5 text-xs font-medium"
: vendor?.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 rounded-full px-3 py-0.5 text-xs font-medium"
: "bg-green-100 text-green-900 rounded-full px-3 py-0.5 text-xs font-medium"
}
>
{vendor.riskTier}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
onClick={(e) => {
e.preventDefault(); // Prevent navigation
if (
window.confirm(
"Are you sure you want to delete this vendor?"
)
) {
deleteVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
vendorId: vendor.id,
},
},
onCompleted() {
toast({
title: "Vendor deleted",
description:
"The vendor has been deleted successfully",
});
},
});
}
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</Link>
))}
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</PageTemplate>
);
}
export function VendorListPageSkeleton() {
export function VendorListSkeleton() {
return (
<PageTemplateSkeleton
title="Vendors"
@@ -483,33 +32,14 @@ export function VendorListPageSkeleton() {
);
}
export default function VendorListPage() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<VendorListPageQueryType>(vendorListPageQuery);
const { organizationId } = useParams();
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
organizationId: organizationId!,
first: before ? undefined : ITEMS_PER_PAGE,
after: after || undefined,
last: before ? ITEMS_PER_PAGE : undefined,
before: before || undefined,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <VendorListPageSkeleton />;
}
export function VendorListPage() {
const location = useLocation();
return (
<Suspense fallback={<VendorListPageSkeleton />}>
<VendorListContent queryRef={queryRef} />
<Suspense key={location.pathname} fallback={<VendorListSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,489 @@
import { Suspense, useEffect, useState, useTransition } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
usePaginationFragment,
} from "react-relay";
import { useSearchParams, useParams } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Store, ChevronRight, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import Fuse from "fuse.js";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate } from "@/components/PageTemplate";
import { VendorListSkeleton } from "./VendorListPage";
import { VendorListViewQuery as VendorListViewQueryType } from "./__generated__/VendorListViewQuery.graphql";
import { VendorListViewCreateVendorMutation } from "./__generated__/VendorListViewCreateVendorMutation.graphql";
import { VendorListViewDeleteVendorMutation } from "./__generated__/VendorListViewDeleteVendorMutation.graphql";
import { VendorListViewPaginationQuery } from "./__generated__/VendorListViewPaginationQuery.graphql";
import { VendorListView_vendors$key } from "./__generated__/VendorListView_vendors.graphql";
const ITEMS_PER_PAGE = 25;
const vendorListViewQuery = graphql`
query VendorListViewQuery(
$organizationId: ID!
$first: Int
$after: CursorKey
$last: Int
$before: CursorKey
) {
organization: node(id: $organizationId) {
id
...VendorListView_vendors
@arguments(first: $first, after: $after, last: $last, before: $before)
}
}
`;
const vendorListFragment = graphql`
fragment VendorListView_vendors on Organization
@refetchable(queryName: "VendorListViewPaginationQuery")
@argumentDefinitions(
first: { type: "Int" }
after: { type: "CursorKey" }
last: { type: "Int" }
before: { type: "CursorKey" }
) {
vendors(
first: $first
after: $after
last: $last
before: $before
orderBy: { direction: ASC, field: NAME }
) @connection(key: "VendorListView_vendors") {
__id
edges {
node {
id
name
description
createdAt
updatedAt
riskTier
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const createVendorMutation = graphql`
mutation VendorListViewCreateVendorMutation(
$input: CreateVendorInput!
$connections: [ID!]!
) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
description
createdAt
updatedAt
riskTier
}
}
}
}
`;
const deleteVendorMutation = graphql`
mutation VendorListViewDeleteVendorMutation(
$input: DeleteVendorInput!
$connections: [ID!]!
) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
// Define a proper type for the vendor items
interface VendorItem {
id: string;
name: string;
createdAt: string;
}
// TODO: Remove this once we have a real list of vendors
const vendorsList: VendorItem[] = [
{ id: "1", name: "Amazon Web Services", createdAt: new Date().toISOString() },
{
id: "2",
name: "Google Cloud Platform",
createdAt: new Date().toISOString(),
},
{ id: "3", name: "Microsoft Azure", createdAt: new Date().toISOString() },
{ id: "4", name: "Salesforce", createdAt: new Date().toISOString() },
{ id: "5", name: "Slack", createdAt: new Date().toISOString() },
{ id: "6", name: "Zoom", createdAt: new Date().toISOString() },
{ id: "7", name: "Dropbox", createdAt: new Date().toISOString() },
{ id: "8", name: "Trello", createdAt: new Date().toISOString() },
{ id: "9", name: "Asana", createdAt: new Date().toISOString() },
{ id: "10", name: "Notion", createdAt: new Date().toISOString() },
{ id: "11", name: "GitHub", createdAt: new Date().toISOString() },
{ id: "12", name: "GitLab", createdAt: new Date().toISOString() },
{ id: "13", name: "Bitbucket", createdAt: new Date().toISOString() },
{ id: "14", name: "Docker", createdAt: new Date().toISOString() },
{ id: "15", name: "Kubernetes", createdAt: new Date().toISOString() },
{ id: "16", name: "Jenkins", createdAt: new Date().toISOString() },
{ id: "17", name: "CircleCI", createdAt: new Date().toISOString() },
];
function LoadAboveButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load above"}
</Button>
</div>
);
}
function LoadBelowButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load below"}
</Button>
</div>
);
}
function VendorListContent({
queryRef,
}: {
queryRef: PreloadedQuery<VendorListViewQueryType>;
}) {
const { toast } = useToast();
const data = usePreloadedQuery<VendorListViewQueryType>(
vendorListViewQuery,
queryRef
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState("");
const [filteredVendors, setFilteredVendors] = useState<VendorItem[]>([]);
const [createVendor] =
useMutation<VendorListViewCreateVendorMutation>(createVendorMutation);
const [deleteVendor] =
useMutation<VendorListViewDeleteVendorMutation>(deleteVendorMutation);
const { organizationId } = useParams();
const {
data: vendorsConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<
VendorListViewPaginationQuery,
VendorListView_vendors$key
>(vendorListFragment, data.organization);
const vendors =
vendorsConnection.vendors.edges.map((edge) => edge.node) ?? [];
const pageInfo = vendorsConnection.vendors.pageInfo;
const fuse = new Fuse(vendorsList, {
keys: ["name"],
threshold: 0.3,
});
return (
<PageTemplate
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center gap-2 mb-4">
<Store className="h-5 w-5" />
<h3 className="font-medium">Add a vendor</h3>
</div>
<div className="flex gap-2 relative">
<Input
type="text"
placeholder="Type vendor's name"
value={searchTerm}
style={{ borderRadius: "0.3rem" }}
onChange={(e) => {
const value = e.target.value;
setSearchTerm(value);
if (value.trim() === "") {
setFilteredVendors([]);
} else {
const results = fuse.search(value).map((result) => result.item);
setFilteredVendors(results);
}
}}
/>
{searchTerm.trim() !== "" && (
<>
{filteredVendors.length > 0 ? (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] max-h-48 overflow-y-auto border bg-popover shadow-md z-10"
>
{filteredVendors.map((vendor: VendorItem) => (
<button
key={vendor.id}
className="w-full px-3 py-2 text-left hover:bg-accent"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: vendor.name,
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor added",
description:
"The vendor has been added successfully",
});
},
});
}}
>
{vendor.name}
</button>
))}
</div>
) : (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] border bg-popover shadow-md z-10"
>
<button
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: searchTerm.trim(),
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor created",
description:
"The new vendor has been created successfully",
});
},
});
}}
>
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
</button>
</div>
)}
</>
)}
</div>
</div>
<div className="space-y-2">
{vendors.map((vendor) => (
<Link
key={vendor?.id}
to={`/organizations/${organizationId}/vendors/${vendor?.id}`}
className="block"
>
<div className="flex items-center justify-between p-4 rounded-xl border bg-card hover:bg-accent/5 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{vendor?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<p className="font-medium">{vendor?.name}</p>
{vendor?.description && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{vendor.description}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
vendor.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 rounded-full px-3 py-0.5 text-xs font-medium"
: vendor?.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 rounded-full px-3 py-0.5 text-xs font-medium"
: "bg-green-100 text-green-900 rounded-full px-3 py-0.5 text-xs font-medium"
}
>
{vendor.riskTier}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
onClick={(e) => {
e.preventDefault(); // Prevent navigation
if (
window.confirm(
"Are you sure you want to delete this vendor?"
)
) {
deleteVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
vendorId: vendor.id,
},
},
onCompleted() {
toast({
title: "Vendor deleted",
description:
"The vendor has been deleted successfully",
});
},
});
}
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</Link>
))}
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</PageTemplate>
);
}
export default function VendorListView() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<VendorListViewQueryType>(vendorListViewQuery);
const { organizationId } = useParams();
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
organizationId: organizationId!,
first: before ? undefined : ITEMS_PER_PAGE,
after: after || undefined,
last: before ? ITEMS_PER_PAGE : undefined,
before: before || undefined,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <VendorListSkeleton />;
}
return (
<Suspense fallback={<VendorListSkeleton />}>
<VendorListContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,209 @@
/**
* @generated SignedSource<<38a771b621d1e813bc7dbdd6d5bcf323>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RiskTier = "CRITICAL" | "GENERAL" | "SIGNIFICANT";
export type ServiceCriticality = "HIGH" | "LOW" | "MEDIUM";
export type CreateVendorInput = {
description: string;
name: string;
organizationId: string;
privacyPolicyUrl?: string | null | undefined;
riskTier: RiskTier;
serviceCriticality: ServiceCriticality;
serviceStartAt: string;
serviceTerminationAt?: string | null | undefined;
statusPageUrl?: string | null | undefined;
termsOfServiceUrl?: string | null | undefined;
};
export type VendorListViewCreateVendorMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateVendorInput;
};
export type VendorListViewCreateVendorMutation$data = {
readonly createVendor: {
readonly vendorEdge: {
readonly node: {
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly riskTier: RiskTier;
readonly updatedAt: string;
};
};
};
};
export type VendorListViewCreateVendorMutation = {
response: VendorListViewCreateVendorMutation$data;
variables: VendorListViewCreateVendorMutation$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": "VendorEdge",
"kind": "LinkedField",
"name": "vendorEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"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": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "riskTier",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "VendorListViewCreateVendorMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateVendorPayload",
"kind": "LinkedField",
"name": "createVendor",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "VendorListViewCreateVendorMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateVendorPayload",
"kind": "LinkedField",
"name": "createVendor",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "vendorEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "cedd0394889a1b00d329ed9f30dcb3b9",
"id": null,
"metadata": {},
"name": "VendorListViewCreateVendorMutation",
"operationKind": "mutation",
"text": "mutation VendorListViewCreateVendorMutation(\n $input: CreateVendorInput!\n) {\n createVendor(input: $input) {\n vendorEdge {\n node {\n id\n name\n description\n createdAt\n updatedAt\n riskTier\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "9be60a8a66ae0214cf280252be1c6b7b";
export default node;

View File

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

View File

@@ -0,0 +1,335 @@
/**
* @generated SignedSource<<e166844caf05e2dfc32321c2547cbf32>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type VendorListViewPaginationQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
};
export type VendorListViewPaginationQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"VendorListView_vendors">;
};
};
export type VendorListViewPaginationQuery = {
response: VendorListViewPaginationQuery$data;
variables: VendorListViewPaginationQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v6 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v7 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v8 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v9 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v10 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v12 = [
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "NAME"
}
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "VendorListViewPaginationQuery",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/)
],
"kind": "FragmentSpread",
"name": "VendorListView_vendors"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "VendorListViewPaginationQuery",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v10/*: any*/),
(v11/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v12/*: any*/),
"concreteType": "VendorConnection",
"kind": "LinkedField",
"name": "vendors",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "VendorEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: 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": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "riskTier",
"storageKey": null
},
(v10/*: 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": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v12/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "VendorListView_vendors",
"kind": "LinkedHandle",
"name": "vendors"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "f447e5171c495d86e5132c58b2afc8c8",
"id": null,
"metadata": {},
"name": "VendorListViewPaginationQuery",
"operationKind": "query",
"text": "query VendorListViewPaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...VendorListView_vendors_pbnwq\n id\n }\n}\n\nfragment VendorListView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n riskTier\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "96466253eae17f60ffdf08ad59719e83";
export default node;

View File

@@ -0,0 +1,337 @@
/**
* @generated SignedSource<<843c11acfe2ed3f07fe2e05237f4cb08>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type VendorListViewQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
last?: number | null | undefined;
organizationId: string;
};
export type VendorListViewQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"VendorListView_vendors">;
};
};
export type VendorListViewQuery = {
response: VendorListViewQuery$data;
variables: VendorListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
},
v5 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "NAME"
}
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "VendorListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v6/*: any*/),
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/)
],
"kind": "FragmentSpread",
"name": "VendorListView_vendors"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v4/*: any*/),
(v2/*: any*/),
(v0/*: any*/),
(v3/*: any*/),
(v1/*: any*/)
],
"kind": "Operation",
"name": "VendorListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v6/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v12/*: any*/),
"concreteType": "VendorConnection",
"kind": "LinkedField",
"name": "vendors",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "VendorEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v6/*: 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": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "riskTier",
"storageKey": null
},
(v11/*: 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": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v12/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "VendorListView_vendors",
"kind": "LinkedHandle",
"name": "vendors"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "4bef9b4458b57ed70122d2bcbb205788",
"id": null,
"metadata": {},
"name": "VendorListViewQuery",
"operationKind": "query",
"text": "query VendorListViewQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...VendorListView_vendors_pbnwq\n }\n}\n\nfragment VendorListView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n riskTier\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "ea4b2e523cecb2f3200afb17b4bd505a";
export default node;

View File

@@ -0,0 +1,262 @@
/**
* @generated SignedSource<<7b424e7690762d554e28421c32fdee78>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type RiskTier = "CRITICAL" | "GENERAL" | "SIGNIFICANT";
import { FragmentRefs } from "relay-runtime";
export type VendorListView_vendors$data = {
readonly id: string;
readonly vendors: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly riskTier: RiskTier;
readonly updatedAt: string;
};
}>;
readonly pageInfo: {
readonly endCursor: string | null | undefined;
readonly hasNextPage: boolean;
readonly hasPreviousPage: boolean;
readonly startCursor: string | null | undefined;
};
};
readonly " $fragmentType": "VendorListView_vendors";
};
export type VendorListView_vendors$key = {
readonly " $data"?: VendorListView_vendors$data;
readonly " $fragmentSpreads": FragmentRefs<"VendorListView_vendors">;
};
const node: ReaderFragment = (function(){
var v0 = [
"vendors"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": require('./VendorListViewPaginationQuery.graphql'),
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "VendorListView_vendors",
"selections": [
{
"alias": "vendors",
"args": [
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "NAME"
}
}
],
"concreteType": "VendorConnection",
"kind": "LinkedField",
"name": "__VendorListView_vendors_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "VendorEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: 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": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "riskTier",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"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": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "__VendorListView_vendors_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "96466253eae17f60ffdf08ad59719e83";
export default node;