refactor: move actions for selected items into its own component

This commit is contained in:
zhom
2025-11-29 16:33:58 +04:00
parent 61dcbbc715
commit 0805c37d33
4 changed files with 282 additions and 42 deletions
+2 -3
View File
@@ -698,9 +698,6 @@ export default function Home() {
<main className="flex flex-col items-center w-full max-w-3xl">
<div className="w-full">
<HomeHeader
selectedProfiles={selectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
@@ -731,6 +728,8 @@ export default function Home() {
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
/>
</div>
</main>
+176
View File
@@ -0,0 +1,176 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface DataTableActionBarProps<TData>
extends React.ComponentProps<typeof motion.div> {
table: Table<TData>;
visible?: boolean;
portalContainer?: Element | DocumentFragment | null;
}
function DataTableActionBar<TData>({
table,
visible: visibleProp,
portalContainer: portalContainerProp,
children,
className,
...props
}: DataTableActionBarProps<TData>) {
const [mounted, setMounted] = React.useState(false);
React.useLayoutEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
table.toggleAllRowsSelected(false);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [table]);
const portalContainer =
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
if (!portalContainer) return null;
const visible =
visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0;
return ReactDOM.createPortal(
<AnimatePresence>
{visible && (
<motion.div
role="toolbar"
aria-orientation="horizontal"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn(
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
className,
)}
{...props}
>
{children}
</motion.div>
)}
</AnimatePresence>,
portalContainer,
);
}
interface DataTableActionBarActionProps
extends React.ComponentProps<typeof Button> {
tooltip?: string;
isPending?: boolean;
}
function DataTableActionBarAction({
size = "sm",
tooltip,
isPending,
disabled,
className,
children,
...props
}: DataTableActionBarActionProps) {
const trigger = (
<Button
variant="secondary"
size={size}
className={cn(
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
size === "icon" ? "size-7" : "h-7",
className,
)}
disabled={disabled || isPending}
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}
</Button>
);
if (!tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
}
interface DataTableActionBarSelectionProps<TData> {
table: Table<TData>;
}
function DataTableActionBarSelection<TData>({
table,
}: DataTableActionBarSelectionProps<TData>) {
const onClearSelection = React.useCallback(() => {
table.toggleAllRowsSelected(false);
}, [table]);
return (
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
<span className="whitespace-nowrap text-xs">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="mr-1 ml-2 h-4 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={onClearSelection}
>
<LuX className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
>
<p>Clear selection</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
<abbr title="Escape" className="no-underline">
Esc
</abbr>
</kbd>
</TooltipContent>
</Tooltip>
</div>
);
}
export {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
};
+2 -38
View File
@@ -1,7 +1,7 @@
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuSearch, LuTrash2, LuUsers, LuX } from "react-icons/lu";
import { LuSearch, LuUsers, LuX } from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -12,13 +12,9 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
selectedProfiles: string[];
onBulkGroupAssignment: () => void;
onBulkDelete: () => void;
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
@@ -29,9 +25,6 @@ type Props = {
};
const HomeHeader = ({
selectedProfiles,
onBulkGroupAssignment,
onBulkDelete,
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
@@ -58,36 +51,7 @@ const HomeHeader = ({
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{selectedProfiles.length > 0 ? (
<div className="flex gap-3 items-center">
<span className="text-sm font-medium">
{selectedProfiles.length} profile
{selectedProfiles.length !== 1 ? "s" : ""} selected
</span>
<div className="flex gap-2">
<RippleButton
variant="outline"
size="sm"
onClick={onBulkGroupAssignment}
className="flex gap-2 items-center"
>
<LuUsers className="w-4 h-4" />
Assign to Group
</RippleButton>
<RippleButton
variant="destructive"
size="sm"
onClick={onBulkDelete}
className="flex gap-2 items-center"
>
<LuTrash2 className="w-4 h-4" />
Delete
</RippleButton>
</div>
</div>
) : (
<CardTitle>Donut</CardTitle>
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
<div className="relative">
+102 -1
View File
@@ -5,6 +5,7 @@ import {
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
@@ -13,7 +14,13 @@ import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { IoEllipsisHorizontal } from "react-icons/io5";
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
import {
LuCheck,
LuChevronDown,
LuChevronUp,
LuTrash2,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -62,6 +69,11 @@ import {
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
@@ -404,6 +416,8 @@ interface ProfilesDataTableProps {
selectedGroupId: string | null;
selectedProfiles: string[];
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
onBulkDelete?: () => void;
onBulkGroupAssignment?: () => void;
}
export function ProfilesDataTable({
@@ -418,9 +432,62 @@ export function ProfilesDataTable({
onAssignProfilesToGroup,
selectedProfiles,
onSelectedProfilesChange,
onBulkDelete,
onBulkGroupAssignment,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
// Sync external selectedProfiles with table's row selection state
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const prevSelectedProfilesRef = React.useRef<string[]>(selectedProfiles);
// Update row selection when external selectedProfiles changes
React.useEffect(() => {
// Only update if selectedProfiles actually changed
if (
prevSelectedProfilesRef.current.length !== selectedProfiles.length ||
!prevSelectedProfilesRef.current.every((id) =>
selectedProfiles.includes(id),
)
) {
const newSelection: RowSelectionState = {};
for (const profileId of selectedProfiles) {
newSelection[profileId] = true;
}
setRowSelection(newSelection);
prevSelectedProfilesRef.current = selectedProfiles;
}
}, [selectedProfiles]);
// Update external selectedProfiles when table selection changes
const handleRowSelectionChange = React.useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
setRowSelection((prevSelection) => {
const newSelection =
typeof updater === "function" ? updater(prevSelection) : updater;
const selectedIds = Object.keys(newSelection).filter(
(id) => newSelection[id],
);
// Only update external state if selection actually changed
const prevIds = Object.keys(prevSelection).filter(
(id) => prevSelection[id],
);
if (
selectedIds.length !== prevIds.length ||
!selectedIds.every((id) => prevIds.includes(id))
) {
onSelectedProfilesChange(selectedIds);
}
return newSelection;
});
},
[onSelectedProfilesChange],
);
const [profileToRename, setProfileToRename] =
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
@@ -1517,8 +1584,19 @@ export function ProfilesDataTable({
columns,
state: {
sorting,
rowSelection,
},
onSortingChange: handleSortingChange,
onRowSelectionChange: handleRowSelectionChange,
enableRowSelection: (row) => {
const profile = row.original;
const isRunning =
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
},
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
@@ -1594,6 +1672,29 @@ export function ProfilesDataTable({
confirmButtonText="Delete Profile"
isLoading={isDeleting}
/>
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
{onBulkGroupAssignment && (
<DataTableActionBarAction
tooltip="Assign to Group"
onClick={onBulkGroupAssignment}
size="icon"
>
<LuUsers />
</DataTableActionBarAction>
)}
{onBulkDelete && (
<DataTableActionBarAction
tooltip="Delete"
onClick={onBulkDelete}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
>
<LuTrash2 />
</DataTableActionBarAction>
)}
</DataTableActionBar>
</>
);
}