mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
388
apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
Normal file
388
apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
168
apps/dokploy/components/shared/tag-filter.tsx
Normal file
168
apps/dokploy/components/shared/tag-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
apps/dokploy/components/shared/tag-selector.tsx
Normal file
164
apps/dokploy/components/shared/tag-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/dokploy/drizzle/0144_cynical_robin_chapel.sql
Normal file
19
apps/dokploy/drizzle/0144_cynical_robin_chapel.sql
Normal 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;
|
||||
7424
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
7424
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
61
apps/dokploy/pages/dashboard/settings/tags.tsx
Normal file
61
apps/dokploy/pages/dashboard/settings/tags.tsx
Normal 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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
387
apps/dokploy/server/api/routers/tag.ts
Normal file
387
apps/dokploy/server/api/routers/tag.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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],
|
||||
|
||||
101
packages/server/src/db/schema/tag.ts
Normal file
101
packages/server/src/db/schema/tag.ts
Normal 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),
|
||||
});
|
||||
@@ -60,6 +60,11 @@ export const findProjectById = async (projectId: string) => {
|
||||
compose: true,
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
|
||||
Reference in New Issue
Block a user