Refactor Duplicate Project component and integrate with project detail page

- Updated the DuplicateProject component to accept projectId and selectedServiceIds as props, enhancing its flexibility.
- Removed unnecessary state management for service selection within the component.
- Integrated the DuplicateProject component directly into the project detail page, allowing for easier access to duplication functionality.
- Improved the user interface by utilizing DialogTrigger for initiating the duplication process and displaying selected services more clearly.
This commit is contained in:
Mauricio Siu
2025-03-30 02:42:35 -06:00
parent 2f16034cb0
commit 3910e22412
2 changed files with 113 additions and 165 deletions

View File

@@ -6,13 +6,11 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server";
import { Copy, Loader2 } from "lucide-react"; import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
@@ -36,25 +34,27 @@ export type Services = {
status?: "idle" | "running" | "done" | "error"; status?: "idle" | "running" | "done" | "error";
}; };
type Project = Awaited<ReturnType<typeof findProjectById>>;
interface DuplicateProjectProps { interface DuplicateProjectProps {
project: Project; projectId: string;
services: Services[]; services: Services[];
selectedServiceIds: string[];
} }
export const DuplicateProject = ({ export const DuplicateProject = ({
project, projectId,
services, services,
selectedServiceIds,
}: DuplicateProjectProps) => { }: DuplicateProjectProps) => {
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 [includeServices, setIncludeServices] = useState(true);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const utils = api.useUtils(); const utils = api.useUtils();
const router = useRouter(); const router = useRouter();
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
const { mutateAsync: duplicateProject, isLoading } = const { mutateAsync: duplicateProject, isLoading } =
api.project.duplicate.useMutation({ api.project.duplicate.useMutation({
onSuccess: async (newProject) => { onSuccess: async (newProject) => {
@@ -75,140 +75,98 @@ export const DuplicateProject = ({
} }
await duplicateProject({ await duplicateProject({
sourceProjectId: project.projectId, sourceProjectId: projectId,
name, name,
description, description,
includeServices, includeServices: true,
selectedServices: includeServices selectedServices: selectedServices.map((service) => ({
? services id: service.id,
.filter((service) => selectedServices.includes(service.id)) type: service.type,
.map((service) => ({ })),
id: service.id,
type: service.type,
}))
: [],
}); });
}; };
return ( return (
<> <Dialog
<DropdownMenuItem open={open}
onSelect={(e) => { onOpenChange={(isOpen) => {
e.preventDefault(); setOpen(isOpen);
setOpen(true); if (!isOpen) {
}} // Reset form when closing
> setName("");
<Copy className="mr-2 h-4 w-4" /> setDescription("");
Duplicate Project }
</DropdownMenuItem> }}
>
<DialogTrigger asChild>
<Button variant="ghost" className="w-full justify-start">
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription>
Create a new project with the selected services
</DialogDescription>
</DialogHeader>
<Dialog <div className="grid gap-4 py-4">
open={open} <div className="grid gap-2">
onOpenChange={(isOpen) => { <Label htmlFor="name">Name</Label>
setOpen(isOpen); <Input
if (!isOpen) { id="name"
// Reset form when closing value={name}
setName(""); onChange={(e) => setName(e.target.value)}
setDescription(""); placeholder="New project name"
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> </div>
<DialogFooter> <div className="grid gap-2">
<Button <Label htmlFor="description">Description</Label>
variant="outline" <Input
onClick={() => setOpen(false)} id="description"
disabled={isLoading} value={description}
> onChange={(e) => setDescription(e.target.value)}
Cancel placeholder="Project description (optional)"
</Button> />
<Button onClick={handleDuplicate} disabled={isLoading}> </div>
{isLoading ? (
<> <div className="grid gap-2">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Label>Selected services to duplicate</Label>
Duplicating... <div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
</> {selectedServices.map((service) => (
) : ( <div key={service.id} className="flex items-center space-x-2">
"Duplicate" <span className="text-sm">
)} {service.name} ({service.type})
</Button> </span>
</DialogFooter> </div>
</DialogContent> ))}
</Dialog> </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,7 +78,6 @@ import {
FolderInput, FolderInput,
GlobeIcon, GlobeIcon,
Loader2, Loader2,
MoreHorizontal,
PlusIcon, PlusIcon,
Search, Search,
Trash2, Trash2,
@@ -595,22 +594,6 @@ 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> </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]">
@@ -688,20 +671,27 @@ const Project = (
</DialogAction> </DialogAction>
{(auth?.role === "owner" || {(auth?.role === "owner" ||
auth?.canDeleteServices) && ( auth?.canDeleteServices) && (
<DialogAction <>
title="Delete Services" <DialogAction
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`} title="Delete Services"
type="destructive" description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
onClick={handleBulkDelete} type="destructive"
> onClick={handleBulkDelete}
<Button
variant="ghost"
className="w-full justify-start text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Button
Delete variant="ghost"
</Button> className="w-full justify-start text-destructive"
</DialogAction> >
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</>
)} )}
<Dialog <Dialog