Improve policies list UI

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-05 10:49:05 +01:00
parent 552e4ecc7a
commit f253a15af8
5 changed files with 333 additions and 58 deletions

View File

@@ -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",

View File

@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/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>

View File

@@ -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;

View File

@@ -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
View File

@@ -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",