refactor: update environment selector and API routes to utilize environmentId for service management; enhance UI with Badge component for production environments

This commit is contained in:
Mauricio Siu
2025-09-01 21:09:30 -06:00
parent e9322fc900
commit 59cbc8ee0d
9 changed files with 136 additions and 82 deletions

View File

@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { ChevronDownIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface Environment {
environmentId: string;
@@ -165,9 +166,9 @@ export const AdvancedEnvironmentSelector = ({
<div className="flex items-center gap-2">
<span>{currentEnv?.name || "Select Environment"}</span>
{currentEnv?.name === "production" && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-800 rounded">
<Badge >
Prod
</span>
</Badge>
)}
</div>
<ChevronDownIcon className="h-4 w-4" />
@@ -189,9 +190,9 @@ export const AdvancedEnvironmentSelector = ({
<div className="flex items-center gap-2">
<span>{environment.name}</span>
{environment.name === "production" && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-800 rounded">
<Badge >
Prod
</span>
</Badge>
)}
</div>
{environment.environmentId === currentEnvironmentId && (
@@ -241,7 +242,6 @@ export const AdvancedEnvironmentSelector = ({
</DropdownMenuContent>
</DropdownMenu>
{/* Create Environment Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -285,9 +285,9 @@ export const AdvancedEnvironmentSelector = ({
</Button>
<Button
onClick={handleCreateEnvironment}
disabled={!name.trim() || createEnvironment.isPending}
disabled={!name.trim() || createEnvironment.isLoading}
>
{createEnvironment.isPending ? "Creating..." : "Create"}
{createEnvironment.isLoading ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
@@ -338,9 +338,9 @@ export const AdvancedEnvironmentSelector = ({
</Button>
<Button
onClick={handleUpdateEnvironment}
disabled={!name.trim() || updateEnvironment.isPending}
disabled={!name.trim() || updateEnvironment.isLoading}
>
{updateEnvironment.isPending ? "Updating..." : "Update"}
{updateEnvironment.isLoading ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</DialogContent>
@@ -370,9 +370,9 @@ export const AdvancedEnvironmentSelector = ({
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={deleteEnvironment.isPending}
disabled={deleteEnvironment.isLoading}
>
{deleteEnvironment.isPending ? "Deleting..." : "Delete"}
{deleteEnvironment.isLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -275,12 +275,19 @@ const EnvironmentPage = (
const { data: currentEnvironment } = api.environment.one.useQuery({
environmentId,
});
const { data: allProjects } = api.project.all.useQuery();
const router = useRouter();
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
const [selectedTargetProject, setSelectedTargetProject] =
useState<string>("");
const [selectedTargetEnvironment, setSelectedTargetEnvironment] =
useState<string>("");
const { data: selectedProjectEnvironments } = api.environment.byProjectId.useQuery(
{ projectId: selectedTargetProject },
{ enabled: !!selectedTargetProject }
);
const emptyServices =
!currentEnvironment ||
@@ -484,6 +491,10 @@ const EnvironmentPage = (
toast.error("Please select a target project");
return;
}
if (!selectedTargetEnvironment) {
toast.error("Please select a target environment");
return;
}
let success = 0;
setIsBulkActionLoading(true);
@@ -492,50 +503,54 @@ const EnvironmentPage = (
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
// TODO: Update move APIs to use targetEnvironmentId instead of targetProjectId
switch (service.type) {
case "application":
await applicationActions.move.mutateAsync({
applicationId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "compose":
await composeActions.move.mutateAsync({
composeId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "postgres":
await postgresActions.move.mutateAsync({
postgresId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "mysql":
await mysqlActions.move.mutateAsync({
mysqlId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "mariadb":
await mariadbActions.move.mutateAsync({
mariadbId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "redis":
await redisActions.move.mutateAsync({
redisId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "mongo":
await mongoActions.move.mutateAsync({
mongoId: serviceId,
targetProjectId: selectedTargetProject,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
});
success++;
} catch (error) {
toast.error(
@@ -551,6 +566,9 @@ const EnvironmentPage = (
setIsDropdownOpen(false);
setIsMoveDialogOpen(false);
setIsBulkActionLoading(false);
// Reset move dialog state
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
};
const handleBulkDelete = async (deleteVolumes = false) => {
@@ -964,14 +982,12 @@ const EnvironmentPage = (
<DialogHeader>
<DialogTitle>Move Services</DialogTitle>
<DialogDescription>
Select the target project to move{" "}
Select the target project and environment to move{" "}
{selectedServices.length} services
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{projectData?.environments?.filter(
(p) => p.projectId !== projectId,
).length === 0 ? (
{allProjects?.filter((p) => p.projectId !== projectId).length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4">
<FolderInput className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
@@ -980,32 +996,70 @@ const EnvironmentPage = (
</p>
</div>
) : (
<Select
value={selectedTargetProject}
onValueChange={setSelectedTargetProject}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter((p) => p.projectId !== projectId)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<>
{/* Step 1: Select Project */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Target Project</label>
<Select
value={selectedTargetProject}
onValueChange={(value) => {
setSelectedTargetProject(value);
setSelectedTargetEnvironment(""); // Reset environment when project changes
}}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter((p) => p.projectId !== projectId)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Step 2: Select Environment (only show if project is selected) */}
{selectedTargetProject && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Target Environment</label>
<Select
value={selectedTargetEnvironment}
onValueChange={setSelectedTargetEnvironment}
>
<SelectTrigger>
<SelectValue placeholder="Select target environment" />
</SelectTrigger>
<SelectContent>
{selectedProjectEnvironments?.map((env) => (
<SelectItem
key={env.environmentId}
value={env.environmentId}
>
{env.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveDialogOpen(false)}
onClick={() => {
setIsMoveDialogOpen(false);
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}}
>
Cancel
</Button>
@@ -1013,9 +1067,9 @@ const EnvironmentPage = (
onClick={handleBulkMove}
isLoading={isBulkActionLoading}
disabled={
projectData?.environments?.filter(
(p) => p.projectId !== projectId,
).length === 0
allProjects?.filter((p) => p.projectId !== projectId).length === 0 ||
!selectedTargetProject ||
!selectedTargetEnvironment
}
>
Move Services

View File

@@ -819,7 +819,7 @@ export const applicationRouter = createTRPCRouter({
.input(
z.object({
applicationId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -833,11 +833,11 @@ export const applicationRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -845,7 +845,7 @@ export const applicationRouter = createTRPCRouter({
const updatedApplication = await db
.update(applications)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(applications.applicationId, input.applicationId))
.returning()

View File

@@ -655,7 +655,7 @@ export const composeRouter = createTRPCRouter({
.input(
z.object({
composeId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -667,18 +667,18 @@ export const composeRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
const updatedCompose = await db
.update(composeTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(composeTable.composeId, input.composeId))
.returning()

View File

@@ -337,7 +337,7 @@ export const mariadbRouter = createTRPCRouter({
.input(
z.object({
mariadbId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -349,11 +349,11 @@ export const mariadbRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -361,7 +361,7 @@ export const mariadbRouter = createTRPCRouter({
const updatedMariadb = await db
.update(mariadbTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(mariadbTable.mariadbId, input.mariadbId))
.returning()

View File

@@ -351,7 +351,7 @@ export const mongoRouter = createTRPCRouter({
.input(
z.object({
mongoId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -363,11 +363,11 @@ export const mongoRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -375,7 +375,7 @@ export const mongoRouter = createTRPCRouter({
const updatedMongo = await db
.update(mongoTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(mongoTable.mongoId, input.mongoId))
.returning()

View File

@@ -346,7 +346,7 @@ export const mysqlRouter = createTRPCRouter({
.input(
z.object({
mysqlId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -358,11 +358,11 @@ export const mysqlRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -370,7 +370,7 @@ export const mysqlRouter = createTRPCRouter({
const updatedMysql = await db
.update(mysqlTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(mysqlTable.mysqlId, input.mysqlId))
.returning()

View File

@@ -367,7 +367,7 @@ export const postgresRouter = createTRPCRouter({
.input(
z.object({
postgresId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -381,11 +381,11 @@ export const postgresRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -393,7 +393,7 @@ export const postgresRouter = createTRPCRouter({
const updatedPostgres = await db
.update(postgresTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(postgresTable.postgresId, input.postgresId))
.returning()

View File

@@ -330,7 +330,7 @@ export const redisRouter = createTRPCRouter({
.input(
z.object({
redisId: z.string(),
targetProjectId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -342,11 +342,11 @@ export const redisRouter = createTRPCRouter({
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
const targetEnvironment = await findEnvironmentById(input.targetEnvironmentId);
if (targetEnvironment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
message: "You are not authorized to move to this environment",
});
}
@@ -354,7 +354,7 @@ export const redisRouter = createTRPCRouter({
const updatedRedis = await db
.update(redisTable)
.set({
projectId: input.targetProjectId,
environmentId: input.targetEnvironmentId,
})
.where(eq(redisTable.redisId, input.redisId))
.returning()