refactor: update DuplicateProject and AdvancedEnvironmentSelector components to utilize environmentId for improved context handling; enhance UI with project and environment selection features for better user experience

This commit is contained in:
Mauricio Siu
2025-09-01 22:40:51 -06:00
parent 766890192d
commit 883c3f9739
5 changed files with 156 additions and 39 deletions

View File

@@ -31,13 +31,11 @@ import { findEnvironmentById } from "@dokploy/server";
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
interface AdvancedEnvironmentSelectorProps {
projectId: string;
environments: Environment[];
currentEnvironmentId?: string;
}
export const AdvancedEnvironmentSelector = ({
projectId,
environments,
currentEnvironmentId,
}: AdvancedEnvironmentSelectorProps) => {
const router = useRouter();
@@ -46,6 +44,11 @@ export const AdvancedEnvironmentSelector = ({
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState<Environment | null>(null);
const { data: project } = api.project.one.useQuery({ projectId },{
enabled: !!projectId,
});
const environments = project?.environments || [];
// Form states
const [name, setName] = useState("");
const [description, setDescription] = useState("");

View File

@@ -15,6 +15,13 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export type Services = {
@@ -36,23 +43,32 @@ export type Services = {
};
interface DuplicateProjectProps {
projectId: string;
environmentId: string;
services: Services[];
selectedServiceIds: string[];
}
export const DuplicateProject = ({
projectId,
environmentId,
services,
selectedServiceIds,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "existing-environment"
const [selectedTargetProject, setSelectedTargetProject] = useState<string>("");
const [selectedTargetEnvironment, setSelectedTargetEnvironment] = useState<string>("");
const utils = api.useUtils();
const router = useRouter();
// Queries for project and environment selection
const { data: allProjects } = api.project.all.useQuery();
const { data: selectedProjectEnvironments } = api.environment.byProjectId.useQuery(
{ projectId: selectedTargetProject },
{ enabled: !!selectedTargetProject }
);
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
@@ -68,7 +84,7 @@ export const DuplicateProject = ({
);
setOpen(false);
if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
router.push(`/dashboard/project/${newProject?.projectId}/environment/${newProject?.environmentId}`);
}
},
onError: (error) => {
@@ -82,8 +98,20 @@ export const DuplicateProject = ({
return;
}
if (duplicateType === "existing-environment") {
if (!selectedTargetProject) {
toast.error("Please select a target project");
return;
}
if (!selectedTargetEnvironment) {
toast.error("Please select a target environment");
return;
}
}
// TODO: Update duplicate API to support targetProjectId and targetEnvironmentId
await duplicateProject({
sourceProjectId: projectId,
sourceEnvironmentId: selectedTargetEnvironment,
name,
description,
includeServices: true,
@@ -105,6 +133,8 @@ export const DuplicateProject = ({
setName("");
setDescription("");
setDuplicateType("new-project");
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
>
@@ -127,7 +157,14 @@ export const DuplicateProject = ({
<Label>Duplicate to</Label>
<RadioGroup
value={duplicateType}
onValueChange={setDuplicateType}
onValueChange={(value) => {
setDuplicateType(value);
// Reset selections when changing type
if (value !== "existing-environment") {
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
className="grid gap-2"
>
<div className="flex items-center space-x-2">
@@ -135,8 +172,8 @@ export const DuplicateProject = ({
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
<RadioGroupItem value="existing-environment" id="existing-environment" />
<Label htmlFor="existing-environment">Existing environment</Label>
</div>
</RadioGroup>
</div>
@@ -165,6 +202,73 @@ export const DuplicateProject = ({
</>
)}
{duplicateType === "existing-environment" && (
<>
{allProjects?.filter((p) => p.projectId !== environmentId).length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4 text-center">
<p className="text-sm text-muted-foreground">
No other projects available. Create a new project first.
</p>
</div>
) : (
<>
{/* Step 1: Select Project */}
<div className="grid gap-2">
<Label>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 !== environmentId)
.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="grid gap-2">
<Label>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 className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
@@ -187,18 +291,25 @@ export const DuplicateProject = ({
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
<Button
onClick={handleDuplicate}
disabled={
isLoading ||
(duplicateType === "new-project" && !name) ||
(duplicateType === "existing-environment" && (!selectedTargetProject || !selectedTargetEnvironment))
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
? "Duplicating to new project..."
: "Duplicating to environment..."}
</>
) : duplicateType === "new-project" ? (
"Duplicate project"
"Duplicate to new project"
) : (
"Duplicate services"
"Duplicate to environment"
)}
</Button>
</DialogFooter>

View File

@@ -790,7 +790,6 @@ const EnvironmentPage = (
</ProjectEnvironment>
<AdvancedEnvironmentSelector
projectId={projectId}
environments={projectData?.environments || []}
currentEnvironmentId={environmentId}
/>
<DropdownMenu>
@@ -957,7 +956,7 @@ const EnvironmentPage = (
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
environmentId={environmentId}
services={applications}
selectedServiceIds={selectedServices}
/>

View File

@@ -19,6 +19,7 @@ import {
deleteProject,
findApplicationById,
findComposeById,
findEnvironmentById,
findMariadbById,
findMemberById,
findMongoById,
@@ -80,7 +81,7 @@ export const projectRouter = createTRPCRouter({
if (ctx.user.role === "member") {
await addNewProject(
ctx.user.id,
project.projectId,
project.project.projectId,
ctx.session.activeOrganizationId,
);
}
@@ -312,7 +313,7 @@ export const projectRouter = createTRPCRouter({
duplicate: protectedProcedure
.input(
z.object({
sourceProjectId: z.string(),
sourceEnvironmentId: z.string(),
name: z.string(),
description: z.string().optional(),
includeServices: z.boolean().default(true),
@@ -346,9 +347,10 @@ export const projectRouter = createTRPCRouter({
}
// Get source project
const sourceProject = await findProjectById(input.sourceProjectId);
const sourceEnvironment = input.duplicateInSameProject ? await findEnvironmentById(input.sourceEnvironmentId) : null;
if (sourceProject.organizationId !== ctx.session.activeOrganizationId) {
if (input.duplicateInSameProject &&sourceEnvironment?.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this project",
@@ -357,15 +359,18 @@ export const projectRouter = createTRPCRouter({
// Create new project or use existing one
const targetProject = input.duplicateInSameProject
? sourceProject
? sourceEnvironment
: await createProject(
{
name: input.name,
description: input.description,
env: sourceProject.env,
env: sourceEnvironment?.project.env,
},
ctx.session.activeOrganizationId,
);
).then((value) => value.environment);
console.log("targetProject", targetProject);
if (input.includeServices) {
const servicesToDuplicate = input.selectedServices || [];
@@ -398,7 +403,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${application.name} (copy)`
: application.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const domain of domains) {
@@ -468,7 +473,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${postgres.name} (copy)`
: postgres.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -504,7 +509,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${mariadb.name} (copy)`
: mariadb.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -540,7 +545,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${mongo.name} (copy)`
: mongo.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -576,7 +581,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${mysql.name} (copy)`
: mysql.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -612,7 +617,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${redis.name} (copy)`
: redis.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -647,7 +652,7 @@ export const projectRouter = createTRPCRouter({
name: input.duplicateInSameProject
? `${compose.name} (copy)`
: compose.name,
projectId: targetProject.projectId,
environmentId: targetProject?.environmentId || "",
});
for (const mount of mounts) {
@@ -682,7 +687,7 @@ export const projectRouter = createTRPCRouter({
if (!input.duplicateInSameProject && ctx.user.role === "member") {
await addNewProject(
ctx.user.id,
targetProject.projectId,
targetProject?.projectId || "",
ctx.session.activeOrganizationId,
);
}

View File

@@ -36,14 +36,13 @@ export const createProject = async (
}
// Automatically create a production environment
try {
await createProductionEnvironment(newProject.projectId);
} catch (error) {
console.error("Error creating production environment:", error);
// Don't fail project creation if environment creation fails
}
const newEnvironment = await createProductionEnvironment(newProject.projectId);
return {
project: newProject,
environment: newEnvironment,
};
return newProject;
};
export const findProjectById = async (projectId: string) => {