Merge pull request #1282 from wish-oss/feat/bulk-actions

feat: added bulk actions for services start and stop and added service status for domain dropdown
This commit is contained in:
Mauricio Siu
2025-02-09 03:07:01 -06:00
committed by GitHub
2 changed files with 242 additions and 69 deletions

View File

@@ -1,5 +1,6 @@
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
AlertDialogAction,
@@ -176,8 +177,11 @@ export const ShowProjects = () => {
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs">
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={app.applicationStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
@@ -209,8 +213,11 @@ export const ShowProjects = () => {
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs">
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (

View File

@@ -13,6 +13,7 @@ import {
import { ProjectLayout } from "@/components/layouts/project-layout";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
@@ -23,6 +24,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
@@ -50,22 +52,27 @@ import type { findProjectById } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
Ban,
Check,
CheckCircle2,
ChevronsUpDown,
CircuitBoard,
FolderInput,
GlobeIcon,
Loader2,
PlusIcon,
Search,
X,
} from "lucide-react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useMemo, useState, type ReactElement } from "react";
import { useMemo, useState, type ReactElement } from "react";
import superjson from "superjson";
import { toast } from "sonner";
export type Services = {
appName: string;
@@ -191,6 +198,7 @@ export const extractServices = (data: Project | undefined) => {
const Project = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props;
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
@@ -201,7 +209,7 @@ const Project = (
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { data, isLoading } = api.project.one.useQuery({ projectId });
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
const router = useRouter();
const emptyServices =
@@ -228,6 +236,70 @@ const Project = (
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [openCombobox, setOpenCombobox] = useState(false);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
setSelectedServices([]);
} else {
setSelectedServices(filteredServices.map((service) => service.id));
}
};
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
event.stopPropagation();
setSelectedServices((prev) =>
prev.includes(serviceId)
? prev.filter((id) => id !== serviceId)
: [...prev, serviceId],
);
};
const composeActions = {
start: api.compose.start.useMutation(),
stop: api.compose.stop.useMutation(),
};
const handleBulkStart = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.start.mutateAsync({ composeId: serviceId });
success++;
} catch (error) {
toast.error(`Error starting service ${serviceId}`);
}
}
if (success > 0) {
toast.success(`${success} services started successfully`);
refetch();
}
setIsBulkActionLoading(false);
setSelectedServices([]);
setIsDropdownOpen(false);
};
const handleBulkStop = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.stop.mutateAsync({ composeId: serviceId });
success++;
} catch (error) {
toast.error(`Error stopping service ${serviceId}`);
}
}
if (success > 0) {
toast.success(`${success} services stopped successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsBulkActionLoading(false);
};
const filteredServices = useMemo(() => {
if (!applications) return [];
@@ -309,78 +381,151 @@ const Project = (
</div>
) : (
<>
<div className="flex flex-row gap-2 items-center">
<div className="w-full relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedServices.length > 0}
className={cn(
"data-[state=checked]:bg-primary",
selectedServices.length > 0 &&
selectedServices.length <
filteredServices.length &&
"bg-primary/50",
)}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm">
Select All{" "}
{selectedServices.length > 0 &&
`(${selectedServices.length}/${filteredServices.length})`}
</span>
</div>
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={selectedServices.length === 0}
isLoading={isBulkActionLoading}
>
Bulk Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DialogAction
title="Start Services"
description={`Are you sure you want to start ${selectedServices.length} services?`}
type="default"
onClick={handleBulkStart}
>
<Button
variant="ghost"
className="w-full justify-start"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Start
</Button>
</DialogAction>
<DialogAction
title="Stop Services"
description={`Are you sure you want to stop ${selectedServices.length} services?`}
type="destructive"
onClick={handleBulkStop}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Ban className="mr-2 h-4 w-4" />
Stop
</Button>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={openCombobox}
className="min-w-[200px] justify-between"
>
{selectedTypes.length === 0
? "Select types..."
: `${selectedTypes.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search type..." />
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{serviceTypes.map((type) => (
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center">
<div className="w-full relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Popover
open={openCombobox}
onOpenChange={setOpenCombobox}
>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={openCombobox}
className="min-w-[200px] justify-between"
>
{selectedTypes.length === 0
? "Select types..."
: `${selectedTypes.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search type..." />
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{serviceTypes.map((type) => (
<CommandItem
key={type.value}
onSelect={() => {
setSelectedTypes((prev) =>
prev.includes(type.value)
? prev.filter((t) => t !== type.value)
: [...prev, type.value],
);
setOpenCombobox(false);
}}
>
<div className="flex flex-row">
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTypes.includes(type.value)
? "opacity-100"
: "opacity-0",
)}
/>
{type.icon && (
<type.icon className="mr-2 h-4 w-4" />
)}
{type.label}
</div>
</CommandItem>
))}
<CommandItem
key={type.value}
onSelect={() => {
setSelectedTypes((prev) =>
prev.includes(type.value)
? prev.filter((t) => t !== type.value)
: [...prev, type.value],
);
setSelectedTypes([]);
setOpenCombobox(false);
}}
className="border-t"
>
<div className="flex flex-row">
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTypes.includes(type.value)
? "opacity-100"
: "opacity-0",
)}
/>
{type.icon && (
<type.icon className="mr-2 h-4 w-4" />
)}
{type.label}
<div className="flex flex-row items-center">
<X className="mr-2 h-4 w-4" />
Clear filters
</div>
</CommandItem>
))}
<CommandItem
onSelect={() => {
setSelectedTypes([]);
setOpenCombobox(false);
}}
className="border-t"
>
<div className="flex flex-row items-center">
<X className="mr-2 h-4 w-4" />
Clear filters
</div>
</CommandItem>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex w-full gap-8">
@@ -418,6 +563,27 @@ const Project = (
<StatusTooltip status={service.status} />
</div>
<div
className={cn(
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
selectedServices.includes(service.id)
? "opacity-100 translate-y-0"
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
)}
onClick={(e) =>
handleServiceSelect(service.id, e)
}
>
<div className="h-full w-full flex items-center justify-center">
<Checkbox
checked={selectedServices.includes(
service.id,
)}
className="data-[state=checked]:bg-primary"
/>
</div>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">