Skip to content
Merged
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 @@ -11,9 +11,7 @@ import {
} from "@ctrlplane/events";
import { logger } from "@ctrlplane/logger";

const log = logger.child({
component: "computeSystemsReleaseTargetsWorker",
});
const log = logger.child({ component: "computeSystemsReleaseTargetsWorker" });

const findMatchingEnvironmentDeploymentPairs = (
tx: Tx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ const markerEnd = {

export const FlowDiagram: React.FC<{
workspace: SCHEMA.Workspace;
system: { id: string };
deploymentVersion: SCHEMA.DeploymentVersion;
envs: Array<SCHEMA.Environment>;
}> = ({ workspace, deploymentVersion, envs }) => {
}> = ({ workspace, system, deploymentVersion, envs }) => {
const [nodes, _, onNodesChange] = useNodesState<{ label: string }>([
{
id: "trigger",
Expand All @@ -44,6 +45,7 @@ export const FlowDiagram: React.FC<{
deploymentId: deploymentVersion.deploymentId,
environmentId: env.id,
environmentName: env.name,
systemId: system.id,
label: env.name,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Loading, Passing, Waiting } from "../StatusIcons";

export const ApprovalCheck: React.FC<{
workspaceId: string;
systemId: string;
environmentId: string;
versionId: string;
versionTag: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type EnvironmentNodeProps = NodeProps<{
deploymentId: string;
environmentId: string;
environmentName: string;
systemId: string;
}>;

export const EnvironmentNode: React.FC<EnvironmentNodeProps> = ({ data }) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export default async function ChecksPage(props: PageProps) {
<ReactFlowProvider>
<FlowDiagram
workspace={system.workspace}
system={system}
deploymentVersion={deploymentVersion}
envs={environments}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ export const DeploymentPageContent: React.FC<DeploymentPageContentProps> = ({
environment={env}
deployment={deployment}
deploymentVersion={version}
system={{ slug: systemSlug }}
system={{ id: deployment.systemId, slug: systemSlug }}
/>
</TableCell>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const ApprovalRequiredCell: React.FC<{
deploymentVersion: { id: string; tag: string };
deployment: { id: string; name: string; slug: string };
environment: { id: string; name: string };
system: { slug: string };
system: { id: string; slug: string };
}> = ({ policies, deploymentVersion, deployment, environment, system }) => {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();

Expand Down Expand Up @@ -143,6 +143,7 @@ export const ApprovalRequiredCell: React.FC<{
<ApprovalDialog
versionId={deploymentVersion.id}
versionTag={deploymentVersion.tag}
systemId={system.id}
environmentId={environment.id}
>
<DropdownMenuItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const NoJobAgentCell: React.FC<{
};

type DeploymentVersionEnvironmentCellProps = {
system: { slug: string };
system: { id: string; slug: string };
environment: SCHEMA.Environment;
deployment: SCHEMA.Deployment;
deploymentVersion: SCHEMA.DeploymentVersion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ const DeploymentTable: React.FC<{
<LazyDeploymentEnvironmentCell
environment={env}
deployment={r}
system={{ slug: systemSlug }}
system={{ id: r.systemId, slug: systemSlug }}
/>
</td>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,87 +1,238 @@
"use client";

import { useState } from "react";
import type * as schema from "@ctrlplane/db/schema";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { IconLoader2, IconSelector, IconX } from "@tabler/icons-react";

import * as SCHEMA from "@ctrlplane/db/schema";
import { Badge } from "@ctrlplane/ui/badge";
import { Button } from "@ctrlplane/ui/button";
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from "@ctrlplane/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ctrlplane/ui/dialog";
import { Label } from "@ctrlplane/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover";
import { Textarea } from "@ctrlplane/ui/textarea";

import { api } from "~/trpc/react";

export const ApprovalDialog: React.FC<{
const EnvironmentCombobox: React.FC<{
allEnvironments: schema.Environment[];
selectedEnvironmentIds: string[];
onSelect: (environmentId: string[]) => void;
onRemove: (environmentId: string) => void;
}> = ({ allEnvironments, selectedEnvironmentIds, onSelect, onRemove }) => {
const unselectedEnvironments = allEnvironments.filter(
(environment) => !selectedEnvironmentIds.includes(environment.id),
);

const selectedEnvironments = allEnvironments.filter((environment) =>
selectedEnvironmentIds.includes(environment.id),
);

return (
<div className="flex flex-col gap-2">
<div className="space-y-0.5">
<Label>Environments</Label>
<p className="text-xs text-muted-foreground">
Select the environments to approve the release for.
</p>
</div>

<div className="flex items-center gap-2">
{selectedEnvironments.map((environment) => (
<Badge
key={environment.id}
variant="secondary"
className="flex max-w-36 items-center gap-1 truncate text-xs"
>
{environment.name}
<Button
variant="ghost"
size="icon"
className="h-3 w-3 focus-visible:ring-0"
onClick={() => onRemove(environment.id)}
>
<IconX className="h-3 w-3" />
</Button>
</Badge>
))}
</div>

<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="flex w-40 items-center gap-1"
size="sm"
>
<IconSelector className="h-3 w-3" />
Select environments
</Button>
</PopoverTrigger>
<PopoverContent className="p-1" side="bottom" align="start">
<Command>
<CommandInput placeholder="Search environments..." />
<CommandList>
<CommandItem
value="All"
onSelect={() => onSelect(allEnvironments.map((e) => e.id))}
>
<span className="text-sm">All environments</span>
</CommandItem>
{unselectedEnvironments.map((environment) => (
<CommandItem
key={environment.id}
value={environment.name}
onSelect={() => onSelect([environment.id])}
>
<span className="text-sm">{environment.name}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
};

const ApprovalDialogControl: React.FC<{
versionId: string;
versionTag: string;
environments: schema.Environment[];
environmentId: string;
children: React.ReactNode;
onSubmit?: () => void;
}> = ({ versionId, versionTag, environmentId, children, onSubmit }) => {
const [open, setOpen] = useState(false);
const addRecord = api.deployment.version.addApprovalRecord.useMutation();
onSubmit: () => void;
onCancel: () => void;
}> = ({ versionId, environments, environmentId, onSubmit, onCancel }) => {
const [environmentIds, setEnvironmentIds] = useState<string[]>([
environmentId,
]);

const router = useRouter();

const [reason, setReason] = useState("");

const addRecord = api.deployment.version.addApprovalRecord.useMutation();
const handleSubmit = (status: SCHEMA.ApprovalStatus) =>
addRecord
.mutateAsync({
deploymentVersionId: versionId,
environmentId,
environmentIds,
status,
reason,
})
.then(() => setOpen(false))
.then(() => onSubmit?.())
.then(() => onSubmit())
.then(() => router.refresh());

const setEnvironmentSelected = (environmentIds: string[]) =>
setEnvironmentIds((prev) => [...prev, ...environmentIds]);

const setEnvironmentUnselected = (environmentId: string) =>
setEnvironmentIds((prev) => prev.filter((id) => id !== environmentId));
Comment on lines +138 to +142
Copy link
Contributor

@coderabbitai coderabbitai bot Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Potential issue with environment selection logic.

The setEnvironmentSelected function appends new IDs without checking for duplicates, while setEnvironmentUnselected properly filters. This could lead to duplicate environment IDs if the same environment is selected multiple times.

-  const setEnvironmentSelected = (environmentIds: string[]) =>
-    setEnvironmentIds((prev) => [...prev, ...environmentIds]);
+  const setEnvironmentSelected = (environmentIds: string[]) =>
+    setEnvironmentIds((prev) => {
+      const newIds = environmentIds.filter((id) => !prev.includes(id));
+      return [...prev, ...newIds];
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const setEnvironmentSelected = (environmentIds: string[]) =>
setEnvironmentIds((prev) => [...prev, ...environmentIds]);
const setEnvironmentUnselected = (environmentId: string) =>
setEnvironmentIds((prev) => prev.filter((id) => id !== environmentId));
const setEnvironmentSelected = (environmentIds: string[]) =>
setEnvironmentIds((prev) => {
const newIds = environmentIds.filter((id) => !prev.includes(id));
return [...prev, ...newIds];
});
const setEnvironmentUnselected = (environmentId: string) =>
setEnvironmentIds((prev) => prev.filter((id) => id !== environmentId));
🤖 Prompt for AI Agents
In
apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ApprovalDialog.tsx
around lines 138 to 142, the setEnvironmentSelected function appends new
environment IDs without checking for duplicates, which can cause duplicate IDs
in the state. Modify setEnvironmentSelected to merge the new IDs with the
existing ones while removing duplicates, for example by using a Set or filtering
duplicates before updating the state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably won't happen and even if it does its a noop since its used in an inArray check in the db

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adityachoudhari26 You're absolutely right! I see the logic now:

  1. The unselectedEnvironments filter ensures users can only select from environments not already selected, making natural duplicate selection unlikely
  2. Even in edge cases with duplicates, the database inArray operations would treat them as identical values, making it effectively a no-op

Thanks for the clarification - your approach is sound and the additional complexity of duplicate filtering isn't necessary here.


return (
<div className="space-y-6">
<EnvironmentCombobox
allEnvironments={environments}
selectedEnvironmentIds={environmentIds}
onSelect={setEnvironmentSelected}
onRemove={setEnvironmentUnselected}
/>

<div className="space-y-2">
<div className="space-y-0.5">
<Label>Reason</Label>
<p className="text-xs text-muted-foreground">
Provide a reason for the approval or rejection (optional).
</p>
</div>
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} />
</div>

<DialogFooter className="flex w-full flex-row items-center justify-between sm:justify-between">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => handleSubmit(SCHEMA.ApprovalStatus.Rejected)}
disabled={addRecord.isPending}
>
Reject
</Button>
<Button
onClick={() => handleSubmit(SCHEMA.ApprovalStatus.Approved)}
disabled={addRecord.isPending}
>
Approve
</Button>
</div>
</DialogFooter>
</div>
);
};

export const ApprovalDialog: React.FC<{
versionId: string;
versionTag: string;
systemId: string;
environmentId: string;
children: React.ReactNode;
onSubmit?: () => void;
}> = ({
versionId,
versionTag,
systemId,
environmentId,
children,
onSubmit,
}) => {
const [open, setOpen] = useState(false);

const { data, isLoading } = api.environment.bySystemId.useQuery(systemId);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve Release</DialogTitle>
<DialogDescription>
Are you sure you want to approve version {versionTag}?
</DialogDescription>
</DialogHeader>

<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for approval (optional)"
/>

<DialogFooter className="flex w-full flex-row items-center justify-between sm:justify-between">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => handleSubmit(SCHEMA.ApprovalStatus.Rejected)}
disabled={addRecord.isPending}
>
Reject
</Button>
<Button
onClick={() => handleSubmit(SCHEMA.ApprovalStatus.Approved)}
disabled={addRecord.isPending}
>
Approve
</Button>
{isLoading && (
<DialogContent>
<div className="flex h-full w-full items-center justify-center">
<IconLoader2 className="h-4 w-4 animate-spin" />
</div>
</DialogFooter>
</DialogContent>
</DialogContent>
)}
{!isLoading && (
<DialogContent>
<DialogHeader>
<DialogTitle className="max-w-96 truncate">
Approve Version {versionTag}
</DialogTitle>
</DialogHeader>

<ApprovalDialogControl
versionId={versionId}
environments={data ?? []}
environmentId={environmentId}
onSubmit={() => {
setOpen(false);
onSubmit?.();
}}
onCancel={() => setOpen(false)}
/>
</DialogContent>
)}
</Dialog>
);
};
Loading
Loading