Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions apps/dokploy/components/dashboard/projects/handle-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { TagSelector } from "@/components/shared/tag-selector";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand Down Expand Up @@ -61,6 +62,7 @@ interface Props {
export const HandleProject = ({ projectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);

const { mutateAsync, error, isError } = projectId
? api.project.update.useMutation()
Expand All @@ -74,6 +76,10 @@ export const HandleProject = ({ projectId }: Props) => {
enabled: !!projectId,
},
);

const { data: availableTags = [] } = api.tag.all.useQuery();
const bulkAssignMutation = api.tag.bulkAssign.useMutation();

const router = useRouter();
const form = useForm<AddProject>({
defaultValues: {
Expand All @@ -88,6 +94,13 @@ export const HandleProject = ({ projectId }: Props) => {
description: data?.description ?? "",
name: data?.name ?? "",
});
// Load existing tags when editing a project
if (data?.projectTags) {
const tagIds = data.projectTags.map((pt) => pt.tagId);
setSelectedTagIds(tagIds);
} else {
setSelectedTagIds([]);
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);

const onSubmit = async (data: AddProject) => {
Expand All @@ -97,12 +110,26 @@ export const HandleProject = ({ projectId }: Props) => {
projectId: projectId || "",
})
.then(async (data) => {
// Assign tags to the project (both create and update)
const projectIdToUse =
projectId ||
(data && "project" in data ? data.project.projectId : undefined);

if (projectIdToUse) {
try {
await bulkAssignMutation.mutateAsync({
projectId: projectIdToUse,
tagIds: selectedTagIds,
});
} catch (error) {
toast.error("Failed to assign tags to project");
}
}

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
Expand Down Expand Up @@ -189,6 +216,20 @@ export const HandleProject = ({ projectId }: Props) => {
</FormItem>
)}
/>

<div className="space-y-2">
<FormLabel>Tags</FormLabel>
<TagSelector
tags={availableTags.map((tag) => ({
id: tag.tagId,
name: tag.name,
color: tag.color ?? undefined,
}))}
selectedTags={selectedTagIds}
onTagsChange={setSelectedTagIds}
placeholder="Select tags..."
/>
</div>
</form>

<DialogFooter>
Expand Down
111 changes: 86 additions & 25 deletions apps/dokploy/components/dashboard/projects/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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 { TagBadge } from "@/components/shared/tag-badge";
import { TagFilter } from "@/components/shared/tag-filter";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -66,6 +68,7 @@ export const ShowProjects = () => {
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const { data: availableTags } = api.tag.all.useQuery();

const [searchQuery, setSearchQuery] = useState(
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
Expand All @@ -79,10 +82,31 @@ export const ShowProjects = () => {
return "createdAt-desc";
});

const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("projectsTagFilter");
return saved ? JSON.parse(saved) : [];
}
return [];
});

useEffect(() => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);

useEffect(() => {
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
}, [selectedTagIds]);

useEffect(() => {
if (!availableTags) return;
const validIds = new Set(availableTags.map((t) => t.tagId));
setSelectedTagIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [availableTags]);

useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
Expand Down Expand Up @@ -110,7 +134,7 @@ export const ShowProjects = () => {
const filteredProjects = useMemo(() => {
if (!data) return [];

const filtered = data.filter(
let filtered = data.filter(
(project) =>
project.name
.toLowerCase()
Expand All @@ -120,6 +144,15 @@ export const ShowProjects = () => {
.includes(debouncedSearchQuery.toLowerCase()),
);

// Filter by selected tags (OR logic: show projects with ANY selected tag)
if (selectedTagIds.length > 0) {
filtered = filtered.filter((project) =>
project.projectTags?.some((pt) =>
selectedTagIds.includes(pt.tag.tagId),
),
);
}

// Then sort the filtered results
const [field, direction] = sortBy.split("-");
return [...filtered].sort((a, b) => {
Expand Down Expand Up @@ -165,7 +198,7 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, debouncedSearchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);

return (
<>
Expand Down Expand Up @@ -218,29 +251,44 @@ export const ShowProjects = () => {

<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<ArrowUpDown className="size-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<TagFilter
tags={
availableTags?.map((tag) => ({
id: tag.tagId,
name: tag.name,
color: tag.color || undefined,
})) || []
}
selectedTags={selectedTagIds}
onTagsChange={setSelectedTagIds}
/>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<ArrowUpDown className="size-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">
Name (Z-A)
</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredProjects?.length === 0 && (
Expand Down Expand Up @@ -443,6 +491,19 @@ export const ShowProjects = () => {
{project.description}
</span>

{project.projectTags &&
project.projectTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{project.projectTags.map((pt) => (
<TagBadge
key={pt.tag.tagId}
name={pt.tag.name}
color={pt.tag.color}
/>
))}
</div>
)}

{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
Expand Down
Loading