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

View File

@@ -4,35 +4,28 @@ import {
PreloadedQuery, PreloadedQuery,
usePreloadedQuery, usePreloadedQuery,
useQueryLoader, useQueryLoader,
useMutation,
ConnectionHandler,
} from "react-relay"; } from "react-relay";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Link, useParams, useNavigate } from "react-router";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Plus, Plus,
FileText, ChevronDown,
Search, MoreHorizontal,
Clock, Trash2,
Filter, Eye,
ArrowUpDown,
} from "lucide-react"; } from "lucide-react";
import { import { Badge } from "@/components/ui/badge";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge"; import { useToast } from "@/hooks/use-toast";
import { format } from "date-fns";
import type { PolicyListViewQuery as PolicyListViewQueryType } from "./__generated__/PolicyListViewQuery.graphql"; import type { PolicyListViewQuery as PolicyListViewQueryType } from "./__generated__/PolicyListViewQuery.graphql";
import type { PolicyListViewDeleteMutation } from "./__generated__/PolicyListViewDeleteMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate"; import { PageTemplate } from "@/components/PageTemplate";
import { PolicyListViewSkeleton } from "./PolicyListPage"; import { PolicyListViewSkeleton } from "./PolicyListPage";
@@ -40,7 +33,7 @@ const PolicyListViewQuery = graphql`
query PolicyListViewQuery($organizationId: ID!) { query PolicyListViewQuery($organizationId: ID!) {
organization: node(id: $organizationId) { organization: node(id: $organizationId) {
... on Organization { ... on Organization {
policies(first: 25) @connection(key: "PolicyListView_policies") { policies(first: 100) @connection(key: "PolicyListView_policies") {
edges { edges {
node { node {
id id
@@ -57,105 +50,139 @@ const PolicyListViewQuery = graphql`
} }
`; `;
function PolicyCard({ const DeletePolicyMutation = graphql`
title, mutation PolicyListViewDeleteMutation(
content, $input: DeletePolicyInput!
status, $connections: [ID!]!
updatedAt, ) {
}: { deletePolicy(input: $input) {
title: string; deletedPolicyId @deleteEdge(connections: $connections)
content?: string; }
status?: string; }
updatedAt: string; `;
}) {
const formattedUpdatedAt = new Date(updatedAt);
// Extract a short description from the content and strip HTML tags function PolicyTableRow({
const stripHtmlTags = (html: string) => { policy,
// First remove HTML tags organizationId,
const withoutTags = html.replace(/<[^>]*>/g, ""); }: {
// Then decode HTML entities policy: {
const decoded = withoutTags id: string;
.replace(/&amp;/g, "&") name: string;
.replace(/&lt;/g, "<") content?: string;
.replace(/&gt;/g, ">") status?: string;
.replace(/&quot;/g, '"') updatedAt: string;
.replace(/&#039;/g, "'") };
.replace(/&nbsp;/g, " "); organizationId: string;
// Remove markdown headers }) {
return decoded.replace(/#.*?\n/, "").trim(); 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 const handleView = (e: React.MouseEvent) => {
? stripHtmlTags(content).substring(0, 120) + e.stopPropagation();
(content.length > 120 ? "..." : "") navigate(`/organizations/${organizationId}/policies/${policy.id}`);
: "No description available"; };
return ( return (
<Card className="relative overflow-hidden border transition-all hover:shadow-md h-full flex flex-col"> <tr
<CardContent className="p-6 flex-grow"> className="border-t border-[#ECEFEC] hover:bg-[rgba(5,77,5,0.01)] cursor-pointer"
<div className="flex flex-col h-full"> onClick={() => {
<div className="flex justify-between items-start mb-3"> navigate(`/organizations/${organizationId}/policies/${policy.id}`);
<h3 className="font-semibold text-xl">{title}</h3> }}
{status && ( >
<Badge <td className="py-4 px-6">
variant={ <div className="flex flex-col">
status === "ACTIVE" <span className="font-medium text-[#141E12]">{policy.name}</span>
? "success" <span className="text-sm text-[#818780]">
: status === "DRAFT" Description
? "warning" </span>
: "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>
</div> </div>
</CardContent> </td>
<CardFooter className="p-0 border-t"> <td className="py-4 px-6">
<div className="w-full grid grid-cols-2"> <span className="text-sm text-[#141E12]">Mon, 8 Mar. 2025</span>
<Button variant="ghost" className="rounded-none h-12 border-r"> </td>
<FileText className="h-5 w-5 mr-2" /> <td className="py-4 px-6">
View Policy <Badge
</Button> className="bg-[rgba(5,77,5,0.03)] text-[#6B716A] font-medium border-0 py-0 px-[6px] h-5 text-xs rounded-md"
<Button variant="ghost" className="rounded-none h-12"> >
<svg Draft
xmlns="http://www.w3.org/2000/svg" </Badge>
width="20" </td>
height="20" <td className="py-4 px-6 text-right">
viewBox="0 0 24 24" <DropdownMenu>
fill="none" <DropdownMenuTrigger asChild>
stroke="currentColor" <Button
strokeWidth="2" variant="ghost"
strokeLinecap="round" size="icon"
strokeLinejoin="round" className="h-8 w-8"
className="mr-2" onClick={(e) => e.stopPropagation()}
> >
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <MoreHorizontal className="h-4 w-4" />
<polyline points="7 10 12 15 17 10" /> </Button>
<line x1="12" y1="15" x2="12" y2="3" /> </DropdownMenuTrigger>
</svg> <DropdownMenuContent align="end">
Download <DropdownMenuItem onClick={handleView}>
</Button> <Eye className="mr-2 h-4 w-4" />
</div> View
</CardFooter> </DropdownMenuItem>
</Card> <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 = const policies =
data.organization.policies?.edges.map((edge) => edge?.node) ?? []; 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 ( return (
<PageTemplate <PageTemplate
title="Policies" title="Policies"
description="Manage your organization's policies"
actions={ actions={
<Button asChild> <Button asChild>
<Link to={`/organizations/${organizationId}/policies/new`}> <Link to={`/organizations/${organizationId}/policies/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create Policy Create policy
</Link> </Link>
</Button> </Button>
} }
> >
{/* Search and filter mesures */} {/* Policy table */}
<div className="flex flex-col md:flex-row gap-4 mb-6"> <div className="rounded-lg border border-[#ECEFEC] overflow-hidden bg-white">
<div className="relative flex-1"> <table className="w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-tertiary" /> <thead>
<Input <tr className="bg-white text-left">
placeholder="Search policies..." <th className="py-3 px-6 text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]">
className="pl-10" <div className="flex items-center gap-1">
value={searchQuery} Vendor
onChange={(e) => setSearchQuery(e.target.value)} <ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
/> </div>
</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 gap-2"> <div className="flex items-center gap-1">
<Select value={statusFilter} onValueChange={setStatusFilter}> Last update
<SelectTrigger className="w-[180px]"> <ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
<div className="flex items-center gap-2"> </div>
<Filter className="h-4 w-4" /> </th>
<SelectValue placeholder="Filter by status" /> <th className="py-3 px-6 text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]">
</div> <div className="flex items-center gap-1">
</SelectTrigger> Status
<SelectContent> <ChevronDown className="h-3 w-3 text-[#C3C8C2]" />
<SelectItem value="ALL">All Statuses</SelectItem> </div>
<SelectItem value="ACTIVE">Active</SelectItem> </th>
<SelectItem value="DRAFT">Draft</SelectItem> <th className="py-3 px-6 text-right text-xs font-medium text-[#818780] border-b border-[rgba(2,42,2,0.08)]"></th>
</SelectContent> </tr>
</Select> </thead>
<tbody className="bg-white">
<DropdownMenu> {policies.length > 0 ? (
<DropdownMenuTrigger asChild> policies.map((policy) => (
<Button variant="secondary" className="flex items-center gap-2"> <PolicyTableRow
<ArrowUpDown className="h-4 w-4" /> key={policy.id}
Sort policy={policy}
</Button> organizationId={organizationId!}
</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}
/> />
</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> <tr>
<Link to={`/organizations/${organizationId}/policies/new`}> <td colSpan={4} className="py-12 text-center">
<Plus className="mr-2 h-4 w-4" /> <div className="flex flex-col items-center gap-2">
Create Policy <h3 className="text-lg font-medium">No policies found</h3>
</Link> <p className="text-[#818780]">
</Button> 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> </div>
</PageTemplate> </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 * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -160,7 +160,7 @@ v5 = [
{ {
"kind": "Literal", "kind": "Literal",
"name": "first", "name": "first",
"value": 25 "value": 100
} }
]; ];
return { return {
@@ -228,7 +228,7 @@ return {
"name": "policies", "name": "policies",
"plural": false, "plural": false,
"selections": (v4/*: any*/), "selections": (v4/*: any*/),
"storageKey": "policies(first:25)" "storageKey": "policies(first:100)"
}, },
{ {
"alias": null, "alias": null,
@@ -250,7 +250,7 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "361b81169a803b656d673b7b8d1c5583", "cacheID": "6434c8135f68ac64eb2f06983ddc3595",
"id": null, "id": null,
"metadata": { "metadata": {
"connection": [ "connection": [
@@ -267,11 +267,11 @@ return {
}, },
"name": "PolicyListViewQuery", "name": "PolicyListViewQuery",
"operationKind": "query", "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; export default node;