@@ -27,6 +27,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lexical": "^0.27.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
graphql,
|
||||
PreloadedQuery,
|
||||
usePreloadedQuery,
|
||||
useQueryLoader,
|
||||
} from "react-relay";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileText } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Search,
|
||||
Clock,
|
||||
Filter,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql";
|
||||
|
||||
const PolicyListPageQuery = graphql`
|
||||
@@ -21,6 +44,7 @@ const PolicyListPageQuery = graphql`
|
||||
node {
|
||||
id
|
||||
name
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
status
|
||||
@@ -34,41 +58,102 @@ const PolicyListPageQuery = graphql`
|
||||
|
||||
function PolicyCard({
|
||||
title,
|
||||
icon,
|
||||
content,
|
||||
status,
|
||||
updatedAt,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
content?: string;
|
||||
status?: string;
|
||||
updatedAt: 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>
|
||||
const formattedUpdatedAt = new Date(updatedAt);
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
// Extract a short description from the content and strip HTML tags
|
||||
const stripHtmlTags = (html: string) => {
|
||||
// First remove HTML tags
|
||||
const withoutTags = html.replace(/<[^>]*>/g, "");
|
||||
// Then decode HTML entities
|
||||
const decoded = withoutTags
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ");
|
||||
// Remove markdown headers
|
||||
return decoded.replace(/#.*?\n/, "").trim();
|
||||
};
|
||||
|
||||
const description = content
|
||||
? stripHtmlTags(content).substring(0, 120) +
|
||||
(content.length > 120 ? "..." : "")
|
||||
: "No description available";
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border bg-card transition-all hover:shadow-md h-full flex flex-col">
|
||||
<CardContent className="p-6 flex-grow">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="font-semibold text-xl">{title}</h3>
|
||||
{status && (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
<Badge
|
||||
className={`${
|
||||
status === "ACTIVE"
|
||||
? "bg-green-100 text-green-700"
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: status === "DRAFT"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
? "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{status === "ACTIVE"
|
||||
? "Active"
|
||||
? "Security"
|
||||
: status === "DRAFT"
|
||||
? "Draft"
|
||||
: status}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm line-clamp-3 mb-4">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-auto">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Last updated: {format(formattedUpdatedAt, "yyyy-MM-dd")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-0 border-t">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<Button variant="ghost" className="rounded-none h-12 border-r">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
View Policy
|
||||
</Button>
|
||||
<Button variant="ghost" className="rounded-none h-12">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +171,53 @@ function PolicyListPageContent({
|
||||
const policies =
|
||||
data.organization.policies?.edges.map((edge) => edge?.node) ?? [];
|
||||
|
||||
// State for search, filtering and sorting
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [sortBy, setSortBy] = useState("name-asc");
|
||||
|
||||
// Filter and sort policies
|
||||
const filteredPolicies = policies
|
||||
.filter((policy) => {
|
||||
// Filter by search query
|
||||
const matchesSearch = policy.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
|
||||
// Filter by status
|
||||
const matchesStatus =
|
||||
statusFilter === "ALL" || policy.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort policies
|
||||
switch (sortBy) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
case "updated-desc":
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
case "updated-asc":
|
||||
return (
|
||||
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
);
|
||||
case "created-desc":
|
||||
return (
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
case "created-asc":
|
||||
return (
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -106,25 +238,118 @@ function PolicyListPageContent({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{policies.map((policy) => (
|
||||
<Link
|
||||
key={policy.id}
|
||||
to={`/organizations/${organizationId}/policies/${policy.id}`}
|
||||
>
|
||||
<PolicyCard
|
||||
title={policy.name}
|
||||
icon={
|
||||
<div className="flex size-full items-center justify-center rounded-full bg-blue-100">
|
||||
<FileText className="h-8 w-8 text-blue-900" />
|
||||
</div>
|
||||
}
|
||||
status={policy.status}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search policies..."
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy("name-asc")}>
|
||||
Name (A-Z)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("name-desc")}>
|
||||
Name (Z-A)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
|
||||
Recently Updated
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
|
||||
Oldest Updated
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("created-desc")}>
|
||||
Recently Created
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("created-asc")}>
|
||||
Oldest Created
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results summary */}
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
Showing {filteredPolicies.length} of {policies.length} policies
|
||||
</div>
|
||||
|
||||
{/* Policy grid */}
|
||||
<div className="space-y-6">
|
||||
{filteredPolicies.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPolicies.map((policy) => (
|
||||
<Link
|
||||
key={policy.id}
|
||||
to={`/organizations/${organizationId}/policies/${policy.id}`}
|
||||
className="group"
|
||||
>
|
||||
<PolicyCard
|
||||
title={policy.name}
|
||||
content={(policy as any).content}
|
||||
status={policy.status}
|
||||
updatedAt={policy.updatedAt}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/20">
|
||||
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
|
||||
<h3 className="text-lg font-medium">No policies found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery || statusFilter !== "ALL"
|
||||
? "Try adjusting your search or filters"
|
||||
: "Create your first policy to get started"}
|
||||
</p>
|
||||
{searchQuery || statusFilter !== "ALL" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("ALL");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/organizations/${organizationId}/policies/create`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Policy
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -133,22 +358,52 @@ function PolicyListPageContent({
|
||||
|
||||
function PolicyListPageFallback() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
|
||||
</div>
|
||||
<div className="h-10 w-36 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="bg-card/50">
|
||||
<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" />
|
||||
|
||||
{/* Search and filter controls skeleton */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 h-10 bg-muted animate-pulse rounded" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-10 w-[180px] bg-muted animate-pulse rounded" />
|
||||
<div className="h-10 w-24 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results summary skeleton */}
|
||||
<div className="mb-4 h-4 w-48 bg-muted animate-pulse rounded" />
|
||||
|
||||
{/* Policy grid skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Card key={i} className="bg-card/50 h-full flex flex-col">
|
||||
<CardContent className="p-6 flex-grow">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="h-7 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-6 w-20 bg-muted animate-pulse rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="h-4 w-full bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-full bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-2/3 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="mt-auto space-y-2">
|
||||
<div className="h-4 w-40 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-40 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
<CardFooter className="p-0 border-t">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="h-12 border-r bg-muted/20 animate-pulse" />
|
||||
<div className="h-12 bg-muted/20 animate-pulse" />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<4225a13c40221ef783ffebcfda157db9>>
|
||||
* @generated SignedSource<<a49c4b1ca92e109b1091a1431fe0c871>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -18,6 +18,7 @@ export type PolicyListPageQuery$data = {
|
||||
readonly policies?: {
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly content: string;
|
||||
readonly createdAt: string;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
@@ -87,6 +88,13 @@ v4 = [
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "content",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
@@ -242,7 +250,7 @@ return {
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "abc2b7659c3b58aa3df934d4ee3aa78a",
|
||||
"cacheID": "5aa6e466b6daa957aa9c582530e385ec",
|
||||
"id": null,
|
||||
"metadata": {
|
||||
"connection": [
|
||||
@@ -259,11 +267,11 @@ return {
|
||||
},
|
||||
"name": "PolicyListPageQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query PolicyListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n policies(first: 25) {\n edges {\n node {\n id\n name\n createdAt\n updatedAt\n status\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
|
||||
"text": "query PolicyListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n policies(first: 25) {\n edges {\n node {\n id\n name\n content\n createdAt\n updatedAt\n status\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "75262b569185ae2e6bfda8c19fd7ac9d";
|
||||
(node as any).hash = "853e3413fd1b3f8781b11a1b7679c08f";
|
||||
|
||||
export default node;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<0d5fd744f67f5e3c2d357a41e392acfb>>
|
||||
* @generated SignedSource<<be4844f007d260c332082a01fe125a57>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -16,11 +16,11 @@ export type PolicyOverviewPageQuery$variables = {
|
||||
export type PolicyOverviewPageQuery$data = {
|
||||
readonly node: {
|
||||
readonly content?: string;
|
||||
readonly createdAt?: any;
|
||||
readonly createdAt?: string;
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly status?: PolicyStatus;
|
||||
readonly updatedAt?: any;
|
||||
readonly updatedAt?: string;
|
||||
};
|
||||
};
|
||||
export type PolicyOverviewPageQuery = {
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lexical": "^0.27.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
@@ -5063,6 +5064,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
||||
Reference in New Issue
Block a user