feat: add context menu to service cards

Right-click on service cards to quickly Start, Deploy, Stop, or Delete
a service without navigating into it. Uses shadcn/ui ContextMenu
component built on @radix-ui/react-context-menu. Delete action shows
a confirmation dialog. LibSQL services are excluded since they lack
standard mutation endpoints.
This commit is contained in:
Mauricio Siu
2026-04-13 20:48:17 -06:00
parent 1a8fd8396d
commit 638b3dd546
4 changed files with 503 additions and 101 deletions
+198
View File
@@ -0,0 +1,198 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+1
View File
@@ -67,6 +67,7 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -12,6 +12,7 @@ import {
Loader2,
Play,
PlusIcon,
RefreshCw,
Search,
ServerIcon,
SquareTerminal,
@@ -68,6 +69,14 @@ import {
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Dialog,
DialogContent,
@@ -424,6 +433,7 @@ const EnvironmentPage = (
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [deleteVolumes, setDeleteVolumes] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string>("all");
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
@@ -814,6 +824,91 @@ const EnvironmentPage = (
setIsBulkActionLoading(false);
};
const getServiceActions = (service: Services) => {
switch (service.type) {
case "application":
return applicationActions;
case "compose":
return composeActions;
case "postgres":
return postgresActions;
case "mysql":
return mysqlActions;
case "mariadb":
return mariadbActions;
case "redis":
return redisActions;
case "mongo":
return mongoActions;
default:
return null;
}
};
const getServiceIdKey = (service: Services) => {
switch (service.type) {
case "application":
return "applicationId";
case "compose":
return "composeId";
case "postgres":
return "postgresId";
case "mysql":
return "mysqlId";
case "mariadb":
return "mariadbId";
case "redis":
return "redisId";
case "mongo":
return "mongoId";
default:
return null;
}
};
const handleServiceAction = async (
service: Services,
action: "start" | "stop" | "deploy",
) => {
const actions = getServiceActions(service);
const idKey = getServiceIdKey(service);
if (!actions || !idKey) return;
try {
await actions[action].mutateAsync({
[idKey]: service.id,
} as any);
toast.success(
`Service ${service.name} ${action === "deploy" ? "queued for deployment" : `${action}ed`} successfully`,
);
refetch();
} catch (error) {
toast.error(
`Error ${action === "stop" ? "stopp" : action}ing service ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
const handleServiceDelete = async (service: Services) => {
const actions = getServiceActions(service);
const idKey = getServiceIdKey(service);
if (!actions || !idKey) return;
try {
await actions.delete.mutateAsync({
[idKey]: service.id,
} as any);
await utils.environment.one.invalidate({ environmentId });
toast.success(`Service ${service.name} deleted successfully`);
refetch();
} catch (error) {
toast.error(
`Error deleting service ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
setServiceToDelete(null);
};
// Get unique servers from services
const availableServers = useMemo(() => {
if (!applications) return [];
@@ -1472,110 +1567,156 @@ const EnvironmentPage = (
<div className="flex w-full flex-col gap-4">
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{filteredServices?.map((service) => (
<Link
key={service.id}
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block"
>
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
)}
<div className="absolute -right-1 -top-2">
<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)
}
<ContextMenu key={service.id}>
<ContextMenuTrigger asChild>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block"
>
<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">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
{service.type === "redis" && (
<RedisIcon className="h-7 w-7" />
)}
{service.type === "mariadb" && (
<MariadbIcon className="h-7 w-7" />
)}
{service.type === "mongo" && (
<MongodbIcon className="h-7 w-7" />
)}
{service.type === "mysql" && (
<MysqlIcon className="h-7 w-7" />
)}
{service.type === "application" &&
(service.icon ? (
// biome-ignore lint/performance/noImgElement: application icon is data URL
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6" />
))}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
{service.type === "libsql" && (
<LibsqlIcon className="h-6 w-6" />
)}
</span>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="mt-auto">
<div className="space-y-1 text-sm w-full">
{service.serverName && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ServerIcon className="size-3" />
<span className="truncate">
{service.serverName}
</span>
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
)}
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>
</div>
</CardFooter>
</Card>
</Link>
<div className="absolute -right-1 -top-2">
<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">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
{service.type === "redis" && (
<RedisIcon className="h-7 w-7" />
)}
{service.type === "mariadb" && (
<MariadbIcon className="h-7 w-7" />
)}
{service.type === "mongo" && (
<MongodbIcon className="h-7 w-7" />
)}
{service.type === "mysql" && (
<MysqlIcon className="h-7 w-7" />
)}
{service.type === "application" &&
(service.icon ? (
// biome-ignore lint/performance/noImgElement: application icon is data URL
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6" />
))}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
{service.type === "libsql" && (
<LibsqlIcon className="h-6 w-6" />
)}
</span>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="mt-auto">
<div className="space-y-1 text-sm w-full">
{service.serverName && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ServerIcon className="size-3" />
<span className="truncate">
{service.serverName}
</span>
</div>
)}
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>
</div>
</CardFooter>
</Card>
</Link>
</ContextMenuTrigger>
{service.type !== "libsql" && (
<ContextMenuContent className="w-48">
<ContextMenuLabel className="truncate">
{service.name}
</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuItem
className="flex items-center gap-2"
onClick={() =>
handleServiceAction(service, "start")
}
>
<Play className="size-4" />
Start
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() =>
handleServiceAction(service, "deploy")
}
>
<RefreshCw className="size-4" />
Deploy
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onClick={() =>
handleServiceAction(service, "stop")
}
>
<Ban className="size-4" />
Stop
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="flex items-center gap-2 text-red-500 focus:text-red-500"
onClick={() => setServiceToDelete(service)}
>
<Trash2 className="size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
))}
</div>
</div>
@@ -1586,6 +1727,38 @@ const EnvironmentPage = (
</div>
</Card>
</div>
{/* Single Service Delete Dialog */}
<Dialog
open={!!serviceToDelete}
onOpenChange={(open) => !open && setServiceToDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Service</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold">{serviceToDelete?.name}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setServiceToDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (serviceToDelete) {
handleServiceDelete(serviceToDelete);
}
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
+30
View File
@@ -176,6 +176,9 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -2801,6 +2804,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': 18.3.5
'@types/react-dom': 18.3.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@@ -10633,6 +10649,20 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.5
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@18.3.5)(react@18.2.0)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.5)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.5)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.3.5
'@types/react-dom': 18.3.0
'@radix-ui/react-context@1.0.0(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.6