From e9650de794ab741150631406cf9d255511db7e73 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 19 Mar 2026 01:13:00 -0600 Subject: [PATCH] feat(tags): implement HandleTag component for creating and updating tags - Added a new HandleTag component to manage tag creation and updates with validation. - Integrated color selection and real-time preview for tags. - Updated tag management references in TagFilter and TagSelector components to use the new HandleTag component. --- .../dashboard/settings/tags/handle-tag.tsx | 239 ++++++++++ .../dashboard/settings/tags/tag-manager.tsx | 443 ++++-------------- apps/dokploy/components/shared/tag-filter.tsx | 4 +- .../components/shared/tag-selector.tsx | 4 +- 4 files changed, 340 insertions(+), 350 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx new file mode 100644 index 000000000..8e49cfd9d --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -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; + +interface HandleTagProps { + tagId?: string; +} + +export const HandleTag = ({ tagId }: HandleTagProps) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + const colorInputRef = useRef(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({ + 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 ( + + + {tagId ? ( + + ) : ( + + )} + + + + {tagId ? "Update" : "Create"} Tag + + {tagId + ? "Update the tag name and color" + : "Create a new tag to organize your projects"} + + + {isError && {error?.message}} +
+ + ( + + Name + + + + + + )} + /> + + ( + + Color (Optional) + +
+ colorInputRef.current?.click()} + > +
+ {!field.value && ( + + )} +
+ +
+
+ { + const value = e.target.value; + if (value.startsWith("#") || value === "") { + field.onChange(value); + } + }} + /> + + Choose a color to easily identify this tag + +
+
+
+ +
+ )} + /> + + {colorValue && ( +
+ Preview: + +
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx index 2a074914c..3c9125a52 100644 --- a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx @@ -1,21 +1,7 @@ -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 { Loader2, TagIcon, Trash2 } from "lucide-react"; import { toast } from "sonner"; -import { z } from "zod"; -import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; import { TagBadge } from "@/components/shared/tag-badge"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -24,347 +10,112 @@ import { 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; - -interface HandleTagProps { - tagId?: string; -} - -export const HandleTag = ({ tagId }: HandleTagProps) => { - const utils = api.useUtils(); - const [isOpen, setIsOpen] = useState(false); - const colorInputRef = useRef(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({ - 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 ( - - - {tagId ? ( - - ) : ( - - )} - - - - {tagId ? "Update" : "Create"} Tag - - {tagId - ? "Update the tag name and color" - : "Create a new tag to organize your projects"} - - - {isError && {error?.message}} -
- - ( - - Name - - - - - - )} - /> - - ( - - Color (Optional) - -
- colorInputRef.current?.click()} - > -
- {!field.value && ( - - )} -
- -
-
- { - const value = e.target.value; - if (value.startsWith("#") || value === "") { - field.onChange(value); - } - }} - /> - - Choose a color to easily identify this tag - -
-
-
- -
- )} - /> - - {colorValue && ( -
- Preview: - -
- )} - - - - - - -
-
- ); -}; - -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 ( - - - - - Delete Tag - - Are you sure you want to delete the tag "{tagName}"? This will - remove the tag from all projects. This action cannot be undone. - - - - Cancel - - {isLoading ? "Deleting..." : "Delete"} - - - - - ); -}; +import { HandleTag } from "./handle-tag"; export const TagManager = () => { - const { data: tags, isLoading } = api.tag.all.useQuery(); + const utils = api.useUtils(); + const { data: tags, isPending } = api.tag.all.useQuery(); + const { mutateAsync: deleteTag, isPending: isRemoving } = + api.tag.remove.useMutation(); return (
- - -
- Tags + +
+ + + + Tags + Create and manage tags to organize your projects -
- - - - {isLoading && ( -
-

Loading tags...

-
- )} - - {!isLoading && (!tags || tags.length === 0) && ( -
-

- No tags yet. Create your first tag to start organizing projects. -

-
- )} - - {!isLoading && tags && tags.length > 0 && ( -
- {tags.map((tag) => ( -
-
- - {tag.color && ( - - {tag.color} - - )} + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {!tags || tags.length === 0 ? ( +
+ + + No tags yet. Create your first tag to start organizing + projects. + +
-
- - + ) : ( +
+
+ {tags.map((tag) => ( +
+
+
+ + {tag.color && ( + + {tag.color} + + )} +
+
+ + { + await deleteTag({ + tagId: tag.tagId, + }) + .then(async () => { + await utils.tag.all.invalidate(); + toast.success( + "Tag deleted successfully", + ); + }) + .catch(() => { + toast.error( + "Error deleting tag", + ); + }); + }} + > + + +
+
+
+ ))} +
+ +
+ +
-
- ))} -
- )} - + )} + + )} + +
); diff --git a/apps/dokploy/components/shared/tag-filter.tsx b/apps/dokploy/components/shared/tag-filter.tsx index a1ce6b413..8b6b23522 100644 --- a/apps/dokploy/components/shared/tag-filter.tsx +++ b/apps/dokploy/components/shared/tag-filter.tsx @@ -1,6 +1,6 @@ import { Tags } from "lucide-react"; import * as React from "react"; -import { HandleTag } from "@/components/dashboard/settings/tags/tag-manager"; +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"; @@ -75,7 +75,7 @@ export function TagFilter({
- + {selectedTags.length > 0 && (