Merge pull request #238 from Dokploy/202-feature-categorize-templates-for-improved-ux

refactor(templates): add tag input selector
This commit is contained in:
Mauricio Siu 2024-07-20 15:15:48 -06:00 committed by GitHub
commit 0ce055c001
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 249 additions and 136 deletions

View File

@ -12,6 +12,13 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -22,8 +29,23 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Code, Github, Globe, PuzzleIcon } from "lucide-react"; import { ScrollArea } from "@radix-ui/react-scroll-area";
import {
CheckIcon,
ChevronsUpDown,
Code,
Github,
Globe,
PuzzleIcon,
SearchIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -34,13 +56,23 @@ interface Props {
export const AddTemplate = ({ projectId }: Props) => { export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { data } = api.compose.templates.useQuery(); const { data } = api.compose.templates.useQuery();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { data: tags, isLoading: isLoadingTags } =
api.compose.getTags.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
api.compose.deployTemplate.useMutation(); api.compose.deployTemplate.useMutation();
const templates = data?.filter((t) => const templates =
t.name.toLowerCase().includes(query.toLowerCase()), data?.filter((template) => {
); const matchesTags =
selectedTags.length === 0 ||
template.tags.some((tag) => selectedTags.includes(tag));
const matchesQuery =
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
}) || [];
return ( return (
<Dialog> <Dialog>
@ -62,146 +94,220 @@ export const AddTemplate = ({ projectId }: Props) => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Input <div className="flex flex-col md:flex-row gap-2">
placeholder="Search Template" <Input
onChange={(e) => setQuery(e.target.value)} placeholder="Search Template"
value={query} onChange={(e) => setQuery(e.target.value)}
/> className="w-full"
</div> value={query}
<div className="p-6"> />
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4"> <Popover modal={true}>
{templates?.map((template, index) => ( <PopoverTrigger asChild>
<div key={`template-${index}`}> <Button
<div variant="outline"
key={template.id} role="combobox"
className="flex flex-col gap-4 border p-6 rounded-lg h-full" className={cn(
"md:max-w-[15rem] w-full justify-between !bg-input",
// !field.value && "text-muted-foreground",
)}
> >
<div className="flex flex-col gap-4"> {isLoadingTags
<div className="flex flex-col items-center gap-2"> ? "Loading...."
<img : selectedTags.length > 0
src={`/templates/${template.logo}`} ? `Selected ${selectedTags.length} tags`
className="size-28 object-contain" : "Select tag"}
alt=""
/>
</div>
<div className="flex flex-col gap-2"> <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
<div className="flex flex-col gap-2 justify-center items-center"> </Button>
<div className="flex flex-col gap-2 items-center justify-center"> </PopoverTrigger>
<div className="flex flex-row gap-2 flex-wrap"> <PopoverContent className="p-0" align="start">
<span className="text-sm font-medium"> <Command>
{template.name} <CommandInput placeholder="Search tag..." className="h-9" />
</span> {isLoadingTags && (
<Badge>{template.version}</Badge> <span className="py-6 text-center text-sm">
</div> Loading Tags....
</span>
<div className="flex flex-row gap-0"> )}
<Link <CommandEmpty>No tags found.</CommandEmpty>
href={template.links.github} <ScrollArea className="h-96 overflow-y-auto">
target="_blank" <CommandGroup>
className={ {tags?.map((tag) => {
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors" return (
<CommandItem
value={tag}
key={tag}
onSelect={() => {
if (selectedTags.includes(tag)) {
setSelectedTags(
selectedTags.filter((t) => t !== tag),
);
return;
} }
> setSelectedTags([...selectedTags, tag]);
<Github className="size-4 text-muted-foreground" /> }}
</Link> >
{template.links.website && ( {tag}
<Link <CheckIcon
href={template.links.website} className={cn(
target="_blank" "ml-auto h-4 w-4",
className={ selectedTags.includes(tag)
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors" ? "opacity-100"
} : "opacity-0",
> )}
<Globe className="size-4 text-muted-foreground" /> />
</Link> </CommandItem>
)} );
{template.links.docs && ( })}
<Link </CommandGroup>
href={template.links.docs} </ScrollArea>
target="_blank" </Command>
className={ </PopoverContent>
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors" </Popover>
} </div>
> </div>
<Globe className="size-4 text-muted-foreground" /> <div className="p-6 w-full">
</Link> {templates.length === 0 ? (
)} <div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<Link <SearchIcon className="text-muted-foreground size-6" />
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`} <div className="text-xl font-medium text-muted-foreground">
target="_blank" No templates found
className={ </div>
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors" </div>
} ) : (
> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
<Code className="size-4 text-muted-foreground" /> {templates?.map((template, index) => (
</Link> <div key={`template-${index}`}>
</div> <div
<div className="flex flex-row gap-2 flex-wrap justify-center"> key={template.id}
{template.tags.map((tag) => ( className="flex flex-col gap-4 border p-6 rounded-lg h-full"
<Badge variant="secondary" key={tag}> >
{tag} <div className="flex flex-col gap-4">
</Badge> <div className="flex flex-col items-center gap-2">
))} <img
</div> src={`/templates/${template.logo}`}
</div> className="size-28 object-contain"
alt=""
<AlertDialog> />
<AlertDialogTrigger asChild>
<Button onSelect={(e) => e.preventDefault()}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will deploy {template.name} template to
your project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
projectId,
id: template.id,
})
.then(async () => {
toast.success(
`${template.name} template created succesfully`,
);
utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error(
`Error to delete ${template.name} template`,
);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<p className="text-sm text-muted-foreground"> <div className="flex flex-col gap-2">
{template.description} <div className="flex flex-col gap-2 justify-center items-center">
</p> <div className="flex flex-col gap-2 items-center justify-center">
<div className="flex flex-row gap-2 flex-wrap">
<span className="text-sm font-medium">
{template.name}
</span>
<Badge>{template.version}</Badge>
</div>
<div className="flex flex-row gap-0">
<Link
href={template.links.github}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Github className="size-4 text-muted-foreground" />
</Link>
{template.links.website && (
<Link
href={template.links.website}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
{template.links.docs && (
<Link
href={template.links.docs}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
<Link
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Code className="size-4 text-muted-foreground" />
</Link>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-center">
{template.tags.map((tag) => (
<Badge variant="secondary" key={tag}>
{tag}
</Badge>
))}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button onSelect={(e) => e.preventDefault()}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will deploy {template.name} template to
your project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
projectId,
id: template.id,
})
.then(async () => {
toast.success(
`${template.name} template created succesfully`,
);
utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error(
`Error to delete ${template.name} template`,
);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-sm text-muted-foreground">
{template.description}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -30,6 +30,7 @@ import {
} from "@/templates/utils"; } from "@/templates/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import _ from "lodash";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { findAdmin } from "../services/admin"; import { findAdmin } from "../services/admin";
import { import {
@ -283,4 +284,10 @@ export const composeRouter = createTRPCRouter({
return templatesData; return templatesData;
}), }),
getTags: protectedProcedure.query(async ({ input }) => {
const allTags = templates.flatMap((template) => template.tags);
const uniqueTags = _.uniq(allTags);
return uniqueTags;
}),
}); });