From f253a15af86de9eb8532889c57c9dc0e64026c74 Mon Sep 17 00:00:00 2001 From: gearnode Date: Wed, 5 Mar 2025 10:49:05 +0100 Subject: [PATCH] Improve policies list UI Signed-off-by: gearnode --- apps/console/package.json | 1 + apps/console/src/pages/PolicyListPage.tsx | 357 +++++++++++++++--- .../PolicyListPageQuery.graphql.ts | 16 +- .../PolicyOverviewPageQuery.graphql.ts | 6 +- package-lock.json | 11 + 5 files changed, 333 insertions(+), 58 deletions(-) diff --git a/apps/console/package.json b/apps/console/package.json index c46d18ea6..c5cad0610 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -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", diff --git a/apps/console/src/pages/PolicyListPage.tsx b/apps/console/src/pages/PolicyListPage.tsx index 1451368dc..c82ee29d3 100644 --- a/apps/console/src/pages/PolicyListPage.tsx +++ b/apps/console/src/pages/PolicyListPage.tsx @@ -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 ( - -
-
{icon}
+ const formattedUpdatedAt = new Date(updatedAt); -
-
-

{title}

+ // 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 ( + + +
+
+

{title}

{status && ( - {status === "ACTIVE" - ? "Active" + ? "Security" : status === "DRAFT" ? "Draft" : status} - + )}
+ +

+ {description} +

+ +
+ + + Last updated: {format(formattedUpdatedAt, "yyyy-MM-dd")} + +
-
+ + +
+ + +
+
); } @@ -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 ( <> @@ -106,25 +238,118 @@ function PolicyListPageContent({
-
-
- {policies.map((policy) => ( - - - -
- } - status={policy.status} - /> - - ))} + + {/* Search and filter controls */} +
+
+ + setSearchQuery(e.target.value)} + />
+ +
+ + + + + + + + setSortBy("name-asc")}> + Name (A-Z) + + setSortBy("name-desc")}> + Name (Z-A) + + setSortBy("updated-desc")}> + Recently Updated + + setSortBy("updated-asc")}> + Oldest Updated + + setSortBy("created-desc")}> + Recently Created + + setSortBy("created-asc")}> + Oldest Created + + + +
+
+ + {/* Results summary */} +
+ Showing {filteredPolicies.length} of {policies.length} policies +
+ + {/* Policy grid */} +
+ {filteredPolicies.length > 0 ? ( +
+ {filteredPolicies.map((policy) => ( + + + + ))} +
+ ) : ( +
+ +

No policies found

+

+ {searchQuery || statusFilter !== "ALL" + ? "Try adjusting your search or filters" + : "Create your first policy to get started"} +

+ {searchQuery || statusFilter !== "ALL" ? ( + + ) : ( + + )} +
+ )}
@@ -133,22 +358,52 @@ function PolicyListPageContent({ function PolicyListPageFallback() { return ( -
-
-
-
+
+
+
+
+
+
+
-
- {[1, 2, 3].map((i) => ( - - -
-
-
-
+ + {/* Search and filter controls skeleton */} +
+
+
+
+
+
+
+ + {/* Results summary skeleton */} +
+ + {/* Policy grid skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + +
+
+
+
+
+
+
+
+
+
+
+
-
+ +
+
+
+
+ ))}
diff --git a/apps/console/src/pages/__generated__/PolicyListPageQuery.graphql.ts b/apps/console/src/pages/__generated__/PolicyListPageQuery.graphql.ts index 88f4175c2..1a3fc76ab 100644 --- a/apps/console/src/pages/__generated__/PolicyListPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/PolicyListPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<4225a13c40221ef783ffebcfda157db9>> + * @generated SignedSource<> * @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; diff --git a/apps/console/src/pages/__generated__/PolicyOverviewPageQuery.graphql.ts b/apps/console/src/pages/__generated__/PolicyOverviewPageQuery.graphql.ts index 9bac43fc5..71d9f7f19 100644 --- a/apps/console/src/pages/__generated__/PolicyOverviewPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/PolicyOverviewPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<0d5fd744f67f5e3c2d357a41e392acfb>> + * @generated SignedSource<> * @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 = { diff --git a/package-lock.json b/package-lock.json index 4b9339c1d..d2bbf671d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",