From 98b2319011e2c0e3931fdee93d3728f5a4fe3db4 Mon Sep 17 00:00:00 2001 From: Marc Fernandez Date: Wed, 31 Dec 2025 14:22:39 +0100 Subject: [PATCH] feat: POC of Clean Architecture --- .devcontainer/Dockerfile | 29 + .devcontainer/devcontainer.json | 29 + .vscode/settings.json | 3 + .../components/dashboard/projects/show.tsx | 571 ------------------ .../use-cases/create-project.use-case.ts | 50 ++ .../use-cases/delete-project.use-case.ts | 18 + .../use-cases/update-project.use-case.ts | 28 + .../projects/domain/models/project.models.ts | 62 ++ .../repositories/projects.repository.ts | 43 ++ .../api/projects-api.repository.ts | 79 +++ .../ui/components/delete-project-dialog.tsx | 89 +++ .../ui/components/empty-projects-state.tsx | 16 + .../ui/components}/handle-project.tsx | 111 ++-- .../projects/ui/components/project-card.tsx | 278 +++++++++ .../ui/components}/project-environment.tsx | 75 ++- .../ui/components/projects-filters.tsx | 57 ++ .../projects/ui/containers/show-projects.tsx | 203 +++++++ .../environment/[environmentId].tsx | 2 +- apps/dokploy/pages/dashboard/projects.tsx | 2 +- apps/dokploy/server/api/routers/project.ts | 2 + 20 files changed, 1080 insertions(+), 667 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json delete mode 100644 apps/dokploy/components/dashboard/projects/show.tsx create mode 100644 apps/dokploy/features/projects/application/use-cases/create-project.use-case.ts create mode 100644 apps/dokploy/features/projects/application/use-cases/delete-project.use-case.ts create mode 100644 apps/dokploy/features/projects/application/use-cases/update-project.use-case.ts create mode 100644 apps/dokploy/features/projects/domain/models/project.models.ts create mode 100644 apps/dokploy/features/projects/domain/repositories/projects.repository.ts create mode 100644 apps/dokploy/features/projects/infrastructure/api/projects-api.repository.ts create mode 100644 apps/dokploy/features/projects/ui/components/delete-project-dialog.tsx create mode 100644 apps/dokploy/features/projects/ui/components/empty-projects-state.tsx rename apps/dokploy/{components/dashboard/projects => features/projects/ui/components}/handle-project.tsx (69%) create mode 100644 apps/dokploy/features/projects/ui/components/project-card.tsx rename apps/dokploy/{components/dashboard/projects => features/projects/ui/components}/project-environment.tsx (71%) create mode 100644 apps/dokploy/features/projects/ui/components/projects-filters.tsx create mode 100644 apps/dokploy/features/projects/ui/containers/show-projects.tsx diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..03eb22bb36 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20.16.0-alpine + +# Install git, Python, build tools and other dependencies needed for native modules +RUN apk add --no-cache \ + git \ + python3 \ + py3-pip \ + make \ + g++ \ + libc6-compat \ + openssl-dev + +# Create symlink for python (some tools expect 'python' command) +RUN ln -sf python3 /usr/bin/python + +# Install pnpm +RUN npm install -g pnpm@9.12.0 + +# Install Biome +RUN npm install -g @biomejs/biome@2.1.1 + +# Set working directory +WORKDIR /workspace + +COPY . . + +RUN pnpm install + +CMD ["tail", "-f", "/dev/null"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..0f7a45f742 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,29 @@ +{ + "name": "Dokploy Development container", + "build": { + "context": "..", + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome", + "sleistner.vscode-fileutils", + "GitHub.copilot", + "GitHub.copilot-chat", + "eamodio.gitlens", + "shd101wyy.markdown-preview-enhanced", + "GitHub.vscode-github-actions", + "redhat.vscode-yaml", + "bradlc.vscode-tailwindcss" + ] + } + }, + "remoteEnv": { + "NODE_TLS_REJECT_UNAUTHORIZED": "0" + }, + "runArgs": [ + "--name", "dokploy-devcontainer", + "--network", "host" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 99357f2369..463ce8e246 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" } } diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx deleted file mode 100644 index a618a20aca..0000000000 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import { - AlertTriangle, - ArrowUpDown, - BookIcon, - ExternalLinkIcon, - FolderInput, - Loader2, - MoreHorizontalIcon, - Search, - TrashIcon, -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; -import { DateTooltip } from "@/components/shared/date-tooltip"; -import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { TimeBadge } from "@/components/ui/time-badge"; -import { api } from "@/utils/api"; -import { useDebounce } from "@/utils/hooks/use-debounce"; -import { HandleProject } from "./handle-project"; -import { ProjectEnvironment } from "./project-environment"; - -export const ShowProjects = () => { - const utils = api.useUtils(); - const router = useRouter(); - const { data: isCloud } = api.settings.isCloud.useQuery(); - const { data, isLoading } = api.project.all.useQuery(); - const { data: auth } = api.user.get.useQuery(); - const { mutateAsync } = api.project.remove.useMutation(); - - const [searchQuery, setSearchQuery] = useState( - router.isReady && typeof router.query.q === "string" ? router.query.q : "", - ); - const debouncedSearchQuery = useDebounce(searchQuery, 500); - - const [sortBy, setSortBy] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem("projectsSort") || "createdAt-desc"; - } - return "createdAt-desc"; - }); - - useEffect(() => { - localStorage.setItem("projectsSort", sortBy); - }, [sortBy]); - - useEffect(() => { - if (!router.isReady) return; - const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; - if (urlQuery !== searchQuery) { - setSearchQuery(urlQuery); - } - }, [router.isReady, router.query.q]); - - useEffect(() => { - if (!router.isReady) return; - const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; - if (debouncedSearchQuery === urlQuery) return; - - const newQuery = { ...router.query }; - if (debouncedSearchQuery) { - newQuery.q = debouncedSearchQuery; - } else { - delete newQuery.q; - } - router.replace({ pathname: router.pathname, query: newQuery }, undefined, { - shallow: true, - }); - }, [debouncedSearchQuery]); - - const filteredProjects = useMemo(() => { - if (!data) return []; - - const filtered = data.filter( - (project) => - project.name - .toLowerCase() - .includes(debouncedSearchQuery.toLowerCase()) || - project.description - ?.toLowerCase() - .includes(debouncedSearchQuery.toLowerCase()), - ); - - // Then sort the filtered results - const [field, direction] = sortBy.split("-"); - return [...filtered].sort((a, b) => { - let comparison = 0; - switch (field) { - case "name": - comparison = a.name.localeCompare(b.name); - break; - case "createdAt": - comparison = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - break; - case "services": { - const aTotalServices = a.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - const bTotalServices = b.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - comparison = aTotalServices - bTotalServices; - break; - } - default: - comparison = 0; - } - return direction === "asc" ? comparison : -comparison; - }); - }, [data, debouncedSearchQuery, sortBy]); - - return ( - <> - - {!isCloud && ( -
- -
- )} -
- -
-
- - - - Projects - - - Create and manage your projects - - - {(auth?.role === "owner" || - auth?.role === "admin" || - auth?.canCreateProjects) && ( -
- -
- )} -
- - - {isLoading ? ( -
- Loading... - -
- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - className="pr-10" - /> - - -
-
- - -
-
- {filteredProjects?.length === 0 && ( -
- - - No projects found - -
- )} -
- {filteredProjects?.map((project) => { - const emptyServices = project?.environments - .map( - (env) => - env.applications.length === 0 && - env.mariadb.length === 0 && - env.mongo.length === 0 && - env.mysql.length === 0 && - env.postgres.length === 0 && - env.redis.length === 0 && - env.applications.length === 0 && - env.compose.length === 0, - ) - .every(Boolean); - - const totalServices = project?.environments - .map( - (env) => - env.mariadb.length + - env.mongo.length + - env.mysql.length + - env.postgres.length + - env.redis.length + - env.applications.length + - env.compose.length, - ) - .reduce((acc, curr) => acc + curr, 0); - - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0, - ) - .some(Boolean); - - const productionEnvironment = project?.environments.find( - (env) => env.isDefault, - ); - - return ( -
- - - {haveServicesWithDomains ? ( - - - - - e.stopPropagation()} - > - {project.environments.some( - (env) => env.applications.length > 0, - ) && ( - - - Applications - - {project.environments.map((env) => - env.applications.map((app) => ( -
- - - - {app.name} - - - - {app.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} - {project.environments.some( - (env) => env.compose.length > 0, - ) && ( - - - Compose - - {project.environments.map((env) => - env.compose.map((comp) => ( -
- - - - {comp.name} - - - - {comp.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} -
-
- ) : null} - - - -
- - - {project.name} - -
- - - {project.description} - -
-
- - - - - e.stopPropagation()} - > - - Actions - -
e.stopPropagation()} - > - -
-
e.stopPropagation()} - > - -
- -
e.stopPropagation()} - > - {(auth?.role === "owner" || - auth?.canDeleteProjects) && ( - - - - e.preventDefault() - } - > - - Delete - - - - - - Are you sure to delete this - project? - - {!emptyServices ? ( -
- - - You have active - services, please delete - them first - -
- ) : ( - - This action cannot be - undone - - )} -
- - - Cancel - - { - await mutateAsync({ - projectId: - project.projectId, - }) - .then(() => { - toast.success( - "Project deleted successfully", - ); - }) - .catch(() => { - toast.error( - "Error deleting this project", - ); - }) - .finally(() => { - utils.project.all.invalidate(); - }); - }} - > - Delete - - -
-
- )} -
-
-
-
-
-
- -
- - Created - - - {totalServices}{" "} - {totalServices === 1 - ? "service" - : "services"} - -
-
-
- -
- ); - })} -
- - )} -
-
-
-
- - ); -}; diff --git a/apps/dokploy/features/projects/application/use-cases/create-project.use-case.ts b/apps/dokploy/features/projects/application/use-cases/create-project.use-case.ts new file mode 100644 index 0000000000..52bd29460e --- /dev/null +++ b/apps/dokploy/features/projects/application/use-cases/create-project.use-case.ts @@ -0,0 +1,50 @@ +import type { Environment } from "../../domain/models/project.models"; +import type { ProjectsRepository } from "../../domain/repositories/projects.repository"; + +interface CreateProjectResult { + shouldNavigate: boolean; + navigationPath?: string; +} + +/** + * Create project use case + * + * @param name Name of the new project + * @param description Description of the new project + * @param repository Projects repository + */ +export const createProjectUseCase = async ( + name: string, + description: string | undefined, + repository: ProjectsRepository, +): Promise => { + const input = { + name, + description, + }; + + const { mutateAsync: createMutation } = repository.create(); + + const result = await createMutation(input); + + await repository.invalidateAll(); + + // Business logic: Navigation logic for new projects + if (result) { + const projectIdToUse = result.projectId; + const defaultEnv = result.environments?.find( + (env: Environment) => env.isDefault, + ); + + if (projectIdToUse && defaultEnv) { + return { + shouldNavigate: true, + navigationPath: `/dashboard/project/${projectIdToUse}/environment/${defaultEnv.environmentId}`, + }; + } + } + + return { + shouldNavigate: false, + }; +}; diff --git a/apps/dokploy/features/projects/application/use-cases/delete-project.use-case.ts b/apps/dokploy/features/projects/application/use-cases/delete-project.use-case.ts new file mode 100644 index 0000000000..c3c77a89d9 --- /dev/null +++ b/apps/dokploy/features/projects/application/use-cases/delete-project.use-case.ts @@ -0,0 +1,18 @@ +import type { ProjectsRepository } from "../../domain/repositories/projects.repository"; + +/** + * Delete project use case + * + * @param projectId ID of the project to delete + * @param repository Projects repository + */ +export const deleteProjectUseCase = async ( + projectId: string, + repository: ProjectsRepository, +): Promise => { + const { mutateAsync } = repository.delete(); + + await mutateAsync({ projectId }); + + await repository.invalidateAll(); +}; diff --git a/apps/dokploy/features/projects/application/use-cases/update-project.use-case.ts b/apps/dokploy/features/projects/application/use-cases/update-project.use-case.ts new file mode 100644 index 0000000000..467ee303e5 --- /dev/null +++ b/apps/dokploy/features/projects/application/use-cases/update-project.use-case.ts @@ -0,0 +1,28 @@ +import type { ProjectsRepository } from "../../domain/repositories/projects.repository"; + +/** + * Update project use case + * + * @param projectId ID of the project to update + * @param name New name of the project + * @param description New description of the project + * @param repository Projects repository + */ +export const updateProjectUseCase = async ( + projectId: string, + name: string, + description: string | undefined, + repository: ProjectsRepository, +): Promise => { + const input = { + projectId, + name, + description, + }; + + const { mutateAsync: updateMutation } = repository.update(); + + await updateMutation(input as any); + + await repository.invalidateAll(); +}; diff --git a/apps/dokploy/features/projects/domain/models/project.models.ts b/apps/dokploy/features/projects/domain/models/project.models.ts new file mode 100644 index 0000000000..61191454ec --- /dev/null +++ b/apps/dokploy/features/projects/domain/models/project.models.ts @@ -0,0 +1,62 @@ +/** + * Application and service status type + */ +export type ServiceStatus = "idle" | "running" | "done" | "error" | "cancelled"; + +/** + * Project model + */ +export interface Project { + projectId: string; + name: string; + description?: string; + createdAt: string; + env?: string; + environments: Environment[]; +} + +/** + * Environment model + */ +export interface Environment { + environmentId: string; + name: string; + isDefault: boolean; + applications: Application[]; + mariadb: any[]; + mongo: any[]; + mysql: any[]; + postgres: any[]; + redis: any[]; + compose: Compose[]; +} + +/** + * Application model + */ +export interface Application { + applicationId: string; + name: string; + applicationStatus: ServiceStatus; + domains: Domain[]; +} + +/** + * Compose model + */ +export interface Compose { + composeId: string; + name: string; + composeStatus: ServiceStatus; + domains: Domain[]; +} + +/** + * Domain model + */ +export interface Domain { + domainId: string; + host: string; + path: string; + https: boolean; +} diff --git a/apps/dokploy/features/projects/domain/repositories/projects.repository.ts b/apps/dokploy/features/projects/domain/repositories/projects.repository.ts new file mode 100644 index 0000000000..733b956d88 --- /dev/null +++ b/apps/dokploy/features/projects/domain/repositories/projects.repository.ts @@ -0,0 +1,43 @@ +import type { Project } from "../models/project.models"; + +/** + * Projects repository + */ +export interface ProjectsRepository { + /** + * Get all projects + */ + getAll: () => { + data: Project[] | undefined; + isLoading: boolean; + error: Error | null; + }; + + /** + * Get one project by ID + */ + getOne: ( + projectId: string, + enabled?: boolean, + ) => { data: Project | undefined; isLoading: boolean; error: Error | null }; + + /** + * Create new project + */ + create: () => any; + + /** + * Update existing project + */ + update: () => any; + + /** + * Delete existing project + */ + delete: () => any; + + /** + * Invalidate all projects cache + */ + invalidateAll: () => Promise; +} diff --git a/apps/dokploy/features/projects/infrastructure/api/projects-api.repository.ts b/apps/dokploy/features/projects/infrastructure/api/projects-api.repository.ts new file mode 100644 index 0000000000..82b4cbc5f6 --- /dev/null +++ b/apps/dokploy/features/projects/infrastructure/api/projects-api.repository.ts @@ -0,0 +1,79 @@ +import { api } from "@/utils/api"; +import type { Project } from "../../domain/models/project.models"; +import type { ProjectsRepository } from "../../domain/repositories/projects.repository"; + +/** + * Adapters to transform API data to domain models + */ +const adaptApiProjectToDomain = (apiProject: any): Project => ({ + projectId: apiProject.projectId, + name: apiProject.name, + description: apiProject.description || undefined, + createdAt: apiProject.createdAt, + env: apiProject.env || undefined, + environments: apiProject.environments || [], +}); + +/** + * Projects API repository + */ +export const useProjectsRepository = (): ProjectsRepository => { + const utils = api.useUtils(); + + return { + getAll: () => { + const { data, isLoading, error } = api.project.all.useQuery(); + + return { + data: data ? data.map(adaptApiProjectToDomain) : undefined, + isLoading, + error: error ? new Error(error.message) : null, + }; + }, + + getOne: (projectId: string, enabled = true) => { + const { data, isLoading, error } = api.project.one.useQuery( + { projectId }, + { enabled: !!projectId && enabled }, + ); + + return { + data: data ? adaptApiProjectToDomain(data) : undefined, + isLoading, + error: error ? new Error(error.message) : null, + }; + }, + + create: () => { + return api.project.create.useMutation({ + onSuccess: async (result) => { + await utils.project.all.invalidate(); + return result && "project" in result + ? adaptApiProjectToDomain(result.project) + : result; + }, + }); + }, + + update: () => { + return api.project.update.useMutation({ + onSuccess: async (result) => { + await utils.project.all.invalidate(); + return adaptApiProjectToDomain(result); + }, + }); + }, + + delete: () => { + return api.project.remove.useMutation({ + onSuccess: async () => { + await utils.project.all.invalidate(); + }, + }); + }, + + invalidateAll: async () => { + await utils.project.all.invalidate(); + }, + }; +}; diff --git a/apps/dokploy/features/projects/ui/components/delete-project-dialog.tsx b/apps/dokploy/features/projects/ui/components/delete-project-dialog.tsx new file mode 100644 index 0000000000..1fe7aa8ccd --- /dev/null +++ b/apps/dokploy/features/projects/ui/components/delete-project-dialog.tsx @@ -0,0 +1,89 @@ +import { AlertTriangle, TrashIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { deleteProjectUseCase } from "../../application/use-cases/delete-project.use-case"; +import { useProjectsRepository } from "../../infrastructure/api/projects-api.repository"; + +interface Props { + projectId: string; + emptyServices: boolean; +} + +/** + * Delete project dialog component. + */ +export const DeleteProjectDialog = ({ projectId, emptyServices }: Props) => { + const [isDeleting, setIsDeleting] = useState(false); + const projectsRepository = useProjectsRepository(); + + const handleDelete = useCallback(async () => { + try { + setIsDeleting(true); + + await deleteProjectUseCase( + projectId, + projectsRepository + ); + + toast.success("Project deleted successfully"); + } catch (error) { + toast.error("Error deleting this project"); + } finally { + setIsDeleting(false); + } + }, [projectId, projectsRepository]); + + return ( + + + e.preventDefault()} + > + + Delete + + + + + + Are you sure to delete this project? + + {!emptyServices ? ( +
+ + + You have active services, please delete them first + +
+ ) : ( + + This action cannot be undone + + )} +
+ + Cancel + + Delete + + +
+
+ ); +}; \ No newline at end of file diff --git a/apps/dokploy/features/projects/ui/components/empty-projects-state.tsx b/apps/dokploy/features/projects/ui/components/empty-projects-state.tsx new file mode 100644 index 0000000000..f9b4f0b5e4 --- /dev/null +++ b/apps/dokploy/features/projects/ui/components/empty-projects-state.tsx @@ -0,0 +1,16 @@ +import { FolderInput } from "lucide-react"; + +/** + * Empty projects state component. + */ +export const EmptyProjectsState = () => { + return ( +
+ + + + No projects found + +
+ ); +}; \ No newline at end of file diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/features/projects/ui/components/handle-project.tsx similarity index 69% rename from apps/dokploy/components/dashboard/projects/handle-project.tsx rename to apps/dokploy/features/projects/ui/components/handle-project.tsx index 09fd36f84f..edfba872a0 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/features/projects/ui/components/handle-project.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { PlusIcon, SquarePen } from "lucide-react"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -27,7 +27,9 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { api } from "@/utils/api"; +import { createProjectUseCase } from "../../application/use-cases/create-project.use-case"; +import { updateProjectUseCase } from "../../application/use-cases/update-project.use-case"; +import { useProjectsRepository } from "../../infrastructure/api/projects-api.repository"; const AddProjectSchema = z.object({ name: z @@ -58,70 +60,67 @@ interface Props { projectId?: string; } +/** + * Handle project component. + */ export const HandleProject = ({ projectId }: Props) => { - const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); - - const { mutateAsync, error, isError } = projectId - ? api.project.update.useMutation() - : api.project.create.useMutation(); - - const { data, refetch } = api.project.one.useQuery( - { - projectId: projectId || "", - }, - { - enabled: !!projectId, - }, - ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); const router = useRouter(); + const projectsRepository = useProjectsRepository(); + + const { data } = projectsRepository.getOne(projectId || "", !!projectId); + const form = useForm({ defaultValues: { - description: "", - name: "", + description: data?.description ?? "", + name: data?.name ?? "", + }, + values: { + description: data?.description ?? "", + name: data?.name ?? "", }, resolver: zodResolver(AddProjectSchema), }); - useEffect(() => { - form.reset({ - description: data?.description ?? "", - name: data?.name ?? "", - }); - }, [form, form.reset, form.formState.isSubmitSuccessful, data]); + const onSubmit = async (formData: AddProject) => { + try { + setIsLoading(true); + setError(null); - const onSubmit = async (data: AddProject) => { - await mutateAsync({ - name: data.name, - description: data.description, - projectId: projectId || "", - }) - .then(async (data) => { - await utils.project.all.invalidate(); - toast.success(projectId ? "Project Updated" : "Project Created"); - setIsOpen(false); - if (!projectId) { - const projectIdToUse = - data && "project" in data ? data.project.projectId : undefined; - const environmentIdToUse = - data && "environment" in data - ? data.environment.environmentId - : undefined; + if (projectId) { + // Update existing project + await updateProjectUseCase( + projectId, + formData.name, + formData.description, + projectsRepository + ); + } else { + // Create new project + const result = await createProjectUseCase( + formData.name, + formData.description, + projectsRepository + ); - if (environmentIdToUse && projectIdToUse) { - router.push( - `/dashboard/project/${projectIdToUse}/environment/${environmentIdToUse}`, - ); - } - } else { - refetch(); + // Handle navigation for new projects + if (result.shouldNavigate && result.navigationPath) { + router.push(result.navigationPath); } - }) - .catch(() => { - toast.error( - projectId ? "Error updating a project" : "Error creating a project", - ); - }); + } + + toast.success(projectId ? "Project Updated" : "Project Created"); + setIsOpen(false); + } catch (error) { + setError(error instanceof Error ? error.message : "Unknown error"); + toast.error( + projectId ? "Error updating a project" : "Error creating a project" + ); + } finally { + setIsLoading(false); + } }; return ( @@ -147,7 +146,7 @@ export const HandleProject = ({ projectId }: Props) => { {projectId ? "Update" : "Add a"} project The home of something big! - {isError && {error?.message}} + {error && {error}}
{ + + e.stopPropagation()} + > + {project.environments.some( + (env) => env.applications.length > 0, + ) && ( + + + Applications + + {project.environments.map((env) => + env.applications.map((app) => ( +
+ + + + {app.name} + + + + {app.domains.map((domain) => ( + + + + {domain.host} + + + + + ))} + +
+ )), + )} +
+ )} + {project.environments.some( + (env) => env.compose.length > 0, + ) && ( + + + Compose + + {project.environments.map((env) => + env.compose.map((comp) => ( +
+ + + + {comp.name} + + + + {comp.domains.map((domain) => ( + + + + {domain.host} + + + + + ))} + +
+ )), + )} +
+ )} +
+ + ) : null} + + + +
+ + + {project.name} + +
+ + + {project.description} + +
+
+ + + + + e.stopPropagation()} + > + + Actions + +
e.stopPropagation()} + > + +
+
e.stopPropagation()} + > + +
+ +
e.stopPropagation()} + > + {(auth?.role === "owner" || + auth?.canDeleteProjects) && ( + + )} +
+
+
+
+
+
+ +
+ + Created + + + {totalServices}{" "} + {totalServices === 1 + ? "service" + : "services"} + +
+
+ + + + ); +}; \ No newline at end of file diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/features/projects/ui/components/project-environment.tsx similarity index 71% rename from apps/dokploy/components/dashboard/projects/project-environment.tsx rename to apps/dokploy/features/projects/ui/components/project-environment.tsx index cb6245f08b..12e9cc6d24 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/features/projects/ui/components/project-environment.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { FileIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -25,7 +25,8 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import { api } from "@/utils/api"; + +import { useProjectsRepository } from "../../infrastructure/api/projects-api.repository"; const updateProjectSchema = z.object({ env: z.string().optional(), @@ -38,55 +39,53 @@ interface Props { children?: React.ReactNode; } +/** + * Project environment component. + */ export const ProjectEnvironment = ({ projectId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); - const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = - api.project.update.useMutation(); - const { data } = api.project.one.useQuery( - { - projectId, - }, - { - enabled: !!projectId, - }, - ); + const [error, setError] = useState(null); + + const projectsRepository = useProjectsRepository(); + const { data } = projectsRepository.getOne(projectId, !!projectId); + const { mutateAsync: updateProject, isPending: isLoading } = projectsRepository.update(); const form = useForm({ defaultValues: { env: data?.env ?? "", }, + values: { + env: data?.env ?? "", + }, resolver: zodResolver(updateProjectSchema), }); - useEffect(() => { - if (data) { - form.reset({ - env: data.env ?? "", + + const onSubmit = useCallback(async (formData: UpdateProject) => { + try { + setError(null); + + await updateProject({ + projectId, + env: formData.env || "", }); - } - }, [data, form, form.reset]); - const onSubmit = async (formData: UpdateProject) => { - await mutateAsync({ - env: formData.env || "", - projectId: projectId, - }) - .then(() => { - toast.success("Project env updated successfully"); - utils.project.all.invalidate(); - }) - .catch(() => { - toast.error("Error updating the env"); - }) - .finally(() => {}); - }; + toast.success("Project env updated successfully"); + await projectsRepository.invalidateAll(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Error updating the env"; + setError(errorMessage); + toast.error("Error updating the env"); + } + }, [projectId, updateProject, projectsRepository.invalidateAll]); // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { e.preventDefault(); - form.handleSubmit(onSubmit)(); + // Get current form values and call onSubmit directly + const currentValues = form.getValues(); + onSubmit(currentValues); } }; @@ -94,7 +93,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading, isOpen]); + }, [onSubmit, isLoading, isOpen]); // Remove form.handleSubmit dependency return ( @@ -117,7 +116,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { services of this project. - {isError && {error?.message}} + {error && {error}} Use this syntax to reference project-level variables in your service environments: DATABASE_URL=${"{{project.DATABASE_URL}}"} @@ -137,14 +136,14 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { Environment variables diff --git a/apps/dokploy/features/projects/ui/components/projects-filters.tsx b/apps/dokploy/features/projects/ui/components/projects-filters.tsx new file mode 100644 index 0000000000..4c41ef47e1 --- /dev/null +++ b/apps/dokploy/features/projects/ui/components/projects-filters.tsx @@ -0,0 +1,57 @@ +import { ArrowUpDown, Search } from "lucide-react"; +import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface Props { + searchQuery: string; + onSearchChange: (value: string) => void; + sortBy: string; + onSortChange: (value: string) => void; +} + +/** + * Projects filters component. + */ +export const ProjectsFilters = ({ + searchQuery, + onSearchChange, + sortBy, + onSortChange, +}: Props) => { + return ( +
+
+ onSearchChange(e.target.value)} + className="pr-10" + /> + + +
+
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/apps/dokploy/features/projects/ui/containers/show-projects.tsx b/apps/dokploy/features/projects/ui/containers/show-projects.tsx new file mode 100644 index 0000000000..db499bcb5f --- /dev/null +++ b/apps/dokploy/features/projects/ui/containers/show-projects.tsx @@ -0,0 +1,203 @@ +import { + FolderInput, + Loader2, +} from "lucide-react"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { TimeBadge } from "@/components/ui/time-badge"; +import { api } from "@/utils/api"; +import { useDebounce } from "@/utils/hooks/use-debounce"; +import { useProjectsRepository } from "../../infrastructure/api/projects-api.repository"; +import { EmptyProjectsState } from "../components/empty-projects-state"; +import { HandleProject } from "../components/handle-project"; +import { ProjectCard } from "../components/project-card"; +import { ProjectsFilters } from "../components/projects-filters"; + +/** + * Show projects container component. + */ +export const ShowProjects = () => { + const router = useRouter(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + + const projectsRepository = useProjectsRepository(); + const { data, isLoading, error } = projectsRepository.getAll(); + const { data: auth } = api.user.get.useQuery(); + + const [searchQuery, setSearchQuery] = useState( + router.isReady && typeof router.query.q === "string" ? router.query.q : "", + ); + const debouncedSearchQuery = useDebounce(searchQuery, 500); + + const [sortBy, setSortBy] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem("projectsSort") || "createdAt-desc"; + } + return "createdAt-desc"; + }); + + useEffect(() => { + localStorage.setItem("projectsSort", sortBy); + }, [sortBy]); + + useEffect(() => { + if (!router.isReady) return; + const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; + if (urlQuery !== searchQuery) { + setSearchQuery(urlQuery); + } + }, [router.isReady, router.query.q]); + + useEffect(() => { + if (!router.isReady) return; + const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; + if (debouncedSearchQuery === urlQuery) return; + + const newQuery = { ...router.query }; + if (debouncedSearchQuery) { + newQuery.q = debouncedSearchQuery; + } else { + delete newQuery.q; + } + router.replace({ pathname: router.pathname, query: newQuery }, undefined, { + shallow: true, + }); + }, [debouncedSearchQuery]); + + const filteredProjects = useMemo(() => { + if (!data) return []; + + const filtered = data.filter( + (project) => + project.name + .toLowerCase() + .includes(debouncedSearchQuery.toLowerCase()) || + project.description + ?.toLowerCase() + .includes(debouncedSearchQuery.toLowerCase()), + ); + + // Then sort the filtered results + const [field, direction] = sortBy.split("-"); + return [...filtered].sort((a, b) => { + let comparison = 0; + switch (field) { + case "name": + comparison = a.name.localeCompare(b.name); + break; + case "createdAt": + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "services": { + const aTotalServices = a.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + const bTotalServices = b.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + comparison = aTotalServices - bTotalServices; + break; + } + default: + comparison = 0; + } + return direction === "asc" ? comparison : -comparison; + }); + }, [data, debouncedSearchQuery, sortBy]); + + return ( + <> + + + {!isCloud && ( +
+ +
+ )} + +
+ +
+
+ + + + Projects + + + Create and manage your projects + + + {(auth?.role === "owner" || + auth?.role === "admin" || + auth?.canCreateProjects) && ( +
+ +
+ )} +
+ + + {isLoading ? ( +
+ Loading... + +
+ ) : ( + <> + + + {filteredProjects?.length === 0 && } + +
+ {filteredProjects?.map((project) => ( + + ))} +
+ + )} +
+
+
+
+ + ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index dcc34cec22..34ae432a4c 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -35,7 +35,6 @@ import { AddTemplate } from "@/components/dashboard/project/add-template"; import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector"; import { DuplicateProject } from "@/components/dashboard/project/duplicate-project"; import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables"; -import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment"; import { MariadbIcon, MongodbIcon, @@ -95,6 +94,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { ProjectEnvironment } from "@/features/projects/ui/components/project-environment"; import { cn } from "@/lib/utils"; import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; diff --git a/apps/dokploy/pages/dashboard/projects.tsx b/apps/dokploy/pages/dashboard/projects.tsx index ac7f5dc1a4..3712c8eeb1 100644 --- a/apps/dokploy/pages/dashboard/projects.tsx +++ b/apps/dokploy/pages/dashboard/projects.tsx @@ -4,8 +4,8 @@ import type { GetServerSidePropsContext } from "next"; import dynamic from "next/dynamic"; import type { ReactElement } from "react"; import superjson from "superjson"; -import { ShowProjects } from "@/components/dashboard/projects/show"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { ShowProjects } from "@/features/projects/ui/containers/show-projects"; import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 9f46d7de37..349ed6dcc4 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -280,6 +280,7 @@ export const projectRouter = createTRPCRouter({ remove: protectedProcedure .input(apiRemoveProject) .mutation(async ({ input, ctx }) => { + console.log("Deleting project...", input); try { if (ctx.user.role === "member") { await checkProjectAccess( @@ -301,6 +302,7 @@ export const projectRouter = createTRPCRouter({ return deletedProject; } catch (error) { + console.error("Error deleting project:", error); throw error; } }),