Cleanup policies list

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-04-24 21:09:57 -07:00
parent b7ccdbf8b9
commit de618dd007
4 changed files with 331 additions and 276 deletions

View File

@@ -77,7 +77,7 @@ function CreatePolicyForm({
: "empty"
);
const [commitMutation] =
const [createPolicy] =
useMutation<NewPolicyViewMutation>(CreatePolicyMutation);
const handleSubmit = (e: React.FormEvent) => {
@@ -109,13 +109,13 @@ function CreatePolicyForm({
ownerId,
};
commitMutation({
createPolicy({
variables: {
input,
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"PolicyListPage_policies"
"PolicyListView_policies"
),
],
},

View File

@@ -4,35 +4,28 @@ import {
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
ConnectionHandler,
} from "react-relay";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import { Link, useParams, useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
FileText,
Search,
Clock,
Filter,
ArrowUpDown,
ChevronDown,
MoreHorizontal,
Trash2,
Eye,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import { useToast } from "@/hooks/use-toast";
import type { PolicyListViewQuery as PolicyListViewQueryType } from "./__generated__/PolicyListViewQuery.graphql";
import type { PolicyListViewDeleteMutation } from "./__generated__/PolicyListViewDeleteMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { PolicyListViewSkeleton } from "./PolicyListPage";
@@ -40,7 +33,7 @@ const PolicyListViewQuery = graphql`
query PolicyListViewQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
policies(first: 25) @connection(key: "PolicyListView_policies") {
policies(first: 100) @connection(key: "PolicyListView_policies") {
edges {
node {
id
@@ -57,105 +50,139 @@ const PolicyListViewQuery = graphql`
}
`;
function PolicyCard({
title,
content,
status,
updatedAt,
}: {
title: string;
content?: string;
status?: string;
updatedAt: string;
}) {
const formattedUpdatedAt = new Date(updatedAt);
const DeletePolicyMutation = graphql`
mutation PolicyListViewDeleteMutation(
$input: DeletePolicyInput!
$connections: [ID!]!
) {
deletePolicy(input: $input) {
deletedPolicyId @deleteEdge(connections: $connections)
}
}
`;
// 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();
function PolicyTableRow({
policy,
organizationId,
}: {
policy: {
id: string;
name: string;
content?: string;
status?: string;
updatedAt: string;
};
organizationId: string;
}) {
const navigate = useNavigate();
const { toast } = useToast();
const [isDeleting, setIsDeleting] = useState(false);
const [commitDeleteMutation] = useMutation<PolicyListViewDeleteMutation>(DeletePolicyMutation);
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (window.confirm("Are you sure you want to delete this policy? This action cannot be undone.")) {
setIsDeleting(true);
commitDeleteMutation({
variables: {
input: {
policyId: policy.id,
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"PolicyListView_policies"
),
],
},
onCompleted: (_, errors) => {
setIsDeleting(false);
if (errors) {
console.error("Error deleting policy:", errors);
toast({
title: "Error",
description: "Failed to delete policy. Please try again.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Policy deleted successfully.",
});
},
onError: (error) => {
setIsDeleting(false);
console.error("Error deleting policy:", error);
toast({
title: "Error",
description: "Failed to delete policy. Please try again.",
variant: "destructive",
});
},
});
}
};
const description = content
? stripHtmlTags(content).substring(0, 120) +
(content.length > 120 ? "..." : "")
: "No description available";
const handleView = (e: React.MouseEvent) => {
e.stopPropagation();
navigate(`/organizations/${organizationId}/policies/${policy.id}`);
};
return (
<Card className="relative overflow-hidden border 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 && (
<Badge
variant={
status === "ACTIVE"
? "success"
: status === "DRAFT"
? "warning"
: "secondary"
}
>
{status === "ACTIVE"
? "Security"
: status === "DRAFT"
? "Draft"
: status}
</Badge>
)}
</div>
<p className="text-tertiary text-sm line-clamp-3 mb-4">
{description}
</p>
<div className="flex items-center gap-2 text-sm text-tertiary mt-auto">
<Clock className="h-4 w-4" />
<span>
Last updated: {format(formattedUpdatedAt, "yyyy-MM-dd")}
</span>
</div>
<tr
className="border-t border-[#ECEFEC] hover:bg-[rgba(5,77,5,0.01)] cursor-pointer"
onClick={() => {
navigate(`/organizations/${organizationId}/policies/${policy.id}`);
}}
>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-medium text-[#141E12]">{policy.name}</span>
<span className="text-sm text-[#818780]">
Description
</span>
</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"
</td>
<td className="py-4 px-6">
<span className="text-sm text-[#141E12]">Mon, 8 Mar. 2025</span>
</td>
<td className="py-4 px-6">
<Badge
className="bg-[rgba(5,77,5,0.03)] text-[#6B716A] font-medium border-0 py-0 px-[6px] h-5 text-xs rounded-md"
>
Draft
</Badge>
</td>
<td className="py-4 px-6 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<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>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleView}>
<Eye className="mr-2 h-4 w-4" />
View
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{isDeleting ? "Deleting..." : "Delete"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
}
@@ -172,177 +199,73 @@ function PolicyListViewContent({
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 (
<PageTemplate
title="Policies"
description="Manage your organization's policies"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
Create policy
</Link>
</Button>
}
>
{/* Search and filter mesures */}
<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-tertiary" />
<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="secondary" 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-tertiary">
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.content}
status={policy.status}
updatedAt={policy.updatedAt}
{/* Policy table */}
<div className="rounded-lg border border-[#ECEFEC] overflow-hidden bg-white">
<table className="w-full">
<thead>
<tr className="bg-white text-left">
<th className="py-3 px-6 text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]">
<div className="flex items-center gap-1">
Vendor
<ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
</div>
</th>
<th className="py-3 px-6 text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]">
<div className="flex items-center gap-1">
Last update
<ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
</div>
</th>
<th className="py-3 px-6 text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]">
<div className="flex items-center gap-1">
Status
<ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
</div>
</th>
<th className="py-3 px-6 text-right text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]"></th>
</tr>
</thead>
<tbody className="bg-white">
{policies.length > 0 ? (
policies.map((policy) => (
<PolicyTableRow
key={policy.id}
policy={policy}
organizationId={organizationId!}
/>
</Link>
))}
</div>
) : (
<div className="text-center py-12 border rounded-lg bg-subtle-bg/20">
<FileText className="mx-auto h-8 w-8 text-tertiary mb-3" />
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-tertiary 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/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
<tr>
<td colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-[#818780]">
Create your first policy to get started
</p>
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/new`}>
<Plus className="mr-2 h-4 w-4" />
Create policy
</Link>
</Button>
</div>
</td>
</tr>
)}
</div>
)}
</tbody>
</table>
</div>
</PageTemplate>
);

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<605e2b8b9bc6fdd85a121a1f1904cc00>>
* @generated SignedSource<<391e3817107bb96f50aad61d20cad0a8>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -160,7 +160,7 @@ v5 = [
{
"kind": "Literal",
"name": "first",
"value": 25
"value": 100
}
];
return {
@@ -228,7 +228,7 @@ return {
"name": "policies",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "policies(first:25)"
"storageKey": "policies(first:100)"
},
{
"alias": null,
@@ -250,7 +250,7 @@ return {
]
},
"params": {
"cacheID": "361b81169a803b656d673b7b8d1c5583",
"cacheID": "6434c8135f68ac64eb2f06983ddc3595",
"id": null,
"metadata": {
"connection": [
@@ -267,11 +267,11 @@ return {
},
"name": "PolicyListViewQuery",
"operationKind": "query",
"text": "query PolicyListViewQuery(\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"
"text": "query PolicyListViewQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n policies(first: 100) {\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 = "c0ed3d1334a535f752062c6479ed3a7e";
(node as any).hash = "5567339ad9b2be90b94edc8b1a16fe1e";
export default node;