mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-11 06:25:17 +00:00
Merge pull request #3706 from cucumber-sp/canary
feat: add project tags for organizing services
This commit is contained in:
@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { TagSelector } from "@/components/shared/tag-selector";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -62,6 +63,7 @@ interface Props {
|
|||||||
export const HandleProject = ({ projectId }: Props) => {
|
export const HandleProject = ({ projectId }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const { mutateAsync, error, isError } = projectId
|
const { mutateAsync, error, isError } = projectId
|
||||||
? api.project.update.useMutation()
|
? api.project.update.useMutation()
|
||||||
@@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
enabled: !!projectId,
|
enabled: !!projectId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: availableTags = [] } = api.tag.all.useQuery();
|
||||||
|
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<AddProject>({
|
const form = useForm<AddProject>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
description: data?.description ?? "",
|
description: data?.description ?? "",
|
||||||
name: data?.name ?? "",
|
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]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddProject) => {
|
const onSubmit = async (data: AddProject) => {
|
||||||
@@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
projectId: projectId || "",
|
projectId: projectId || "",
|
||||||
})
|
})
|
||||||
.then(async (data) => {
|
.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();
|
await utils.project.all.invalidate();
|
||||||
toast.success(projectId ? "Project Updated" : "Project Created");
|
toast.success(projectId ? "Project Updated" : "Project Created");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
const projectIdToUse =
|
|
||||||
data && "project" in data ? data.project.projectId : undefined;
|
|
||||||
const environmentIdToUse =
|
const environmentIdToUse =
|
||||||
data && "environment" in data
|
data && "environment" in data
|
||||||
? data.environment.environmentId
|
? data.environment.environmentId
|
||||||
@@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
</FormItem>
|
</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>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { toast } from "sonner";
|
|||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||||
|
import { TagBadge } from "@/components/shared/tag-badge";
|
||||||
|
import { TagFilter } from "@/components/shared/tag-filter";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -63,6 +65,7 @@ export const ShowProjects = () => {
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
|
const { data: availableTags } = api.tag.all.useQuery();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(
|
const [searchQuery, setSearchQuery] = useState(
|
||||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||||
@@ -76,10 +79,31 @@ export const ShowProjects = () => {
|
|||||||
return "createdAt-desc";
|
return "createdAt-desc";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const saved = localStorage.getItem("projectsTagFilter");
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("projectsSort", sortBy);
|
localStorage.setItem("projectsSort", sortBy);
|
||||||
}, [sortBy]);
|
}, [sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
|
||||||
|
}, [selectedTagIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTags) return;
|
||||||
|
const validIds = new Set(availableTags.map((t) => t.tagId));
|
||||||
|
setSelectedTagIds((prev) => {
|
||||||
|
const filtered = prev.filter((id) => validIds.has(id));
|
||||||
|
return filtered.length === prev.length ? prev : filtered;
|
||||||
|
});
|
||||||
|
}, [availableTags]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady) return;
|
if (!router.isReady) return;
|
||||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||||
@@ -107,7 +131,7 @@ export const ShowProjects = () => {
|
|||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
const filtered = data.filter(
|
let filtered = data.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name
|
project.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -117,6 +141,15 @@ export const ShowProjects = () => {
|
|||||||
.includes(debouncedSearchQuery.toLowerCase()),
|
.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
|
// Then sort the filtered results
|
||||||
const [field, direction] = sortBy.split("-");
|
const [field, direction] = sortBy.split("-");
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -162,7 +195,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
return direction === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [data, debouncedSearchQuery, sortBy]);
|
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -213,29 +246,44 @@ export const ShowProjects = () => {
|
|||||||
|
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowUpDown className="size-4 text-muted-foreground" />
|
<TagFilter
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
tags={
|
||||||
<SelectTrigger className="w-full">
|
availableTags?.map((tag) => ({
|
||||||
<SelectValue placeholder="Sort by..." />
|
id: tag.tagId,
|
||||||
</SelectTrigger>
|
name: tag.name,
|
||||||
<SelectContent>
|
color: tag.color || undefined,
|
||||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
})) || []
|
||||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
}
|
||||||
<SelectItem value="createdAt-desc">
|
selectedTags={selectedTagIds}
|
||||||
Newest first
|
onTagsChange={setSelectedTagIds}
|
||||||
</SelectItem>
|
/>
|
||||||
<SelectItem value="createdAt-asc">
|
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||||
Oldest first
|
<ArrowUpDown className="size-4 text-muted-foreground" />
|
||||||
</SelectItem>
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
<SelectItem value="services-desc">
|
<SelectTrigger className="w-full">
|
||||||
Most services
|
<SelectValue placeholder="Sort by..." />
|
||||||
</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="services-asc">
|
<SelectContent>
|
||||||
Least services
|
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="name-desc">
|
||||||
</SelectContent>
|
Name (Z-A)
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{filteredProjects?.length === 0 && (
|
{filteredProjects?.length === 0 && (
|
||||||
@@ -314,6 +362,19 @@ export const ShowProjects = () => {
|
|||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{project.projectTags &&
|
||||||
|
project.projectTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{project.projectTags.map((pt) => (
|
||||||
|
<TagBadge
|
||||||
|
key={pt.tag.tagId}
|
||||||
|
name={pt.tag.name}
|
||||||
|
color={pt.tag.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasNoEnvironments && (
|
{hasNoEnvironments && (
|
||||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
<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" />
|
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Palette, PenBoxIcon, PlusIcon } 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 { TagBadge } from "@/components/shared/tag-badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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>
|
||||||
|
<TagBadge
|
||||||
|
name={form.watch("name") || "Tag Name"}
|
||||||
|
color={colorValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={form.formState.isSubmitting}
|
||||||
|
form="hook-form-tag"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{tagId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Loader2, TagIcon, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { TagBadge } from "@/components/shared/tag-badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { HandleTag } from "./handle-tag";
|
||||||
|
|
||||||
|
export const TagManager = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: tags, isPending } = api.tag.all.useQuery();
|
||||||
|
const { mutateAsync: deleteTag, isPending: isRemoving } =
|
||||||
|
api.tag.remove.useMutation();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<TagIcon className="size-6 text-muted-foreground self-center" />
|
||||||
|
Tags
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create and manage tags to organize your projects
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!tags || tags.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
|
<TagIcon className="size-6 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground text-center">
|
||||||
|
No tags yet. Create your first tag to start organizing
|
||||||
|
projects.
|
||||||
|
</span>
|
||||||
|
{permissions?.tag.create && <HandleTag />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.tagId}
|
||||||
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TagBadge name={tag.name} color={tag.color} />
|
||||||
|
{tag.color && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{tag.color}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-1 items-center">
|
||||||
|
{permissions?.tag.update && (
|
||||||
|
<HandleTag tagId={tag.tagId} />
|
||||||
|
)}
|
||||||
|
{permissions?.tag.delete && (
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Tag"
|
||||||
|
description={`Are you sure you want to delete the tag "${tag.name}"? This will remove the tag from all projects. This action cannot be undone.`}
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteTag({
|
||||||
|
tagId: tag.tagId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await utils.tag.all.invalidate();
|
||||||
|
toast.success(
|
||||||
|
"Tag deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting tag");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permissions?.tag.create && (
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
|
<HandleTag />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
|
Tags,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
@@ -325,6 +326,13 @@ const MENU: Menu = {
|
|||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Tags",
|
||||||
|
url: "/dashboard/settings/tags",
|
||||||
|
icon: Tags,
|
||||||
|
isEnabled: ({ permissions }) => !!permissions?.tag.read,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Git",
|
title: "Git",
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
"Manage notification providers (Slack, Discord, Telegram, etc.)",
|
"Manage notification providers (Slack, Discord, Telegram, etc.)",
|
||||||
},
|
},
|
||||||
|
tag: {
|
||||||
|
label: "Tags",
|
||||||
|
description: "Manage tags to organize and categorize projects",
|
||||||
|
},
|
||||||
member: {
|
member: {
|
||||||
label: "Users",
|
label: "Users",
|
||||||
description: "Manage organization members, invitations, and roles",
|
description: "Manage organization members, invitations, and roles",
|
||||||
@@ -379,6 +383,12 @@ const ACTION_META: Record<
|
|||||||
},
|
},
|
||||||
delete: { label: "Delete", description: "Remove notification providers" },
|
delete: { label: "Delete", description: "Remove notification providers" },
|
||||||
},
|
},
|
||||||
|
tag: {
|
||||||
|
read: { label: "Read", description: "View tags" },
|
||||||
|
create: { label: "Create", description: "Create new tags" },
|
||||||
|
update: { label: "Update", description: "Edit existing tags" },
|
||||||
|
delete: { label: "Delete", description: "Delete tags" },
|
||||||
|
},
|
||||||
member: {
|
member: {
|
||||||
read: {
|
read: {
|
||||||
label: "Read",
|
label: "Read",
|
||||||
@@ -447,6 +457,7 @@ const ROLE_PRESETS: {
|
|||||||
domain: ["read"],
|
domain: ["read"],
|
||||||
destination: ["read"],
|
destination: ["read"],
|
||||||
notification: ["read"],
|
notification: ["read"],
|
||||||
|
tag: ["read"],
|
||||||
member: ["read"],
|
member: ["read"],
|
||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
@@ -515,6 +526,7 @@ const ROLE_PRESETS: {
|
|||||||
domain: ["read", "create", "delete"],
|
domain: ["read", "create", "delete"],
|
||||||
destination: ["read", "create", "delete"],
|
destination: ["read", "create", "delete"],
|
||||||
notification: ["read", "create", "delete"],
|
notification: ["read", "create", "delete"],
|
||||||
|
tag: ["read", "create", "update", "delete"],
|
||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TagBadgeProps {
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagBadge({ name, color, className, children }: TagBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: color ? `${color}33` : undefined,
|
||||||
|
color: color || undefined,
|
||||||
|
borderColor: color ? `${color}66` : undefined,
|
||||||
|
}}
|
||||||
|
className={cn("border", className)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Tags } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { HandleTag } from "@/components/dashboard/settings/tags/handle-tag";
|
||||||
|
import { TagBadge } from "@/components/shared/tag-badge";
|
||||||
|
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([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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")}
|
||||||
|
>
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
<span>Tags</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 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
{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)}
|
||||||
|
/>
|
||||||
|
<TagBadge name={tag.name} color={tag.color} />
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { HandleTag } from "@/components/dashboard/settings/tags/handle-tag";
|
||||||
|
import { TagBadge } from "@/components/shared/tag-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 bg-input",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1 flex-1">
|
||||||
|
{selectedTagObjects.length > 0 ? (
|
||||||
|
selectedTagObjects.map((tag) => (
|
||||||
|
<TagBadge
|
||||||
|
key={tag.id}
|
||||||
|
name={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
className="flex items-center gap-1 pr-1"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</TagBadge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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..."
|
||||||
|
className="focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
<TagBadge
|
||||||
|
name={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
isSelected ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1065,6 +1065,13 @@
|
|||||||
"when": 1773872561300,
|
"when": 1773872561300,
|
||||||
"tag": "0151_modern_sunfire",
|
"tag": "0151_modern_sunfire",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 152,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773903778014,
|
||||||
|
"tag": "0152_odd_firelord",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
await helpers.settings.isCloud.prefetch();
|
||||||
|
|
||||||
|
const userPermissions = await helpers.user.getPermissions.fetch();
|
||||||
|
|
||||||
|
if (!userPermissions?.tag.read) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import { settingsRouter } from "./routers/settings";
|
|||||||
import { sshRouter } from "./routers/ssh-key";
|
import { sshRouter } from "./routers/ssh-key";
|
||||||
import { stripeRouter } from "./routers/stripe";
|
import { stripeRouter } from "./routers/stripe";
|
||||||
import { swarmRouter } from "./routers/swarm";
|
import { swarmRouter } from "./routers/swarm";
|
||||||
|
import { tagRouter } from "./routers/tag";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +98,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
|
tag: tagRouter,
|
||||||
patch: patchRouter,
|
patch: patchRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ export const projectRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectTags: {
|
||||||
|
with: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,6 +285,11 @@ export const projectRouter = createTRPCRouter({
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectTags: {
|
||||||
|
with: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: desc(projects.createdAt),
|
orderBy: desc(projects.createdAt),
|
||||||
});
|
});
|
||||||
@@ -335,6 +345,11 @@ export const projectRouter = createTRPCRouter({
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectTags: {
|
||||||
|
with: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
orderBy: desc(projects.createdAt),
|
orderBy: desc(projects.createdAt),
|
||||||
|
|||||||
@@ -656,6 +656,18 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
"github",
|
"github",
|
||||||
"gitlab",
|
"gitlab",
|
||||||
"gitea",
|
"gitea",
|
||||||
|
"tag",
|
||||||
|
"patch",
|
||||||
|
"server",
|
||||||
|
"volumeBackups",
|
||||||
|
"environment",
|
||||||
|
"auditLog",
|
||||||
|
"customRole",
|
||||||
|
"whitelabeling",
|
||||||
|
"sso",
|
||||||
|
"licenseKey",
|
||||||
|
"organization",
|
||||||
|
"previewDeployment",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||||
|
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, withPermission } from "../trpc";
|
||||||
|
|
||||||
|
export const tagRouter = createTRPCRouter({
|
||||||
|
create: withPermission("tag", "create")
|
||||||
|
.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: withPermission("tag", "update")
|
||||||
|
.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: withPermission("tag", "delete")
|
||||||
|
.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 {
|
||||||
|
const memberRecord = await findMemberByUserId(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 member has access to the project
|
||||||
|
if (
|
||||||
|
memberRecord.role !== "owner" &&
|
||||||
|
memberRecord.role !== "admin" &&
|
||||||
|
!memberRecord.accessedProjects.includes(input.projectId)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const memberRecord = await findMemberByUserId(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 member has access to the project
|
||||||
|
if (
|
||||||
|
memberRecord.role !== "owner" &&
|
||||||
|
memberRecord.role !== "admin" &&
|
||||||
|
!memberRecord.accessedProjects.includes(input.projectId)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const memberRecord = await findMemberByUserId(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 member has access to the project
|
||||||
|
if (
|
||||||
|
memberRecord.role !== "owner" &&
|
||||||
|
memberRecord.role !== "admin" &&
|
||||||
|
!memberRecord.accessedProjects.includes(input.projectId)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ export * from "./session";
|
|||||||
export * from "./shared";
|
export * from "./shared";
|
||||||
export * from "./ssh-key";
|
export * from "./ssh-key";
|
||||||
export * from "./sso";
|
export * from "./sso";
|
||||||
|
export * from "./tag";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./volume-backups";
|
export * from "./volume-backups";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { organization } from "./account";
|
import { organization } from "./account";
|
||||||
import { environments } from "./environment";
|
import { environments } from "./environment";
|
||||||
|
import { projectTags } from "./tag";
|
||||||
|
|
||||||
export const projects = pgTable("project", {
|
export const projects = pgTable("project", {
|
||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
@@ -25,6 +26,7 @@ export const projects = pgTable("project", {
|
|||||||
|
|
||||||
export const projectRelations = relations(projects, ({ many, one }) => ({
|
export const projectRelations = relations(projects, ({ many, one }) => ({
|
||||||
environments: many(environments),
|
environments: many(environments),
|
||||||
|
projectTags: many(projectTags),
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [projects.organizationId],
|
fields: [projects.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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 = z.object({
|
||||||
|
tagId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiRemoveTag = createSchema
|
||||||
|
.pick({
|
||||||
|
tagId: true,
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export const apiUpdateTag = createSchema.partial().extend({
|
||||||
|
tagId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export const statements = {
|
|||||||
domain: ["read", "create", "delete"],
|
domain: ["read", "create", "delete"],
|
||||||
destination: ["read", "create", "delete"],
|
destination: ["read", "create", "delete"],
|
||||||
notification: ["read", "create", "update", "delete"],
|
notification: ["read", "create", "update", "delete"],
|
||||||
|
tag: ["read", "create", "update", "delete"],
|
||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
@@ -69,6 +70,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
|||||||
"domain",
|
"domain",
|
||||||
"destination",
|
"destination",
|
||||||
"notification",
|
"notification",
|
||||||
|
"tag",
|
||||||
"logs",
|
"logs",
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"auditLog",
|
"auditLog",
|
||||||
@@ -107,6 +109,7 @@ export const ownerRole = ac.newRole({
|
|||||||
domain: ["read", "create", "delete"],
|
domain: ["read", "create", "delete"],
|
||||||
destination: ["read", "create", "delete"],
|
destination: ["read", "create", "delete"],
|
||||||
notification: ["read", "create", "update", "delete"],
|
notification: ["read", "create", "update", "delete"],
|
||||||
|
tag: ["read", "create", "update", "delete"],
|
||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
@@ -143,6 +146,7 @@ export const adminRole = ac.newRole({
|
|||||||
domain: ["read", "create", "delete"],
|
domain: ["read", "create", "delete"],
|
||||||
destination: ["read", "create", "delete"],
|
destination: ["read", "create", "delete"],
|
||||||
notification: ["read", "create", "update", "delete"],
|
notification: ["read", "create", "update", "delete"],
|
||||||
|
tag: ["read", "create", "update", "delete"],
|
||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
@@ -186,5 +190,6 @@ export const memberRole = ac.newRole({
|
|||||||
certificate: [],
|
certificate: [],
|
||||||
destination: [],
|
destination: [],
|
||||||
notification: [],
|
notification: [],
|
||||||
|
tag: ["read"],
|
||||||
auditLog: [],
|
auditLog: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const findProjectById = async (projectId: string) => {
|
|||||||
compose: true,
|
compose: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectTags: {
|
||||||
|
with: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
Reference in New Issue
Block a user