diff --git a/.changeset/flat-cloths-cough.md b/.changeset/flat-cloths-cough.md new file mode 100644 index 000000000..7464ebe3e --- /dev/null +++ b/.changeset/flat-cloths-cough.md @@ -0,0 +1,5 @@ +--- +"@workflow/web": patch +--- + +Allow selecting and cancelling multiple runs from table view diff --git a/packages/web/package.json b/packages/web/package.json index 86cdc52a4..115fe59e1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@biomejs/biome": "catalog:", "@radix-ui/react-alert-dialog": "1.1.5", + "@radix-ui/react-checkbox": "1.1.4", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slot": "1.1.1", diff --git a/packages/web/src/components/display-utils/selection-bar.tsx b/packages/web/src/components/display-utils/selection-bar.tsx new file mode 100644 index 000000000..6787f4f95 --- /dev/null +++ b/packages/web/src/components/display-utils/selection-bar.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export interface SelectionBarProps { + /** Number of selected items */ + selectionCount: number; + /** Callback to clear selection */ + onClearSelection: () => void; + /** Optional action buttons to render */ + actions?: ReactNode; + /** Label for what type of items are selected (e.g., "runs", "hooks") */ + itemLabel?: string; + /** Additional className */ + className?: string; +} + +/** + * A floating bar that appears when items are selected in a table. + * Shows selection count and provides actions for bulk operations. + */ +export function SelectionBar({ + selectionCount, + onClearSelection, + actions, + itemLabel = 'items', + className, +}: SelectionBarProps) { + if (selectionCount === 0) { + return null; + } + + const label = selectionCount === 1 ? itemLabel.replace(/s$/, '') : itemLabel; + + return ( +
+ + {selectionCount} {label} selected + + + {actions && ( +
+ {actions} +
+ )} + + +
+ ); +} diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 5a6e12fd6..f4d7729d2 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -2,6 +2,7 @@ import { parseWorkflowName } from '@workflow/core/parse-name'; import { + cancelRun, type EnvMap, type Event, getErrorMessage, @@ -15,16 +16,17 @@ import { ArrowUpAZ, ChevronLeft, ChevronRight, - Loader2Icon, + Loader2, MoreHorizontal, RefreshCw, + XCircle, } from 'lucide-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { DocsLink } from '@/components/ui/docs-link'; import { DropdownMenu, DropdownMenuContent, @@ -53,11 +55,14 @@ import { import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; import { useDataDirInfo } from '@/lib/hooks'; +import { useTableSelection } from '@/lib/hooks/use-table-selection'; import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; +import { SelectionBar } from './display-utils/selection-bar'; import { StatusBadge } from './display-utils/status-badge'; import { TableSkeleton } from './display-utils/table-skeleton'; import { RunActionsDropdownItems } from './run-actions'; +import { Checkbox } from './ui/checkbox'; // Inner content that fetches events when it mounts (only rendered when dropdown is open) function RunActionsDropdownContentInner({ @@ -408,6 +413,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { status: status === 'all' ? undefined : status, }); + // Multi-select functionality + const selection = useTableSelection({ + getItemId: (run) => run.runId, + }); + + const runs = data.data ?? []; + + // Bulk cancel state + const [isBulkCancelling, setIsBulkCancelling] = useState(false); + const isLocalAndHasMissingData = isLocal && (!dataDirInfo?.dataDir || !data?.data?.length) && @@ -434,6 +449,54 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { reload(); }, [reload]); + // Get selected runs that are cancellable (pending or running) + const selectedRuns = useMemo(() => { + return runs.filter((run) => selection.selectedIds.has(run.runId)); + }, [runs, selection.selectedIds]); + + const cancellableSelectedRuns = useMemo(() => { + return selectedRuns.filter( + (run) => run.status === 'pending' || run.status === 'running' + ); + }, [selectedRuns]); + + const hasCancellableSelection = cancellableSelectedRuns.length > 0; + + const handleBulkCancel = useCallback(async () => { + if (isBulkCancelling || cancellableSelectedRuns.length === 0) return; + + setIsBulkCancelling(true); + try { + const results = await Promise.allSettled( + cancellableSelectedRuns.map((run) => cancelRun(env, run.runId)) + ); + + const succeeded = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + if (failed === 0) { + toast.success( + `Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}` + ); + } else if (succeeded === 0) { + toast.error(`Failed to cancel ${failed} run${failed !== 1 ? 's' : ''}`); + } else { + toast.warning( + `Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}, ${failed} failed` + ); + } + + selection.clearSelection(); + onReload(); + } catch (err) { + toast.error('Failed to cancel runs', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setIsBulkCancelling(false); + } + }, [env, cancellableSelectedRuns, isBulkCancelling, selection, onReload]); + const toggleSortOrder = () => { setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc')); }; @@ -501,6 +564,14 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { + + selection.toggleSelectAll(runs)} + aria-label="Select all runs" + /> + Workflow @@ -520,12 +591,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { - {data.data?.map((run) => ( + {runs.map((run) => ( onRunClick(run.runId)} + data-selected={selection.isSelected(run)} > + + selection.toggleSelection(run)} + aria-label={`Select run ${run.runId}`} + /> + {parseWorkflowName(run.workflowName)?.shortName || @@ -606,6 +685,34 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { )} + + + {isBulkCancelling ? ( + + ) : ( + + )} + Cancel{' '} + {cancellableSelectedRuns.length !== selection.selectionCount + ? `${cancellableSelectedRuns.length} ` + : ''} + {isBulkCancelling ? 'cancelling...' : ''} + + ) + } + /> ); } diff --git a/packages/web/src/components/ui/checkbox.tsx b/packages/web/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..4105f30b8 --- /dev/null +++ b/packages/web/src/components/ui/checkbox.tsx @@ -0,0 +1,74 @@ +'use client'; + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check, Minus } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface CheckboxProps + extends Omit< + React.ComponentPropsWithoutRef, + 'checked' | 'onCheckedChange' + > { + /** Whether the checkbox is checked */ + checked?: boolean; + /** Whether the checkbox is in indeterminate state (for "select all" with partial selection) */ + indeterminate?: boolean; + /** Callback when checked state changes */ + onCheckedChange?: (checked: boolean) => void; +} + +const Checkbox = React.forwardRef< + React.ElementRef, + CheckboxProps +>(({ className, checked, indeterminate, onCheckedChange, ...props }, ref) => { + // Generate a unique id for associating label with checkbox + const id = React.useId(); + const checkboxId = props.id ?? id; + + // Convert our boolean props to Radix's CheckedState + const checkedState: CheckboxPrimitive.CheckedState = indeterminate + ? 'indeterminate' + : (checked ?? false); + + return ( + // Label provides the click grace area and proper a11y association + + ); +}); +Checkbox.displayName = 'Checkbox'; + +export { Checkbox }; diff --git a/packages/web/src/lib/hooks/use-table-selection.ts b/packages/web/src/lib/hooks/use-table-selection.ts new file mode 100644 index 000000000..d9058a22b --- /dev/null +++ b/packages/web/src/lib/hooks/use-table-selection.ts @@ -0,0 +1,153 @@ +import { useCallback, useMemo, useState } from 'react'; + +export interface UseTableSelectionOptions { + /** Function to extract unique ID from each item */ + getItemId: (item: T) => string; +} + +export interface UseTableSelectionReturn { + /** Set of currently selected item IDs */ + selectedIds: Set; + /** Whether a specific item is selected */ + isSelected: (item: T) => boolean; + /** Toggle selection of a single item */ + toggleSelection: (item: T) => void; + /** Select all items in the provided list */ + selectAll: (items: T[]) => void; + /** Clear all selections */ + clearSelection: () => void; + /** Toggle all items (select all if not all selected, otherwise clear) */ + toggleSelectAll: (items: T[]) => void; + /** Number of selected items */ + selectionCount: number; + /** Whether all provided items are selected */ + isAllSelected: (items: T[]) => boolean; + /** Whether some but not all items are selected (for indeterminate state) */ + isSomeSelected: (items: T[]) => boolean; + /** Select a specific item by ID */ + selectById: (id: string) => void; + /** Deselect a specific item by ID */ + deselectById: (id: string) => void; +} + +/** + * Hook for managing table row selection state. + * Provides a consistent interface for multi-select functionality across different tables. + */ +export function useTableSelection({ + getItemId, +}: UseTableSelectionOptions): UseTableSelectionReturn { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const isSelected = useCallback( + (item: T) => selectedIds.has(getItemId(item)), + [selectedIds, getItemId] + ); + + const toggleSelection = useCallback( + (item: T) => { + const id = getItemId(item); + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, + [getItemId] + ); + + const selectAll = useCallback( + (items: T[]) => { + setSelectedIds(new Set(items.map(getItemId))); + }, + [getItemId] + ); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isAllSelected = useCallback( + (items: T[]) => { + if (items.length === 0) return false; + return items.every((item) => selectedIds.has(getItemId(item))); + }, + [selectedIds, getItemId] + ); + + const isSomeSelected = useCallback( + (items: T[]) => { + if (items.length === 0) return false; + const someSelected = items.some((item) => + selectedIds.has(getItemId(item)) + ); + const allSelected = items.every((item) => + selectedIds.has(getItemId(item)) + ); + return someSelected && !allSelected; + }, + [selectedIds, getItemId] + ); + + const toggleSelectAll = useCallback( + (items: T[]) => { + if (isAllSelected(items)) { + clearSelection(); + } else { + selectAll(items); + } + }, + [isAllSelected, clearSelection, selectAll] + ); + + const selectById = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + }, []); + + const deselectById = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + + const selectionCount = selectedIds.size; + + return useMemo( + () => ({ + selectedIds, + isSelected, + toggleSelection, + selectAll, + clearSelection, + toggleSelectAll, + selectionCount, + isAllSelected, + isSomeSelected, + selectById, + deselectById, + }), + [ + selectedIds, + isSelected, + toggleSelection, + selectAll, + clearSelection, + toggleSelectAll, + selectionCount, + isAllSelected, + isSomeSelected, + selectById, + deselectById, + ] + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc68acda..b92f85d24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: 1.1.5 version: 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-checkbox': + specifier: 1.1.4 + version: 1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -4653,6 +4656,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.1.4': + resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + 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-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -16546,6 +16562,22 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-checkbox@1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3