From 0805c37d331487ba68272ba6a8c0da9fb50f65cb Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:33:58 +0400 Subject: [PATCH] refactor: move actions for selected items into its own component --- src/app/page.tsx | 5 +- src/components/data-table-action-bar.tsx | 176 +++++++++++++++++++++++ src/components/home-header.tsx | 40 +----- src/components/profile-data-table.tsx | 103 ++++++++++++- 4 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 src/components/data-table-action-bar.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index a6b4435..d2aa757 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -698,9 +698,6 @@ export default function Home() {
diff --git a/src/components/data-table-action-bar.tsx b/src/components/data-table-action-bar.tsx new file mode 100644 index 0000000..527fb3b --- /dev/null +++ b/src/components/data-table-action-bar.tsx @@ -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 + extends React.ComponentProps { + table: Table; + visible?: boolean; + portalContainer?: Element | DocumentFragment | null; +} + +function DataTableActionBar({ + table, + visible: visibleProp, + portalContainer: portalContainerProp, + children, + className, + ...props +}: DataTableActionBarProps) { + 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( + + {visible && ( + + {children} + + )} + , + portalContainer, + ); +} + +interface DataTableActionBarActionProps + extends React.ComponentProps { + tooltip?: string; + isPending?: boolean; +} + +function DataTableActionBarAction({ + size = "sm", + tooltip, + isPending, + disabled, + className, + children, + ...props +}: DataTableActionBarActionProps) { + const trigger = ( + + ); + + if (!tooltip) return trigger; + + return ( + + {trigger} + +

{tooltip}

+
+
+ ); +} + +interface DataTableActionBarSelectionProps { + table: Table; +} + +function DataTableActionBarSelection({ + table, +}: DataTableActionBarSelectionProps) { + const onClearSelection = React.useCallback(() => { + table.toggleAllRowsSelected(false); + }, [table]); + + return ( +
+ + {table.getFilteredSelectedRowModel().rows.length} selected + +
+ + + + + +

Clear selection

+ + + Esc + + +
+
+
+ ); +} + +export { + DataTableActionBar, + DataTableActionBarAction, + DataTableActionBarSelection, +}; diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index b724fd0..fb6cb00 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -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 = ({ > - {selectedProfiles.length > 0 ? ( -
- - {selectedProfiles.length} profile - {selectedProfiles.length !== 1 ? "s" : ""} selected - -
- - - Assign to Group - - - - Delete - -
-
- ) : ( - Donut - )} + Donut
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 12989fa..51fff9f 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -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>; + 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([]); + + // Sync external selectedProfiles with table's row selection state + const [rowSelection, setRowSelection] = React.useState({}); + const prevSelectedProfilesRef = React.useRef(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) => { + 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(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} /> + + + {onBulkGroupAssignment && ( + + + + )} + {onBulkDelete && ( + + + + )} + ); }