Add Duplicate Project functionality

- Introduced a new component for duplicating projects, allowing users to create a new project with the same configuration as an existing one.
- Implemented a mutation in the project router to handle project duplication, including optional service duplication.
- Updated the project detail page to include a dropdown menu for initiating the duplication process.
- Enhanced the API to validate and process the duplication request, ensuring proper handling of services associated with the project.
This commit is contained in:
Mauricio Siu 2025-03-30 02:38:53 -06:00
parent d4925dd2b7
commit 2f16034cb0
6 changed files with 572 additions and 5 deletions

View File

@ -0,0 +1,214 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server";
import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
};
type Project = Awaited<ReturnType<typeof findProjectById>>;
interface DuplicateProjectProps {
project: Project;
services: Services[];
}
export const DuplicateProject = ({
project,
services,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [includeServices, setIncludeServices] = useState(true);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const utils = api.useUtils();
const router = useRouter();
const { mutateAsync: duplicateProject, isLoading } =
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
toast.success("Project duplicated successfully");
setOpen(false);
router.push(`/dashboard/project/${newProject.projectId}`);
},
onError: (error) => {
toast.error(error.message);
},
});
const handleDuplicate = async () => {
if (!name) {
toast.error("Project name is required");
return;
}
await duplicateProject({
sourceProjectId: project.projectId,
name,
description,
includeServices,
selectedServices: includeServices
? services
.filter((service) => selectedServices.includes(service.id))
.map((service) => ({
id: service.id,
type: service.type,
}))
: [],
});
};
return (
<>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setOpen(true);
}}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate Project
</DropdownMenuItem>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
// Reset form when closing
setName("");
setDescription("");
setIncludeServices(true);
setSelectedServices([]);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription>
Create a new project with the same configuration
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeServices"
checked={includeServices}
onCheckedChange={(checked) => {
setIncludeServices(checked as boolean);
if (!checked) {
setSelectedServices([]);
}
}}
/>
<Label htmlFor="includeServices">Include services</Label>
</div>
{includeServices && services.length > 0 && (
<div className="grid gap-2">
<Label>Select services to duplicate</Label>
<div className="space-y-2">
{services.map((service) => (
<div
key={service.id}
className="flex items-center space-x-2"
>
<Checkbox
id={service.id}
checked={selectedServices.includes(service.id)}
onCheckedChange={(checked) => {
setSelectedServices((prev) =>
checked
? [...prev, service.id]
: prev.filter((id) => id !== service.id),
);
}}
/>
<Label htmlFor={service.id}>
{service.name} ({service.type})
</Label>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Duplicating...
</>
) : (
"Duplicate"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -78,6 +78,7 @@ import {
FolderInput, FolderInput,
GlobeIcon, GlobeIcon,
Loader2, Loader2,
MoreHorizontal,
PlusIcon, PlusIcon,
Search, Search,
Trash2, Trash2,
@ -92,6 +93,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useMemo, useState } from "react"; import { type ReactElement, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import superjson from "superjson"; import superjson from "superjson";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
export type Services = { export type Services = {
appName: string; appName: string;
@ -553,7 +555,7 @@ const Project = (
</CardTitle> </CardTitle>
<CardDescription>{data?.description}</CardDescription> <CardDescription>{data?.description}</CardDescription>
</CardHeader> </CardHeader>
{(auth?.role === "owner" || auth?.canCreateServices) && ( <div className="flex flex-row gap-4 flex-wrap justify-between items-center">
<div className="flex flex-row gap-4 flex-wrap"> <div className="flex flex-row gap-4 flex-wrap">
<ProjectEnvironment projectId={projectId}> <ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button> <Button variant="outline">Project Environment</Button>
@ -593,8 +595,24 @@ const Project = (
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{auth?.role === "owner" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DuplicateProject
project={data}
services={applications}
/>
</DropdownMenuContent>
</DropdownMenu>
)} )}
</div> </div>
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]"> <CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
{isLoading ? ( {isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[60vh]"> <div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[60vh]">

View File

@ -14,21 +14,44 @@ import {
projects, projects,
redis, redis,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { z } from "zod";
import { import {
IS_CLOUD, IS_CLOUD,
addNewProject, addNewProject,
checkProjectAccess, checkProjectAccess,
createApplication,
createCompose,
createMariadb,
createMongo,
createMysql,
createPostgres,
createProject, createProject,
createRedis,
deleteProject, deleteProject,
findApplicationById,
findComposeById,
findMongoById,
findMemberById, findMemberById,
findRedisById,
findProjectById, findProjectById,
findUserById, findUserById,
updateProjectById, updateProjectById,
findPostgresById,
findMariadbById,
findMySqlById,
createDomain,
createPort,
createMount,
createRedirect,
createPreviewDeployment,
createBackup,
createSecurity,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core"; import type { AnyPgColumn } from "drizzle-orm/pg-core";
export const projectRouter = createTRPCRouter({ export const projectRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateProject) .input(apiCreateProject)
@ -266,7 +289,317 @@ export const projectRouter = createTRPCRouter({
throw error; throw error;
} }
}), }),
duplicate: protectedProcedure
.input(
z.object({
sourceProjectId: z.string(),
name: z.string(),
description: z.string().optional(),
includeServices: z.boolean().default(true),
selectedServices: z
.array(
z.object({
id: z.string(),
type: z.enum([
"application",
"postgres",
"mariadb",
"mongo",
"mysql",
"redis",
"compose",
]),
}),
)
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.rol === "member") {
await checkProjectAccess(
ctx.user.id,
"create",
ctx.session.activeOrganizationId,
);
}
// Get source project
const sourceProject = await findProjectById(input.sourceProjectId);
if (sourceProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this project",
}); });
}
// Create new project
const newProject = await createProject(
{
name: input.name,
description: input.description,
env: sourceProject.env,
},
ctx.session.activeOrganizationId,
);
if (input.includeServices) {
const servicesToDuplicate = input.selectedServices || [];
// Helper function to duplicate a service
const duplicateService = async (id: string, type: string) => {
switch (type) {
case "application": {
const {
applicationId,
domains,
security,
ports,
registry,
redirects,
previewDeployments,
mounts,
...application
} = await findApplicationById(id);
const newApplication = await createApplication({
...application,
projectId: newProject.projectId,
});
for (const domain of domains) {
const { domainId, ...rest } = domain;
await createDomain({
...rest,
applicationId: newApplication.applicationId,
domainType: "application",
});
}
for (const port of ports) {
const { portId, ...rest } = port;
await createPort({
...rest,
applicationId: newApplication.applicationId,
});
}
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newApplication.applicationId,
serviceType: "application",
});
}
for (const redirect of redirects) {
const { redirectId, ...rest } = redirect;
await createRedirect({
...rest,
applicationId: newApplication.applicationId,
});
}
for (const secure of security) {
const { securityId, ...rest } = secure;
await createSecurity({
...rest,
applicationId: newApplication.applicationId,
});
}
for (const previewDeployment of previewDeployments) {
const { previewDeploymentId, ...rest } = previewDeployment;
await createPreviewDeployment({
...rest,
applicationId: newApplication.applicationId,
});
}
break;
}
case "postgres": {
const { postgresId, mounts, backups, ...postgres } =
await findPostgresById(id);
const newPostgres = await createPostgres({
...postgres,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newPostgres.postgresId,
serviceType: "postgres",
});
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
await createBackup({
...rest,
postgresId: newPostgres.postgresId,
});
}
break;
}
case "mariadb": {
const { mariadbId, mounts, backups, ...mariadb } =
await findMariadbById(id);
const newMariadb = await createMariadb({
...mariadb,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newMariadb.mariadbId,
serviceType: "mariadb",
});
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mariadbId: newMariadb.mariadbId,
});
}
break;
}
case "mongo": {
const { mongoId, mounts, backups, ...mongo } =
await findMongoById(id);
const newMongo = await createMongo({
...mongo,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newMongo.mongoId,
serviceType: "mongo",
});
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mongoId: newMongo.mongoId,
});
}
break;
}
case "mysql": {
const { mysqlId, mounts, backups, ...mysql } =
await findMySqlById(id);
const newMysql = await createMysql({
...mysql,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newMysql.mysqlId,
serviceType: "mysql",
});
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mysqlId: newMysql.mysqlId,
});
}
break;
}
case "redis": {
const { redisId, mounts, ...redis } = await findRedisById(id);
const newRedis = await createRedis({
...redis,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newRedis.redisId,
serviceType: "redis",
});
}
break;
}
case "compose": {
const { composeId, mounts, domains, ...compose } =
await findComposeById(id);
const newCompose = await createCompose({
...compose,
projectId: newProject.projectId,
});
for (const mount of mounts) {
const { mountId, ...rest } = mount;
await createMount({
...rest,
serviceId: newCompose.composeId,
serviceType: "compose",
});
}
for (const domain of domains) {
const { domainId, ...rest } = domain;
await createDomain({
...rest,
composeId: newCompose.composeId,
domainType: "compose",
});
}
break;
}
}
};
// Duplicate selected services
for (const service of servicesToDuplicate) {
await duplicateService(service.id, service.type);
}
}
if (ctx.user.rol === "member") {
await addNewProject(
ctx.user.id,
newProject.projectId,
ctx.session.activeOrganizationId,
);
}
return newProject;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error duplicating the project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
});
function buildServiceFilter( function buildServiceFilter(
fieldName: AnyPgColumn, fieldName: AnyPgColumn,
accessedServices: string[], accessedServices: string[],

View File

@ -139,7 +139,7 @@ const createSchema = createInsertSchema(compose, {
name: z.string().min(1), name: z.string().min(1),
description: z.string(), description: z.string(),
env: z.string().optional(), env: z.string().optional(),
composeFile: z.string().min(1), composeFile: z.string().optional(),
projectId: z.string(), projectId: z.string(),
customGitSSHKeyId: z.string().optional(), customGitSSHKeyId: z.string().optional(),
command: z.string().optional(), command: z.string().optional(),
@ -155,6 +155,7 @@ export const apiCreateCompose = createSchema.pick({
composeType: true, composeType: true,
appName: true, appName: true,
serverId: true, serverId: true,
composeFile: true,
}); });
export const apiCreateComposeByTemplate = createSchema export const apiCreateComposeByTemplate = createSchema

View File

@ -52,6 +52,7 @@ const createSchema = createInsertSchema(projects, {
export const apiCreateProject = createSchema.pick({ export const apiCreateProject = createSchema.pick({
name: true, name: true,
description: true, description: true,
env: true,
}); });
export const apiFindOneProject = createSchema export const apiFindOneProject = createSchema

View File

@ -69,7 +69,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
.insert(compose) .insert(compose)
.values({ .values({
...input, ...input,
composeFile: "", composeFile: input.composeFile || "",
appName, appName,
}) })
.returning() .returning()