Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import type { UseFormReturn } from "react-hook-form";
import { createContext, useContext } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { z } from "zod";

import { trpc } from "~/api/trpc";
import { Form } from "~/components/ui/form";
import { useWorkspace } from "~/components/WorkspaceProvider";

const selectorSchema = z.object({
cel: z.string().min(1, "CEL expression is required"),
Expand All @@ -13,7 +17,7 @@ const selectorSchema = z.object({
export const policyCreateFormSchema = z.object({
name: z.string().min(1, "Policy name is required"),
description: z.string().optional(),
priority: z.number().min(0, "Priority must be greater than 0"),
priority: z.number().min(0, "Priority must be 0 or greater"),
enabled: z.boolean().default(true),
target: z.object({
deploymentSelector: selectorSchema,
Expand All @@ -33,6 +37,7 @@ export type PolicyCreateFormSchema = z.infer<typeof policyCreateFormSchema>;

type PolicyFormContextType = {
form: UseFormReturn<PolicyCreateFormSchema>;
isSubmitting: boolean;
};

const PolicyFormContext = createContext<PolicyFormContextType | null>(null);
Expand All @@ -49,10 +54,14 @@ export function usePolicyCreateForm() {
export const PolicyCreateFormContextProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const form = useForm({
const { workspace } = useWorkspace();
const navigate = useNavigate();
const utils = trpc.useUtils();
const form = useForm<PolicyCreateFormSchema>({
resolver: zodResolver(policyCreateFormSchema),
defaultValues: {
name: "",
description: "",
priority: 0,
enabled: true,
target: {
Expand All @@ -63,12 +72,41 @@ export const PolicyCreateFormContextProvider: React.FC<{
},
});

const onSubmit = form.handleSubmit(() => {
// todo
const createPolicyMutation = trpc.policies.create.useMutation({
onSuccess: () => {
toast.success("Policy created successfully");
void utils.policies.list.invalidate({ workspaceId: workspace.id });
form.reset();
navigate(`/${workspace.slug}/policies`);
},
onError: (error: unknown) => {
const message =
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
? error.message
: "Failed to create policy";
toast.error(message);
},
});

const onSubmit = form.handleSubmit(async (data) => {
await createPolicyMutation.mutateAsync({
workspaceId: workspace.id,
name: data.name,
description: data.description?.trim() || undefined,
priority: data.priority,
enabled: data.enabled,
target: data.target,
anyApproval: data.anyApproval,
});
});

const isSubmitting = createPolicyMutation.isPending;

return (
<PolicyFormContext.Provider value={{ form }}>
<PolicyFormContext.Provider value={{ form, isSubmitting }}>
<Form {...form}>
<form onSubmit={onSubmit}>{children}</form>
</Form>
Expand Down
272 changes: 268 additions & 4 deletions apps/web/app/routes/ws/policies/page.create.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
import { Loader2Icon } from "lucide-react";
import { Link } from "react-router";

import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea";
import { useWorkspace } from "~/components/WorkspaceProvider";
import CelExpressionInput from "../_components/CelExpiressionInput";
import { usePolicyCreateForm } from "./_components/create/PolicyFormContext";

export function meta() {
return [
Expand All @@ -11,16 +29,262 @@ export function meta() {
}

export default function PageCreate() {
const { workspace } = useWorkspace();
const { form, isSubmitting } = usePolicyCreateForm();
const anyApprovalEnabled = form.watch("anyApproval") != null;

const handleApprovalToggle = (checked: boolean) => {
if (checked) {
form.setValue(
"anyApproval",
{ minApprovals: 1 },
{ shouldDirty: true, shouldValidate: true },
);
} else {
form.setValue("anyApproval", undefined, {
shouldDirty: true,
shouldValidate: true,
});
form.clearErrors("anyApproval");
}
};

return (
<Card>
<CardHeader>
<CardTitle>Create New Policy</CardTitle>
</CardHeader>
<CardContent>
{/* TODO: Add policy creation form here */}
<p className="text-muted-foreground">
Policy creation form coming soon...
</p>
<div className="space-y-8">
<section className="space-y-4">
<h3 className="text-lg font-medium">Basic information</h3>

<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Require approvals for production"
{...field}
disabled={isSubmitting}
autoFocus
/>
</FormControl>
<FormDescription>A short, descriptive policy name</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Add context about when this policy should apply"
{...field}
disabled={isSubmitting}
rows={3}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
type="number"
min={0}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
Controls ordering when multiple policies apply
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border border-input p-4">
<div className="space-y-1">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable this policy for evaluations immediately
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
</section>

<section className="space-y-4">
<h3 className="text-lg font-medium">Target selectors</h3>

<FormField
control={form.control}
name="target.deploymentSelector.cel"
render={({ field }) => (
<FormItem>
<FormLabel>Deployment selector</FormLabel>
<FormControl>
<div className="rounded-md border border-input p-2">
<CelExpressionInput
height="100px"
value={field.value}
onChange={field.onChange}
placeholder='deployment.name.startsWith("api-")'
/>
</div>
</FormControl>
<FormDescription>
CEL expression to match deployments (use true for all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="target.environmentSelector.cel"
render={({ field }) => (
<FormItem>
<FormLabel>Environment selector</FormLabel>
<FormControl>
<div className="rounded-md border border-input p-2">
<CelExpressionInput
height="100px"
value={field.value}
onChange={field.onChange}
placeholder='environment.name == "production"'
/>
</div>
</FormControl>
<FormDescription>
CEL expression to match environments (use true for all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="target.resourceSelector.cel"
render={({ field }) => (
<FormItem>
<FormLabel>Resource selector</FormLabel>
<FormControl>
<div className="rounded-md border border-input p-2">
<CelExpressionInput
height="100px"
value={field.value}
onChange={field.onChange}
placeholder='resource.metadata.tier == "critical"'
/>
</div>
</FormControl>
<FormDescription>
CEL expression to match resources (use true for all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</section>

<section className="space-y-4">
<h3 className="text-lg font-medium">Approvals</h3>

<div className="flex items-center justify-between rounded-md border border-input p-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
Require approvals
</p>
<p className="text-sm text-muted-foreground">
Add an approval rule before a deployment can proceed
</p>
</div>
<Switch
checked={anyApprovalEnabled}
onCheckedChange={handleApprovalToggle}
disabled={isSubmitting}
/>
</div>

{anyApprovalEnabled ? (
<FormField
control={form.control}
name="anyApproval.minApprovals"
render={({ field }) => (
<FormItem>
<FormLabel>Minimum approvals</FormLabel>
<FormControl>
<Input
type="number"
min={1}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
Required approvals before the policy allows deployment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
</section>

<div className="flex items-center justify-end gap-3">
<Button asChild variant="outline">
<Link to={`/${workspace.slug}/policies`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Policy"
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
Expand Down
Loading
Loading