feat: enhance project duplication functionality with options for new or same project

This commit is contained in:
Mauricio Siu
2025-05-17 14:33:07 -06:00
parent c64d2245ce
commit 380656efee
2 changed files with 102 additions and 43 deletions

View File

@@ -15,6 +15,7 @@ import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export type Services = { export type Services = {
appName: string; appName: string;
@@ -48,6 +49,7 @@ export const DuplicateProject = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const utils = api.useUtils(); const utils = api.useUtils();
const router = useRouter(); const router = useRouter();
@@ -59,9 +61,15 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({ api.project.duplicate.useMutation({
onSuccess: async (newProject) => { onSuccess: async (newProject) => {
await utils.project.all.invalidate(); await utils.project.all.invalidate();
toast.success("Project duplicated successfully"); toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"
: "Services duplicated successfully",
);
setOpen(false); setOpen(false);
router.push(`/dashboard/project/${newProject.projectId}`); if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
}
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
@@ -69,7 +77,7 @@ export const DuplicateProject = ({
}); });
const handleDuplicate = async () => { const handleDuplicate = async () => {
if (!name) { if (duplicateType === "new-project" && !name) {
toast.error("Project name is required"); toast.error("Project name is required");
return; return;
} }
@@ -83,6 +91,7 @@ export const DuplicateProject = ({
id: service.id, id: service.id,
type: service.type, type: service.type,
})), })),
duplicateInSameProject: duplicateType === "same-project",
}); });
}; };
@@ -95,6 +104,7 @@ export const DuplicateProject = ({
// Reset form when closing // Reset form when closing
setName(""); setName("");
setDescription(""); setDescription("");
setDuplicateType("new-project");
} }
}} }}
> >
@@ -106,32 +116,54 @@ export const DuplicateProject = ({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle> <DialogTitle>Duplicate Services</DialogTitle>
<DialogDescription> <DialogDescription>
Create a new project with the selected services Choose where to duplicate the selected services
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name">Name</Label> <Label>Duplicate to</Label>
<Input <RadioGroup
id="name" value={duplicateType}
value={name} onValueChange={setDuplicateType}
onChange={(e) => setName(e.target.value)} className="grid gap-2"
placeholder="New project name" >
/> <div className="flex items-center space-x-2">
<RadioGroupItem value="new-project" id="new-project" />
<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>
</div>
</RadioGroup>
</div> </div>
<div className="grid gap-2"> {duplicateType === "new-project" && (
<Label htmlFor="description">Description</Label> <>
<Input <div className="grid gap-2">
id="description" <Label htmlFor="name">Name</Label>
value={description} <Input
onChange={(e) => setDescription(e.target.value)} id="name"
placeholder="Project description (optional)" value={name}
/> onChange={(e) => setName(e.target.value)}
</div> 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="grid gap-2"> <div className="grid gap-2">
<Label>Selected services to duplicate</Label> <Label>Selected services to duplicate</Label>
@@ -159,10 +191,14 @@ export const DuplicateProject = ({
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Duplicating... {duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
</> </>
) : duplicateType === "new-project" ? (
"Duplicate project"
) : ( ) : (
"Duplicate" "Duplicate services"
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -309,6 +309,7 @@ export const projectRouter = createTRPCRouter({
}), }),
) )
.optional(), .optional(),
duplicateInSameProject: z.boolean().default(false),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -331,15 +332,17 @@ export const projectRouter = createTRPCRouter({
}); });
} }
// Create new project // Create new project or use existing one
const newProject = await createProject( const targetProject = input.duplicateInSameProject
{ ? sourceProject
name: input.name, : await createProject(
description: input.description, {
env: sourceProject.env, name: input.name,
}, description: input.description,
ctx.session.activeOrganizationId, env: sourceProject.env,
); },
ctx.session.activeOrganizationId,
);
if (input.includeServices) { if (input.includeServices) {
const servicesToDuplicate = input.selectedServices || []; const servicesToDuplicate = input.selectedServices || [];
@@ -362,7 +365,10 @@ export const projectRouter = createTRPCRouter({
const newApplication = await createApplication({ const newApplication = await createApplication({
...application, ...application,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${application.name} (copy)`
: application.name,
projectId: targetProject.projectId,
}); });
for (const domain of domains) { for (const domain of domains) {
@@ -423,7 +429,10 @@ export const projectRouter = createTRPCRouter({
const newPostgres = await createPostgres({ const newPostgres = await createPostgres({
...postgres, ...postgres,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${postgres.name} (copy)`
: postgres.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -449,7 +458,10 @@ export const projectRouter = createTRPCRouter({
await findMariadbById(id); await findMariadbById(id);
const newMariadb = await createMariadb({ const newMariadb = await createMariadb({
...mariadb, ...mariadb,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${mariadb.name} (copy)`
: mariadb.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -475,7 +487,10 @@ export const projectRouter = createTRPCRouter({
await findMongoById(id); await findMongoById(id);
const newMongo = await createMongo({ const newMongo = await createMongo({
...mongo, ...mongo,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${mongo.name} (copy)`
: mongo.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -501,7 +516,10 @@ export const projectRouter = createTRPCRouter({
await findMySqlById(id); await findMySqlById(id);
const newMysql = await createMysql({ const newMysql = await createMysql({
...mysql, ...mysql,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${mysql.name} (copy)`
: mysql.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -526,7 +544,10 @@ export const projectRouter = createTRPCRouter({
const { redisId, mounts, ...redis } = await findRedisById(id); const { redisId, mounts, ...redis } = await findRedisById(id);
const newRedis = await createRedis({ const newRedis = await createRedis({
...redis, ...redis,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${redis.name} (copy)`
: redis.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -545,7 +566,10 @@ export const projectRouter = createTRPCRouter({
await findComposeById(id); await findComposeById(id);
const newCompose = await createCompose({ const newCompose = await createCompose({
...compose, ...compose,
projectId: newProject.projectId, name: input.duplicateInSameProject
? `${compose.name} (copy)`
: compose.name,
projectId: targetProject.projectId,
}); });
for (const mount of mounts) { for (const mount of mounts) {
@@ -572,21 +596,20 @@ export const projectRouter = createTRPCRouter({
}; };
// Duplicate selected services // Duplicate selected services
for (const service of servicesToDuplicate) { for (const service of servicesToDuplicate) {
await duplicateService(service.id, service.type); await duplicateService(service.id, service.type);
} }
} }
if (ctx.user.role === "member") { if (!input.duplicateInSameProject && ctx.user.role === "member") {
await addNewProject( await addNewProject(
ctx.user.id, ctx.user.id,
newProject.projectId, targetProject.projectId,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
} }
return newProject; return targetProject;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",