Merge pull request #856 from DJKnaeckebrot/feat/project-search

feat: add global search command
This commit is contained in:
Mauricio Siu
2024-12-13 00:38:07 -06:00
committed by GitHub
3 changed files with 511 additions and 316 deletions

View File

@@ -1,35 +1,35 @@
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { import {
AlertTriangle, AlertTriangle,
BookIcon, BookIcon,
ExternalLink, ExternalLink,
ExternalLinkIcon, ExternalLinkIcon,
FolderInput, FolderInput,
MoreHorizontalIcon, MoreHorizontalIcon,
TrashIcon, TrashIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Fragment } from "react"; import { Fragment } from "react";
@@ -38,253 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
import { UpdateProject } from "./update"; import { UpdateProject } from "./update";
export const ShowProjects = () => { export const ShowProjects = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data } = api.project.all.useQuery(); const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery(); const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery( const { data: user } = api.user.byAuthId.useQuery(
{ {
authId: auth?.id || "", authId: auth?.id || "",
}, },
{ {
enabled: !!auth?.id && auth?.rol === "user", enabled: !!auth?.id && auth?.rol === "user",
}, }
); );
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
return ( return (
<> <>
{data?.length === 0 && ( {data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4"> <div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" /> <FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground"> <span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project. No projects added yet. Click on Create project.
</span> </span>
</div> </div>
)} )}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10"> <div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => { {data?.map((project) => {
const emptyServices = const emptyServices =
project?.mariadb.length === 0 && project?.mariadb.length === 0 &&
project?.mongo.length === 0 && project?.mongo.length === 0 &&
project?.mysql.length === 0 && project?.mysql.length === 0 &&
project?.postgres.length === 0 && project?.postgres.length === 0 &&
project?.redis.length === 0 && project?.redis.length === 0 &&
project?.applications.length === 0 && project?.applications.length === 0 &&
project?.compose.length === 0; project?.compose.length === 0;
const totalServices = const totalServices =
project?.mariadb.length + project?.mariadb.length +
project?.mongo.length + project?.mongo.length +
project?.mysql.length + project?.mysql.length +
project?.postgres.length + project?.postgres.length +
project?.redis.length + project?.redis.length +
project?.applications.length + project?.applications.length +
project?.compose.length; project?.compose.length;
const flattedDomains = [ const flattedDomains = [
...project.applications.flatMap((a) => a.domains), ...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains), ...project.compose.flatMap((a) => a.domains),
]; ];
const renderDomainsDropdown = ( const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications, item: typeof project.compose | typeof project.applications
) => ) =>
item[0] ? ( item[0] ? (
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel> <DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"} {"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel> </DropdownMenuLabel>
{item.map((a) => ( {item.map((a) => (
<Fragment <Fragment
key={"applicationId" in a ? a.applicationId : a.composeId} key={"applicationId" in a ? a.applicationId : a.composeId}
> >
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs "> <DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name} {a.name}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{a.domains.map((domain) => ( {a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild> <DropdownMenuItem key={domain.domainId} asChild>
<Link <Link
className="space-x-4 text-xs cursor-pointer justify-between" className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank" target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`} href={`${domain.https ? "https" : "http"}://${
> domain.host
<span>{domain.host}</span> }${domain.path}`}
<ExternalLink className="size-4 shrink-0" /> >
</Link> <span>{domain.host}</span>
</DropdownMenuItem> <ExternalLink className="size-4 shrink-0" />
))} </Link>
</DropdownMenuGroup> </DropdownMenuItem>
</Fragment> ))}
))} </DropdownMenuGroup>
</DropdownMenuGroup> </Fragment>
) : null; ))}
</DropdownMenuGroup>
) : null;
return ( return (
<div key={project.projectId} className="w-full lg:max-w-md"> <div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}> <Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card"> <Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? ( {flattedDomains.length > 1 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100" className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm" size="sm"
variant="default" variant="default"
> >
<ExternalLinkIcon className="size-3.5" /> <ExternalLinkIcon className="size-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[200px] space-y-2" className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{renderDomainsDropdown(project.applications)} {renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)} {renderDomainsDropdown(project.compose)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : flattedDomains[0] ? ( ) : flattedDomains[0] ? (
<Button <Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100" className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm" size="sm"
variant="default" variant="default"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Link <Link
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`} href={`${
target="_blank" flattedDomains[0].https ? "https" : "http"
> }://${flattedDomains[0].host}${flattedDomains[0].path}`}
<ExternalLinkIcon className="size-3.5" /> target="_blank"
</Link> >
</Button> <ExternalLinkIcon className="size-3.5" />
) : null} </Link>
</Button>
) : null}
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2"> <CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5"> <span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" /> <BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none"> <span className="text-base font-medium leading-none">
{project.name} {project.name}
</span> </span>
</div> </div>
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{project.description} {project.description}
</span> </span>
</span> </span>
<div className="flex self-start space-x-1"> <div className="flex self-start space-x-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="px-2" className="px-2"
> >
<MoreHorizontalIcon className="size-5" /> <MoreHorizontalIcon className="size-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2"> <DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
Actions Actions
</DropdownMenuLabel> </DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment <ProjectEnviroment
projectId={project.projectId} projectId={project.projectId}
/> />
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} /> <UpdateProject projectId={project.projectId} />
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" || {(auth?.rol === "admin" ||
user?.canDeleteProjects) && ( user?.canDeleteProjects) && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger className="w-full"> <AlertDialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
<span>Delete</span> <span>Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
Are you sure to delete this project? Are you sure to delete this project?
</AlertDialogTitle> </AlertDialogTitle>
{!emptyServices ? ( {!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950"> <div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" /> <AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400"> <span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please You have active services, please
delete them first delete them first
</span> </span>
</div> </div>
) : ( ) : (
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone This action cannot be undone
</AlertDialogDescription> </AlertDialogDescription>
)} )}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel> <AlertDialogCancel>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={!emptyServices} disabled={!emptyServices}
onClick={async () => { onClick={async () => {
await mutateAsync({ await mutateAsync({
projectId: project.projectId, projectId: project.projectId,
}) })
.then(() => { .then(() => {
toast.success( toast.success(
"Project delete succesfully", "Project delete succesfully"
); );
}) })
.catch(() => { .catch(() => {
toast.error( toast.error(
"Error to delete this project", "Error to delete this project"
); );
}) })
.finally(() => { .finally(() => {
utils.project.all.invalidate(); utils.project.all.invalidate();
}); });
}} }}
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardFooter className="pt-4"> <CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4"> <div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}> <DateTooltip date={project.createdAt}>
Created Created
</DateTooltip> </DateTooltip>
<span> <span>
{totalServices}{" "} {totalServices}{" "}
{totalServices === 1 ? "service" : "services"} {totalServices === 1 ? "service" : "services"}
</span> </span>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>
</Link> </Link>
</div> </div>
); );
})} })}
</div> </div>
</> </>
); );
}; };

View File

@@ -0,0 +1,189 @@
"use client";
import React from "react";
import {
Command,
CommandEmpty,
CommandList,
CommandGroup,
CommandInput,
CommandItem,
CommandDialog,
CommandSeparator,
} from "@/components/ui/command";
import { useRouter } from "next/router";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { api } from "@/utils/api";
import { Badge } from "@/components/ui/badge";
import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>;
export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery();
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<div>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder={"Search projects or settings"}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
No projects added yet. Click on Create project.
</CommandEmpty>
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Services"}>
<CommandList>
{data?.map((project) => {
const applications: Services[] = extractServices(project);
return applications.map((application) => (
<CommandItem
key={application.id}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`
);
setOpen(false);
}}
>
{application.type === "postgres" && (
<PostgresqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "redis" && (
<RedisIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mariadb" && (
<MariadbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mongo" && (
<MongodbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mysql" && (
<MysqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "application" && (
<GlobeIcon className="h-6 w-6 mr-2" />
)}
{application.type === "compose" && (
<CircuitBoard className="h-6 w-6 mr-2" />
)}
<span className="flex-grow">
{project.name} / {application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div>
</span>
<div>
<StatusTooltip status={application.status} />
</div>
</CommandItem>
));
})}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
setOpen(false);
}}
>
Projects
</CommandItem>
{!isCloud && (
<>
<CommandItem
onSelect={() => {
router.push("/dashboard/monitoring");
setOpen(false);
}}
>
Monitoring
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/traefik");
setOpen(false);
}}
>
Traefik
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/docker");
setOpen(false);
}}
>
Docker
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/requests");
setOpen(false);
}}
>
Requests
</CommandItem>
</>
)}
<CommandItem
onSelect={() => {
router.push("/dashboard/settings/server");
setOpen(false);
}}
>
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
};

View File

@@ -11,72 +11,74 @@ import { Inter } from "next/font/google";
import Head from "next/head"; import Head from "next/head";
import Script from "next/script"; import Script from "next/script";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & { export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode; getLayout?: (page: ReactElement) => ReactNode;
// session: Session | null; // session: Session | null;
theme?: string; theme?: string;
}; };
type AppPropsWithLayout = AppProps & { type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout; Component: NextPageWithLayout;
}; };
const MyApp = ({ const MyApp = ({
Component, Component,
pageProps: { ...pageProps }, pageProps: { ...pageProps },
}: AppPropsWithLayout) => { }: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page); const getLayout = Component.getLayout ?? ((page) => page);
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`
:root { :root {
--font-inter: ${inter.style.fontFamily}; --font-inter: ${inter.style.fontFamily};
} }
`}</style> `}</style>
<Head> <Head>
<title>Dokploy</title> <title>Dokploy</title>
</Head> </Head>
{process.env.NEXT_PUBLIC_UMAMI_HOST && {process.env.NEXT_PUBLIC_UMAMI_HOST &&
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && ( process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
<Script <Script
src={process.env.NEXT_PUBLIC_UMAMI_HOST} src={process.env.NEXT_PUBLIC_UMAMI_HOST}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
/> />
)} )}
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
forcedTheme={Component.theme} forcedTheme={Component.theme}
> >
<Toaster richColors /> <Toaster richColors />
{getLayout(<Component {...pageProps} />)} <SearchCommand />
</ThemeProvider> {getLayout(<Component {...pageProps} />)}
</> </ThemeProvider>
); </>
);
}; };
export default api.withTRPC( export default api.withTRPC(
appWithTranslation( appWithTranslation(
MyApp, MyApp,
// keep this in sync with next-i18next.config.js // keep this in sync with next-i18next.config.js
// if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM. // if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM.
// Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259 // Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
// if one day every page is translated, we can safely remove this config. // if one day every page is translated, we can safely remove this config.
{ {
i18n: { i18n: {
defaultLocale: "en", defaultLocale: "en",
locales: Object.values(Languages), locales: Object.values(Languages),
localeDetection: false, localeDetection: false,
}, },
fallbackLng: "en", fallbackLng: "en",
keySeparator: false, keySeparator: false,
}, }
), )
); );