Files
probo/apps/console/src/pages/VendorListPage.tsx
gearnode 3cce6ea235 Use dedicated pagination hook
Signed-off-by: gearnode <bryan@frimin.fr>
2025-02-19 13:43:18 +01:00

434 lines
14 KiB
TypeScript

import { Suspense, useEffect, useState, useTransition } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
ConnectionHandler,
usePaginationFragment,
} from "react-relay";
import { useSearchParams } from "react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { CircleUser, Globe, Shield, Store } 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 { Helmet } from "react-helmet-async";
import { VendorListPageCreateVendorMutation } from "./__generated__/VendorListPageCreateVendorMutation.graphql";
import { VendorListPageDeleteVendorMutation } from "./__generated__/VendorListPageDeleteVendorMutation.graphql";
import { toast } from "@/hooks/use-toast";
import { VendorListPagePaginationQuery } from "./__generated__/VendorListPagePaginationQuery.graphql";
import { VendorListPage_vendors$key } from "./__generated__/VendorListPage_vendors.graphql";
const ITEMS_PER_PAGE = 25;
const vendorListPageQuery = graphql`
query VendorListPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
currentOrganization: node(id: "AZSfP_xAcAC5IAAAAAAltA") {
id
... on Organization {
...VendorListPage_vendors
}
}
}
`;
const vendorListFragment = graphql`
fragment VendorListPage_vendors on Organization
@refetchable(queryName: "VendorListPagePaginationQuery") {
id
vendors(
first: $first
after: $after
last: $last
before: $before
) @connection(key: "VendorListPage_vendors") {
edges {
node {
id
name
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const createVendorMutation = graphql`
mutation VendorListPageCreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
createdAt
updatedAt
}
}
}
}
`;
const deleteVendorMutation = graphql`
mutation VendorListPageDeleteVendorMutation($input: DeleteVendorInput!) {
deleteVendor(input: $input)
}
`;
// TODO: Remove this once we have a real list of vendors
const vendorsList = [
{ 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;
}) {
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;
}) {
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 data = usePreloadedQuery<VendorListPageQueryType>(vendorListPageQuery, queryRef);
const [searchParams, setSearchParams] = useSearchParams();
const [isPending, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState("");
const [filteredVendors, setFilteredVendors] = useState<Array<any>>([]);
const [createVendor] = useMutation<VendorListPageCreateVendorMutation>(createVendorMutation);
const [deleteVendor] = useMutation<VendorListPageDeleteVendorMutation>(deleteVendorMutation);
const {
data: vendorsConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<VendorListPagePaginationQuery, VendorListPage_vendors$key>(vendorListFragment, data.currentOrganization);
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 (
<div className="p-6 space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Vendors</h2>
<p className="text-sm text-muted-foreground">
Keep track of your company's vendors and their compliance status.
</p>
</div>
<div className="rounded-lg border p-4 shadow-sm bg-card text-card-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-10 w-10">
<AvatarFallback>+</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Add a vendor</p>
<p className="text-sm text-muted-foreground">
Search and add new vendors
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col gap-3 relative">
<Input
type="text"
placeholder="Type vendor's name"
className="w-64"
value={searchTerm}
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 className="absolute top-full mt-1 w-64 max-h-48 overflow-y-auto rounded-md border bg-white shadow-lg z-10">
{filteredVendors.map((vendor) => (
<div
key={vendor?.id}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer"
onClick={() => {
createVendor({
variables: {
connections: [ConnectionHandler.getConnectionID(data.currentOrganization.id, "VendorListPageQuery_vendors")],
input: {
organizationId: data.currentOrganization.id,
name: vendor.name,
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted(response) {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor added",
description: "The vendor has been added successfully",
});
},
});
}}
>
<div className="flex items-center gap-2">
<Store className="h-4 w-4" />
<span>{vendor.name}</span>
</div>
</div>
))}
</div>
)}
<Button variant="secondary">
Search
<span className="ml-2">→</span>
</Button>
</div>
</div>
</div>
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<div className="space-y-2">
{vendors.map((vendor) => (
<div
key={vendor?.id}
className="block hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground shadow-sm">
<Link
to={`/vendors/${vendor?.id}`}
className="flex items-center gap-4 flex-1"
>
<div className="flex items-center gap-4">
<Avatar className="h-10 w-10">
<AvatarFallback>{vendor?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{vendor?.name}
</p>
<p className="text-sm text-muted-foreground">
Vendor since{" "}
{new Date(vendor?.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</Link>
<div className="flex items-center gap-4">
<Badge variant="secondary" className="font-medium">
Active
</Badge>
<div className="flex gap-1">
<CircleUser className="h-4 w-4 text-muted-foreground" />
<Globe className="h-4 w-4 text-muted-foreground" />
<Shield className="h-4 w-4 text-muted-foreground" />
</div>
<Badge variant="outline" className="text-muted-foreground">
Not assessed
</Badge>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.preventDefault();
if (
window.confirm(
"Are you sure you want to delete this vendor?",
)
) {
deleteVendor({
variables: {
input: {
vendorId: vendor.id,
},
},
onCompleted() {
toast({
title: "Vendor deleted",
description: "The vendor has been deleted successfully",
});
},
});
}
}}
>
Delete
</Button>
</div>
</div>
</div>
))}
</div>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</div>
);
}
function VendorListFallback() {
return (
<div className="p-6 space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
);
}
export default function VendorListPage() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<VendorListPageQueryType>(vendorListPageQuery);
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
first: before ? undefined : ITEMS_PER_PAGE,
after: after || undefined,
last: before ? ITEMS_PER_PAGE : undefined,
before: before || undefined,
});
}, [loadQuery]);
if (!queryRef) {
return <VendorListFallback />;
}
return (
<>
<Helmet>
<title>Vendors - Probo Console</title>
</Helmet>
<Suspense fallback={<VendorListFallback />}>
<VendorListContent queryRef={queryRef} />
</Suspense>
</>
);
}