diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/_components/PolicyTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/_components/PolicyTable.tsx index 43af7fe51..5c98ab428 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/_components/PolicyTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/_components/PolicyTable.tsx @@ -2,7 +2,8 @@ import type { RouterOutputs } from "@ctrlplane/api"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; import { IconDots, IconPencil, IconTrash } from "@tabler/icons-react"; import { Badge } from "@ctrlplane/ui/badge"; @@ -25,6 +26,7 @@ import { import { toast } from "@ctrlplane/ui/toast"; import type { RuleType } from "./rule-themes"; +import { urls } from "~/app/urls"; import { api } from "~/trpc/react"; import { getRuleTypeIcon, @@ -56,6 +58,7 @@ interface PolicyTableRowProps { } const PolicyTableRow: React.FC = ({ policy }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const updatePolicy = api.policy.update.useMutation(); const router = useRouter(); const [isEnabled, setIsEnabled] = useState(policy.enabled); @@ -63,6 +66,12 @@ const PolicyTableRow: React.FC = ({ policy }) => { const environmentCount = 0; const deploymentCount = 0; + const editUrl = urls + .workspace(workspaceSlug) + .policies() + .edit(policy.id) + .baseUrl(); + return ( {/* Name column */} @@ -143,10 +152,12 @@ const PolicyTableRow: React.FC = ({ policy }) => { - - - Edit - + + + + Edit + + Delete diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/approval-gates/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/approval-gates/page.tsx index c90cf5e35..4a2dee382 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/approval-gates/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/approval-gates/page.tsx @@ -120,7 +120,11 @@ const PolicyActionMenu: React.FC = ({ diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/version-conditions/_components/VersionConditionsPoliciesTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/version-conditions/_components/VersionConditionsPoliciesTable.tsx index 5ce552013..b2e90f0e3 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/version-conditions/_components/VersionConditionsPoliciesTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/version-conditions/_components/VersionConditionsPoliciesTable.tsx @@ -69,7 +69,8 @@ export const VersionConditionsPoliciesTable: React.FC< href={urls .workspace(workspaceSlug) .policies() - .edit(policy.id)} + .edit(policy.id) + .baseUrl()} > Edit Policy diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/_components/PolicyEditTabs.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/_components/PolicyEditTabs.tsx new file mode 100644 index 000000000..990366509 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/_components/PolicyEditTabs.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { IconCircleFilled } from "@tabler/icons-react"; + +import { cn } from "@ctrlplane/ui"; + +import type { PolicyTab } from "../../../create/_components/PolicyContext"; +import { urls } from "~/app/urls"; + +type TabConfig = { + id: PolicyTab; + label: string; + description: string; + href: string; +}; + +export const PolicyEditTabs: React.FC = () => { + const { workspaceSlug, policyId } = useParams<{ + workspaceSlug: string; + policyId: string; + }>(); + + const pathname = usePathname(); + + const policyEditUrls = urls + .workspace(workspaceSlug) + .policies() + .edit(policyId); + + const tabs: TabConfig[] = [ + { + id: "config", + label: "Policy Configuration", + description: "Basic policy configuration", + href: policyEditUrls.configuration(), + }, + { + id: "time-windows", + label: "Time Windows", + description: "Schedule-based deployment rules", + href: policyEditUrls.timeWindows(), + }, + { + id: "deployment-flow", + label: "Deployment Flow", + description: "Control deployment progression", + href: policyEditUrls.deploymentFlow(), + }, + { + id: "quality-security", + label: "Quality & Security", + description: "Deployment safety measures", + href: policyEditUrls.qualitySecurity(), + }, + ]; + + return ( +
+
+
+ {tabs.map((tab) => ( + + +
+
{tab.label}
+
+ {tab.description} +
+
+ + ))} +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/configuration/EditConfiguration.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/configuration/EditConfiguration.tsx new file mode 100644 index 000000000..85f7be25e --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/[policyId]/edit/configuration/EditConfiguration.tsx @@ -0,0 +1,454 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { DeploymentCondition } from "@ctrlplane/validators/deployments"; +import type { EnvironmentCondition } from "@ctrlplane/validators/environments"; +import { useRouter } from "next/navigation"; +import { IconPlus, IconTrash } from "@tabler/icons-react"; +import { z } from "zod"; + +import { Button } from "@ctrlplane/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ctrlplane/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + useFieldArray, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { Label } from "@ctrlplane/ui/label"; +import { Switch } from "@ctrlplane/ui/switch"; +import { Textarea } from "@ctrlplane/ui/textarea"; +import { deploymentCondition } from "@ctrlplane/validators/deployments"; +import { environmentCondition } from "@ctrlplane/validators/environments"; + +import { DeploymentConditionRender } from "~/app/[workspaceSlug]/(app)/_components/deployments/condition/DeploymentConditionRender"; +import { EnvironmentConditionRender } from "~/app/[workspaceSlug]/(app)/_components/environment/condition/EnvironmentConditionRender"; +import { api } from "~/trpc/react"; + +const editConfigSchema = z.object({ + name: z.string(), + description: z.string().nullable(), + priority: z.number(), + enabled: z.boolean(), + targets: z.array( + z.object({ + deploymentSelector: deploymentCondition.nullable(), + environmentSelector: environmentCondition.nullable(), + }), + ), +}); + +// Available options for environments and deployments +const ENVIRONMENTS = ["production", "staging", "development"] as const; +const DEPLOYMENTS = ["web-app", "api-service", "worker"] as const; + +const TARGET_SCOPE_OPTIONS = [ + { + value: "deployment_specific", + label: "Specific Deployments", + description: "Apply policy to selected deployments across all environments", + isDeploymentSelectorNull: false, + isEnvironmentSelectorNull: true, + }, + { + value: "environment_specific", + label: "Specific Environments", + description: "Apply policy to selected environments across all deployments", + isDeploymentSelectorNull: true, + isEnvironmentSelectorNull: false, + }, + { + value: "deployment_environment_pair", + label: "Specific Deployment-Environment Pairs", + description: + "Apply policy when both deployment conditions and environment conditions match", + isDeploymentSelectorNull: false, + isEnvironmentSelectorNull: false, + }, +]; + +export const EditConfiguration: React.FC<{ + policy: SCHEMA.Policy & { + targets: Array<{ + deploymentSelector: DeploymentCondition | null; + environmentSelector: EnvironmentCondition | null; + }>; + }; +}> = ({ policy }) => { + const form = useForm({ + schema: editConfigSchema, + defaultValues: { + name: policy.name, + description: policy.description, + priority: policy.priority, + enabled: policy.enabled, + targets: policy.targets, + }, + }); + + const updatePolicy = api.policy.update.useMutation(); + const router = useRouter(); + + const { id } = policy; + const onSubmit = form.handleSubmit((data) => + updatePolicy + .mutateAsync({ id, data }) + .then((res) => form.reset(res)) + .then(() => router.refresh()), + ); + + const { fields, append, remove, update } = useFieldArray({ + control: form.control, + name: "targets", + }); + + return ( +
+ +
+

Basic Policy Configuration

+

+ Configure the basic settings for your policy +

+
+ +
+
+

General Settings

+

+ Configure the basic policy information +

+
+ +
+ ( + + Policy Name + + + + + A unique name to identify this policy + + + + )} + /> + + ( + + Description + +