feat: add project tags for organizing services

Add tag management system that allows users to create, edit, and delete
tags scoped to their organization, and assign them to projects for
better organization and filtering.

- Add tag and project_tag database schemas with Drizzle migration
- Add tRPC router for tag CRUD and project-tag assignment operations
- Add tag management page in Settings with color picker
- Add tag selector to project create/edit form
- Add tag filter to project list with localStorage persistence
- Display tag badges on project cards
This commit is contained in:
Andrey Onishchenko
2026-02-13 18:37:41 +03:00
parent 389a69484e
commit affd17d788
17 changed files with 8887 additions and 29 deletions

View File

@@ -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,
@@ -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()
@@ -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: {
@@ -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) => {
@@ -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
@@ -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>

View File

@@ -17,6 +17,7 @@ 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 { TagFilter } from "@/components/shared/tag-filter";
import {
AlertDialog,
AlertDialogAction,
@@ -28,6 +29,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -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 : "",
@@ -79,10 +82,22 @@ 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 (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
@@ -110,7 +125,7 @@ export const ShowProjects = () => {
const filteredProjects = useMemo(() => {
if (!data) return [];
const filtered = data.filter(
let filtered = data.filter(
(project) =>
project.name
.toLowerCase()
@@ -120,6 +135,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) => {
@@ -165,7 +189,7 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, debouncedSearchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
return (
<>
@@ -218,29 +242,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 && (
@@ -443,6 +482,31 @@ 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) => (
<Badge
key={pt.tag.tagId}
variant="blank"
style={{
backgroundColor: pt.tag.color
? `${pt.tag.color}33`
: undefined,
color:
pt.tag.color || undefined,
borderColor: pt.tag.color
? `${pt.tag.color}66`
: undefined,
}}
className="border"
>
{pt.tag.name}
</Badge>
))}
</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" />

View File

@@ -0,0 +1,388 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Palette, PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const TagSchema = z.object({
name: z
.string()
.min(1, "Tag name is required")
.max(50, "Tag name must be less than 50 characters")
.refine(
(name) => {
const trimmedName = name.trim();
const validNameRegex =
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
return validNameRegex.test(trimmedName);
},
{
message:
"Tag name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
},
)
.transform((name) => name.trim()),
color: z.string().optional(),
});
type Tag = z.infer<typeof TagSchema>;
interface HandleTagProps {
tagId?: string;
}
export const HandleTag = ({ tagId }: HandleTagProps) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const colorInputRef = useRef<HTMLInputElement>(null);
const { mutateAsync, error, isError } = tagId
? api.tag.update.useMutation()
: api.tag.create.useMutation();
const { data: tag } = api.tag.one.useQuery(
{
tagId: tagId || "",
},
{
enabled: !!tagId,
},
);
const form = useForm<Tag>({
defaultValues: {
name: "",
color: "#3b82f6",
},
resolver: zodResolver(TagSchema),
});
useEffect(() => {
if (tag) {
form.reset({
name: tag.name ?? "",
color: tag.color ?? "#3b82f6",
});
} else {
form.reset({
name: "",
color: "#3b82f6",
});
}
}, [form, form.reset, tag]);
const onSubmit = async (data: Tag) => {
await mutateAsync({
name: data.name,
color: data.color,
...(tagId && { tagId }),
})
.then(async () => {
await utils.tag.all.invalidate();
toast.success(tagId ? "Tag Updated" : "Tag Created");
setIsOpen(false);
form.reset();
})
.catch(() => {
toast.error(tagId ? "Error updating tag" : "Error creating tag");
});
};
const colorValue = form.watch("color");
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{tagId ? (
<Button variant="ghost" size="icon" className="h-8 w-8">
<PenBoxIcon className="h-4 w-4" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
Create Tag
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{tagId ? "Update" : "Create"} Tag</DialogTitle>
<DialogDescription>
{tagId
? "Update the tag name and color"
: "Create a new tag to organize your projects"}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-tag"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Production, Client, Internal"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Color (Optional)</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<FormLabel
className="relative flex items-center justify-center w-12 h-12 rounded-md border-2 cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: field.value || "#3b82f6",
}}
onClick={() => colorInputRef.current?.click()}
>
<div className="flex items-center justify-center">
{!field.value && (
<Palette className="h-5 w-5 text-white" />
)}
</div>
<input
ref={colorInputRef}
type="color"
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
value={field.value || "#3b82f6"}
onChange={field.onChange}
/>
</FormLabel>
<div className="flex-1">
<Input
placeholder="#3b82f6"
{...field}
value={field.value || ""}
onChange={(e) => {
const value = e.target.value;
if (value.startsWith("#") || value === "") {
field.onChange(value);
}
}}
/>
<FormDescription className="mt-1">
Choose a color to easily identify this tag
</FormDescription>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{colorValue && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<Badge
style={{
backgroundColor: `${colorValue}33`,
color: colorValue,
borderColor: `${colorValue}66`,
}}
className="border"
>
{form.watch("name") || "Tag Name"}
</Badge>
</div>
)}
</form>
</Form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form-tag"
type="submit"
>
{tagId ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface DeleteTagProps {
tagId: string;
tagName: string;
}
const DeleteTag = ({ tagId, tagName }: DeleteTagProps) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading } = api.tag.remove.useMutation();
const handleDelete = async () => {
await mutateAsync({ tagId })
.then(async () => {
await utils.tag.all.invalidate();
toast.success("Tag deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting tag");
});
};
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setIsOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the tag "{tagName}"? This will
remove the tag from all projects. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export const TagManager = () => {
const { data: tags, isLoading } = api.tag.all.useQuery();
return (
<div className="w-full">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Tags</CardTitle>
<CardDescription>
Create and manage tags to organize your projects
</CardDescription>
</div>
<HandleTag />
</CardHeader>
<CardContent>
{isLoading && (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">Loading tags...</p>
</div>
)}
{!isLoading && (!tags || tags.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
No tags yet. Create your first tag to start organizing projects.
</p>
</div>
)}
{!isLoading && tags && tags.length > 0 && (
<div className="space-y-2">
{tags.map((tag) => (
<div
key={tag.tagId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3">
<Badge
style={{
backgroundColor: tag.color
? `${tag.color}33`
: undefined,
color: tag.color || undefined,
borderColor: tag.color ? `${tag.color}66` : undefined,
}}
className="border"
>
{tag.name}
</Badge>
{tag.color && (
<span className="text-xs text-muted-foreground font-mono">
{tag.color}
</span>
)}
</div>
<div className="flex items-center gap-1">
<HandleTag tagId={tag.tagId} />
<DeleteTag tagId={tag.tagId} tagName={tag.name} />
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -28,6 +28,7 @@ import {
Server,
ShieldCheck,
Star,
Tags,
Trash2,
User,
Users,
@@ -331,6 +332,14 @@ const MENU: Menu = {
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "Tags",
url: "/dashboard/settings/tags",
icon: Tags,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "Git",

View File

@@ -0,0 +1,168 @@
import { Filter, X } from "lucide-react";
import * as React from "react";
import { HandleTag } from "@/components/dashboard/settings/tags/tag-manager";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export interface Tag {
id: string;
name: string;
color?: string;
}
interface TagFilterProps {
tags: Tag[];
selectedTags: string[];
onTagsChange: (tagIds: string[]) => void;
className?: string;
}
export function TagFilter({
tags,
selectedTags,
onTagsChange,
className,
}: TagFilterProps) {
const [open, setOpen] = React.useState(false);
const handleTagToggle = (tagId: string) => {
if (selectedTags.includes(tagId)) {
onTagsChange(selectedTags.filter((id) => id !== tagId));
} else {
onTagsChange([...selectedTags, tagId]);
}
};
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation();
onTagsChange([]);
};
const selectedTagObjects = tags.filter((tag) =>
selectedTags.includes(tag.id),
);
return (
<div className={cn("flex items-center gap-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("gap-2", selectedTags.length > 0 && "border-primary")}
>
<Filter className="h-4 w-4" />
<span>Filter by Tag</span>
{selectedTags.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0">
{selectedTags.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<div className="flex items-center border-b px-3">
<CommandInput placeholder="Search tags..." className="h-9" />
{selectedTags.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-8 px-2 text-xs"
>
Clear
</Button>
)}
</div>
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
</span>
<HandleTag />
</div>
</CommandEmpty>
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<CommandItem
key={tag.id}
onSelect={() => handleTagToggle(tag.id)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => handleTagToggle(tag.id)}
/>
<Badge
variant="blank"
style={{
backgroundColor: tag.color
? `${tag.color}33`
: undefined,
color: tag.color || undefined,
borderColor: tag.color ? `${tag.color}66` : undefined,
}}
className="flex-1 border"
>
{tag.name}
</Badge>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedTagObjects.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
{selectedTagObjects.map((tag) => (
<Badge
key={tag.id}
variant="blank"
style={{
backgroundColor: tag.color ? `${tag.color}33` : undefined,
color: tag.color || undefined,
borderColor: tag.color ? `${tag.color}66` : undefined,
}}
className="flex items-center gap-1 pr-1 border"
>
<span>{tag.name}</span>
<button
type="button"
onClick={() =>
onTagsChange(selectedTags.filter((id) => id !== tag.id))
}
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<X className="h-3 w-3 hover:opacity-70" />
<span className="sr-only">Remove {tag.name} filter</span>
</button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import * as React from "react";
import { HandleTag } from "@/components/dashboard/settings/tags/tag-manager";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export interface Tag {
id: string;
name: string;
color?: string;
}
interface TagSelectorProps {
tags: Tag[];
selectedTags: string[];
onTagsChange: (tagIds: string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export function TagSelector({
tags,
selectedTags,
onTagsChange,
placeholder = "Select tags...",
className,
disabled = false,
}: TagSelectorProps) {
const [open, setOpen] = React.useState(false);
const handleTagToggle = (tagId: string) => {
if (selectedTags.includes(tagId)) {
onTagsChange(selectedTags.filter((id) => id !== tagId));
} else {
onTagsChange([...selectedTags, tagId]);
}
};
const handleTagRemove = (tagId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
onTagsChange(selectedTags.filter((id) => id !== tagId));
};
const selectedTagObjects = tags.filter((tag) =>
selectedTags.includes(tag.id),
);
return (
<div className={cn("w-full", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className={cn(
"w-full justify-between min-h-10 h-auto",
disabled && "cursor-not-allowed opacity-50",
)}
disabled={disabled}
>
<div className="flex flex-wrap gap-1 flex-1">
{selectedTagObjects.length > 0 ? (
selectedTagObjects.map((tag) => (
<Badge
key={tag.id}
variant="blank"
style={{
backgroundColor: tag.color ? `${tag.color}33` : undefined,
color: tag.color || undefined,
borderColor: tag.color ? `${tag.color}66` : undefined,
}}
className="flex items-center gap-1 pr-1 border"
>
<span>{tag.name}</span>
<button
type="button"
onClick={(e) => handleTagRemove(tag.id, e)}
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
disabled={disabled}
>
<X className="h-3 w-3 hover:opacity-70" />
<span className="sr-only">Remove {tag.name}</span>
</button>
</Badge>
))
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search tags..." />
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
</span>
<HandleTag />
</div>
</CommandEmpty>
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<CommandItem
key={tag.id}
onSelect={() => handleTagToggle(tag.id)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => handleTagToggle(tag.id)}
/>
<Badge
variant="blank"
style={{
backgroundColor: tag.color
? `${tag.color}33`
: undefined,
color: tag.color || undefined,
borderColor: tag.color ? `${tag.color}66` : undefined,
}}
className="mr-2 border"
>
{tag.name}
</Badge>
<Check
className={cn(
"ml-auto h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,19 @@
CREATE TABLE "project_tag" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"tagId" text NOT NULL,
CONSTRAINT "unique_project_tag" UNIQUE("projectId","tagId")
);
--> statement-breakpoint
CREATE TABLE "tag" (
"tagId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"color" text,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL,
CONSTRAINT "unique_org_tag_name" UNIQUE("organizationId","name")
);
--> statement-breakpoint
ALTER TABLE "project_tag" ADD CONSTRAINT "project_tag_projectId_project_projectId_fk" FOREIGN KEY ("projectId") REFERENCES "public"."project"("projectId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_tag" ADD CONSTRAINT "project_tag_tagId_tag_tagId_fk" FOREIGN KEY ("tagId") REFERENCES "public"."tag"("tagId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1009,6 +1009,13 @@
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1770989218411,
"tag": "0144_cynical_robin_chapel",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,61 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { TagManager } from "@/components/dashboard/settings/tags/tag-manager";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<TagManager />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -22,12 +22,12 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
@@ -39,6 +39,7 @@ import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { userRouter } from "./routers/user";
import { volumeBackupsRouter } from "./routers/volume-backups";
/**
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
environment: environmentRouter,
tag: tagRouter,
});
// export type definition of API

View File

@@ -163,6 +163,11 @@ export const projectRouter = createTRPCRouter({
},
},
},
projectTags: {
with: {
tag: true,
},
},
},
});
@@ -245,6 +250,11 @@ export const projectRouter = createTRPCRouter({
},
},
},
projectTags: {
with: {
tag: true,
},
},
},
orderBy: desc(projects.createdAt),
});
@@ -271,6 +281,11 @@ export const projectRouter = createTRPCRouter({
},
},
},
projectTags: {
with: {
tag: true,
},
},
},
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(projects.createdAt),

View File

@@ -0,0 +1,387 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/server/db";
import {
apiCreateTag,
apiFindOneTag,
apiRemoveTag,
apiUpdateTag,
projects,
projectTags,
tags,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const tagRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateTag)
.mutation(async ({ input, ctx }) => {
try {
const newTag = await db
.insert(tags)
.values({
name: input.name,
color: input.color,
organizationId: ctx.session.activeOrganizationId,
})
.returning();
return newTag[0];
} catch (error) {
if (
error instanceof Error &&
error.message.includes("unique_org_tag_name")
) {
throw new TRPCError({
code: "CONFLICT",
message: "A tag with this name already exists in your organization",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error creating tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
all: protectedProcedure.query(async ({ ctx }) => {
try {
const organizationTags = await db.query.tags.findMany({
where: eq(tags.organizationId, ctx.session.activeOrganizationId),
orderBy: (tags, { asc }) => [asc(tags.name)],
});
return organizationTags;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tags: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
one: protectedProcedure.input(apiFindOneTag).query(async ({ input, ctx }) => {
try {
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found",
});
}
return tag;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
update: protectedProcedure
.input(apiUpdateTag)
.mutation(async ({ input, ctx }) => {
try {
// First verify the tag belongs to the user's organization
const existingTag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!existingTag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to update it",
});
}
const updatedTag = await db
.update(tags)
.set({
...(input.name !== undefined && { name: input.name }),
...(input.color !== undefined && { color: input.color }),
})
.where(eq(tags.tagId, input.tagId))
.returning();
return updatedTag[0];
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (
error instanceof Error &&
error.message.includes("unique_org_tag_name")
) {
throw new TRPCError({
code: "CONFLICT",
message: "A tag with this name already exists in your organization",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error updating tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
remove: protectedProcedure
.input(apiRemoveTag)
.mutation(async ({ input, ctx }) => {
try {
// First verify the tag belongs to the user's organization
const existingTag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!existingTag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to delete it",
});
}
// Delete the tag - cascade delete will handle projectTags associations
await db.delete(tags).where(eq(tags.tagId, input.tagId));
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error deleting tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
assignToProject: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
try {
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to use it",
});
}
// Insert the project-tag association
const newAssociation = await db
.insert(projectTags)
.values({
projectId: input.projectId,
tagId: input.tagId,
})
.returning();
return newAssociation[0];
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (
error instanceof Error &&
error.message.includes("unique_project_tag")
) {
throw new TRPCError({
code: "CONFLICT",
message: "This tag is already assigned to this project",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error assigning tag to project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
removeFromProject: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
try {
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to use it",
});
}
// Delete the project-tag association
await db
.delete(projectTags)
.where(
and(
eq(projectTags.projectId, input.projectId),
eq(projectTags.tagId, input.tagId),
),
);
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error removing tag from project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
bulkAssign: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagIds: z.array(z.string().min(1)),
}),
)
.mutation(async ({ input, ctx }) => {
try {
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify all tags belong to the user's organization
if (input.tagIds.length > 0) {
const tagCount = await db.query.tags.findMany({
where: and(
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
const validTagIds = tagCount.map((tag) => tag.tagId);
const invalidTags = input.tagIds.filter(
(id) => !validTagIds.includes(id),
);
if (invalidTags.length > 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more tags not found in your organization",
});
}
}
// Delete all existing tag associations for this project
await db
.delete(projectTags)
.where(eq(projectTags.projectId, input.projectId));
// Insert new tag associations
if (input.tagIds.length > 0) {
await db.insert(projectTags).values(
input.tagIds.map((tagId) => ({
projectId: input.projectId,
tagId,
})),
);
}
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error bulk assigning tags to project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
});

View File

@@ -33,6 +33,7 @@ export * from "./session";
export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./user";
export * from "./utils";
export * from "./volume-backups";

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { environments } from "./environment";
import { projectTags } from "./tag";
export const projects = pgTable("project", {
projectId: text("projectId")
@@ -25,6 +26,7 @@ export const projects = pgTable("project", {
export const projectRelations = relations(projects, ({ many, one }) => ({
environments: many(environments),
projectTags: many(projectTags),
organization: one(organization, {
fields: [projects.organizationId],
references: [organization.id],

View File

@@ -0,0 +1,101 @@
import { relations } from "drizzle-orm";
import { pgTable, text, unique } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { projects } from "./project";
export const tags = pgTable(
"tag",
{
tagId: text("tagId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
color: text("color"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
},
(table) => ({
// Unique index on (organizationId, name) to prevent duplicate tag names per organization
uniqueOrgName: unique("unique_org_tag_name").on(
table.organizationId,
table.name,
),
}),
);
export const projectTags = pgTable(
"project_tag",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
tagId: text("tagId")
.notNull()
.references(() => tags.tagId, { onDelete: "cascade" }),
},
(table) => ({
// Unique constraint to prevent duplicate project-tag associations
uniqueProjectTag: unique("unique_project_tag").on(
table.projectId,
table.tagId,
),
}),
);
export const tagRelations = relations(tags, ({ one, many }) => ({
organization: one(organization, {
fields: [tags.organizationId],
references: [organization.id],
}),
projectTags: many(projectTags),
}));
export const projectTagRelations = relations(projectTags, ({ one }) => ({
project: one(projects, {
fields: [projectTags.projectId],
references: [projects.projectId],
}),
tag: one(tags, {
fields: [projectTags.tagId],
references: [tags.tagId],
}),
}));
const createSchema = createInsertSchema(tags, {
tagId: z.string().min(1),
name: z.string().min(1),
color: z.string().optional(),
});
export const apiCreateTag = createSchema.pick({
name: true,
color: true,
});
export const apiFindOneTag = createSchema
.pick({
tagId: true,
})
.required();
export const apiRemoveTag = createSchema
.pick({
tagId: true,
})
.required();
export const apiUpdateTag = createSchema.partial().extend({
tagId: z.string().min(1),
});

View File

@@ -60,6 +60,11 @@ export const findProjectById = async (projectId: string) => {
compose: true,
},
},
projectTags: {
with: {
tag: true,
},
},
},
});
if (!project) {