Skip to content

Commit 8a8688c

Browse files
authored
Merge pull request #3706 from cucumber-sp/canary
feat: add project tags for organizing services
2 parents 837373f + bd18461 commit 8a8688c

File tree

22 files changed

+9345
-27
lines changed

22 files changed

+9345
-27
lines changed

apps/dokploy/components/dashboard/projects/handle-project.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
77
import { toast } from "sonner";
88
import { z } from "zod";
99
import { AlertBlock } from "@/components/shared/alert-block";
10+
import { TagSelector } from "@/components/shared/tag-selector";
1011
import { Button } from "@/components/ui/button";
1112
import {
1213
Dialog,
@@ -62,6 +63,7 @@ interface Props {
6263
export const HandleProject = ({ projectId }: Props) => {
6364
const utils = api.useUtils();
6465
const [isOpen, setIsOpen] = useState(false);
66+
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
6567

6668
const { mutateAsync, error, isError } = projectId
6769
? api.project.update.useMutation()
@@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => {
7577
enabled: !!projectId,
7678
},
7779
);
80+
81+
const { data: availableTags = [] } = api.tag.all.useQuery();
82+
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
83+
7884
const router = useRouter();
7985
const form = useForm<AddProject>({
8086
defaultValues: {
@@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
8995
description: data?.description ?? "",
9096
name: data?.name ?? "",
9197
});
98+
// Load existing tags when editing a project
99+
if (data?.projectTags) {
100+
const tagIds = data.projectTags.map((pt) => pt.tagId);
101+
setSelectedTagIds(tagIds);
102+
} else {
103+
setSelectedTagIds([]);
104+
}
92105
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
93106

94107
const onSubmit = async (data: AddProject) => {
@@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
98111
projectId: projectId || "",
99112
})
100113
.then(async (data) => {
114+
// Assign tags to the project (both create and update)
115+
const projectIdToUse =
116+
projectId ||
117+
(data && "project" in data ? data.project.projectId : undefined);
118+
119+
if (projectIdToUse) {
120+
try {
121+
await bulkAssignMutation.mutateAsync({
122+
projectId: projectIdToUse,
123+
tagIds: selectedTagIds,
124+
});
125+
} catch (error) {
126+
toast.error("Failed to assign tags to project");
127+
}
128+
}
129+
101130
await utils.project.all.invalidate();
102131
toast.success(projectId ? "Project Updated" : "Project Created");
103132
setIsOpen(false);
104133
if (!projectId) {
105-
const projectIdToUse =
106-
data && "project" in data ? data.project.projectId : undefined;
107134
const environmentIdToUse =
108135
data && "environment" in data
109136
? data.environment.environmentId
@@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
190217
</FormItem>
191218
)}
192219
/>
220+
221+
<div className="space-y-2">
222+
<FormLabel>Tags</FormLabel>
223+
<TagSelector
224+
tags={availableTags.map((tag) => ({
225+
id: tag.tagId,
226+
name: tag.name,
227+
color: tag.color ?? undefined,
228+
}))}
229+
selectedTags={selectedTagIds}
230+
onTagsChange={setSelectedTagIds}
231+
placeholder="Select tags..."
232+
/>
233+
</div>
193234
</form>
194235

195236
<DialogFooter>

apps/dokploy/components/dashboard/projects/show.tsx

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { toast } from "sonner";
1515
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
1616
import { DateTooltip } from "@/components/shared/date-tooltip";
1717
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
18+
import { TagBadge } from "@/components/shared/tag-badge";
19+
import { TagFilter } from "@/components/shared/tag-filter";
1820
import {
1921
AlertDialog,
2022
AlertDialogAction,
@@ -63,6 +65,7 @@ export const ShowProjects = () => {
6365
const { data: auth } = api.user.get.useQuery();
6466
const { data: permissions } = api.user.getPermissions.useQuery();
6567
const { mutateAsync } = api.project.remove.useMutation();
68+
const { data: availableTags } = api.tag.all.useQuery();
6669

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

82+
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
83+
if (typeof window !== "undefined") {
84+
const saved = localStorage.getItem("projectsTagFilter");
85+
return saved ? JSON.parse(saved) : [];
86+
}
87+
return [];
88+
});
89+
7990
useEffect(() => {
8091
localStorage.setItem("projectsSort", sortBy);
8192
}, [sortBy]);
8293

94+
useEffect(() => {
95+
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
96+
}, [selectedTagIds]);
97+
98+
useEffect(() => {
99+
if (!availableTags) return;
100+
const validIds = new Set(availableTags.map((t) => t.tagId));
101+
setSelectedTagIds((prev) => {
102+
const filtered = prev.filter((id) => validIds.has(id));
103+
return filtered.length === prev.length ? prev : filtered;
104+
});
105+
}, [availableTags]);
106+
83107
useEffect(() => {
84108
if (!router.isReady) return;
85109
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
@@ -107,7 +131,7 @@ export const ShowProjects = () => {
107131
const filteredProjects = useMemo(() => {
108132
if (!data) return [];
109133

110-
const filtered = data.filter(
134+
let filtered = data.filter(
111135
(project) =>
112136
project.name
113137
.toLowerCase()
@@ -117,6 +141,15 @@ export const ShowProjects = () => {
117141
.includes(debouncedSearchQuery.toLowerCase()),
118142
);
119143

144+
// Filter by selected tags (OR logic: show projects with ANY selected tag)
145+
if (selectedTagIds.length > 0) {
146+
filtered = filtered.filter((project) =>
147+
project.projectTags?.some((pt) =>
148+
selectedTagIds.includes(pt.tag.tagId),
149+
),
150+
);
151+
}
152+
120153
// Then sort the filtered results
121154
const [field, direction] = sortBy.split("-");
122155
return [...filtered].sort((a, b) => {
@@ -162,7 +195,7 @@ export const ShowProjects = () => {
162195
}
163196
return direction === "asc" ? comparison : -comparison;
164197
});
165-
}, [data, debouncedSearchQuery, sortBy]);
198+
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
166199

167200
return (
168201
<>
@@ -213,29 +246,44 @@ export const ShowProjects = () => {
213246

214247
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
215248
</div>
216-
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
217-
<ArrowUpDown className="size-4 text-muted-foreground" />
218-
<Select value={sortBy} onValueChange={setSortBy}>
219-
<SelectTrigger className="w-full">
220-
<SelectValue placeholder="Sort by..." />
221-
</SelectTrigger>
222-
<SelectContent>
223-
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
224-
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
225-
<SelectItem value="createdAt-desc">
226-
Newest first
227-
</SelectItem>
228-
<SelectItem value="createdAt-asc">
229-
Oldest first
230-
</SelectItem>
231-
<SelectItem value="services-desc">
232-
Most services
233-
</SelectItem>
234-
<SelectItem value="services-asc">
235-
Least services
236-
</SelectItem>
237-
</SelectContent>
238-
</Select>
249+
<div className="flex items-center gap-2">
250+
<TagFilter
251+
tags={
252+
availableTags?.map((tag) => ({
253+
id: tag.tagId,
254+
name: tag.name,
255+
color: tag.color || undefined,
256+
})) || []
257+
}
258+
selectedTags={selectedTagIds}
259+
onTagsChange={setSelectedTagIds}
260+
/>
261+
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
262+
<ArrowUpDown className="size-4 text-muted-foreground" />
263+
<Select value={sortBy} onValueChange={setSortBy}>
264+
<SelectTrigger className="w-full">
265+
<SelectValue placeholder="Sort by..." />
266+
</SelectTrigger>
267+
<SelectContent>
268+
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
269+
<SelectItem value="name-desc">
270+
Name (Z-A)
271+
</SelectItem>
272+
<SelectItem value="createdAt-desc">
273+
Newest first
274+
</SelectItem>
275+
<SelectItem value="createdAt-asc">
276+
Oldest first
277+
</SelectItem>
278+
<SelectItem value="services-desc">
279+
Most services
280+
</SelectItem>
281+
<SelectItem value="services-asc">
282+
Least services
283+
</SelectItem>
284+
</SelectContent>
285+
</Select>
286+
</div>
239287
</div>
240288
</div>
241289
{filteredProjects?.length === 0 && (
@@ -314,6 +362,19 @@ export const ShowProjects = () => {
314362
{project.description}
315363
</span>
316364

365+
{project.projectTags &&
366+
project.projectTags.length > 0 && (
367+
<div className="flex flex-wrap gap-1.5 mt-2">
368+
{project.projectTags.map((pt) => (
369+
<TagBadge
370+
key={pt.tag.tagId}
371+
name={pt.tag.name}
372+
color={pt.tag.color}
373+
/>
374+
))}
375+
</div>
376+
)}
377+
317378
{hasNoEnvironments && (
318379
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
319380
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />

0 commit comments

Comments
 (0)