Skip to content

Commit 0da8e54

Browse files
[web] Allow selecting and cancelling multiple runs from table view (#716)
1 parent 6660e57 commit 0da8e54

File tree

7 files changed

+444
-3
lines changed

7 files changed

+444
-3
lines changed

.changeset/flat-cloths-cough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/web": patch
3+
---
4+
5+
Allow selecting and cancelling multiple runs from table view

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"devDependencies": {
3535
"@biomejs/biome": "catalog:",
3636
"@radix-ui/react-alert-dialog": "1.1.5",
37+
"@radix-ui/react-checkbox": "1.1.4",
3738
"@radix-ui/react-label": "2.1.7",
3839
"@radix-ui/react-select": "2.2.6",
3940
"@radix-ui/react-slot": "1.1.1",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import { X } from 'lucide-react';
4+
import type { ReactNode } from 'react';
5+
import { Button } from '@/components/ui/button';
6+
import { cn } from '@/lib/utils';
7+
8+
export interface SelectionBarProps {
9+
/** Number of selected items */
10+
selectionCount: number;
11+
/** Callback to clear selection */
12+
onClearSelection: () => void;
13+
/** Optional action buttons to render */
14+
actions?: ReactNode;
15+
/** Label for what type of items are selected (e.g., "runs", "hooks") */
16+
itemLabel?: string;
17+
/** Additional className */
18+
className?: string;
19+
}
20+
21+
/**
22+
* A floating bar that appears when items are selected in a table.
23+
* Shows selection count and provides actions for bulk operations.
24+
*/
25+
export function SelectionBar({
26+
selectionCount,
27+
onClearSelection,
28+
actions,
29+
itemLabel = 'items',
30+
className,
31+
}: SelectionBarProps) {
32+
if (selectionCount === 0) {
33+
return null;
34+
}
35+
36+
const label = selectionCount === 1 ? itemLabel.replace(/s$/, '') : itemLabel;
37+
38+
return (
39+
<div
40+
className={cn(
41+
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
42+
'flex items-center gap-3 px-4 py-2.5 rounded-lg',
43+
'bg-primary text-primary-foreground shadow-lg',
44+
'animate-in fade-in slide-in-from-bottom-4 duration-200',
45+
className
46+
)}
47+
>
48+
<span className="text-sm font-medium">
49+
{selectionCount} {label} selected
50+
</span>
51+
52+
{actions && (
53+
<div className="flex items-center gap-2 border-l border-primary-foreground/20 pl-3">
54+
{actions}
55+
</div>
56+
)}
57+
58+
<Button
59+
variant="ghost"
60+
size="sm"
61+
className="h-7 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
62+
onClick={onClearSelection}
63+
>
64+
<X className="h-4 w-4" />
65+
<span className="sr-only">Clear selection</span>
66+
</Button>
67+
</div>
68+
);
69+
}

packages/web/src/components/runs-table.tsx

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { parseWorkflowName } from '@workflow/core/parse-name';
44
import {
5+
cancelRun,
56
type EnvMap,
67
type Event,
78
getErrorMessage,
@@ -15,16 +16,17 @@ import {
1516
ArrowUpAZ,
1617
ChevronLeft,
1718
ChevronRight,
18-
Loader2Icon,
19+
Loader2,
1920
MoreHorizontal,
2021
RefreshCw,
22+
XCircle,
2123
} from 'lucide-react';
2224
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
2325
import { useCallback, useEffect, useMemo, useState } from 'react';
26+
import { toast } from 'sonner';
2427
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
2528
import { Button } from '@/components/ui/button';
2629
import { Card, CardContent } from '@/components/ui/card';
27-
import { DocsLink } from '@/components/ui/docs-link';
2830
import {
2931
DropdownMenu,
3032
DropdownMenuContent,
@@ -53,11 +55,14 @@ import {
5355
import { worldConfigToEnvMap } from '@/lib/config';
5456
import type { WorldConfig } from '@/lib/config-world';
5557
import { useDataDirInfo } from '@/lib/hooks';
58+
import { useTableSelection } from '@/lib/hooks/use-table-selection';
5659
import { CopyableText } from './display-utils/copyable-text';
5760
import { RelativeTime } from './display-utils/relative-time';
61+
import { SelectionBar } from './display-utils/selection-bar';
5862
import { StatusBadge } from './display-utils/status-badge';
5963
import { TableSkeleton } from './display-utils/table-skeleton';
6064
import { RunActionsDropdownItems } from './run-actions';
65+
import { Checkbox } from './ui/checkbox';
6166

6267
// Inner content that fetches events when it mounts (only rendered when dropdown is open)
6368
function RunActionsDropdownContentInner({
@@ -408,6 +413,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
408413
status: status === 'all' ? undefined : status,
409414
});
410415

416+
// Multi-select functionality
417+
const selection = useTableSelection<WorkflowRun>({
418+
getItemId: (run) => run.runId,
419+
});
420+
421+
const runs = data.data ?? [];
422+
423+
// Bulk cancel state
424+
const [isBulkCancelling, setIsBulkCancelling] = useState(false);
425+
411426
const isLocalAndHasMissingData =
412427
isLocal &&
413428
(!dataDirInfo?.dataDir || !data?.data?.length) &&
@@ -434,6 +449,54 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
434449
reload();
435450
}, [reload]);
436451

452+
// Get selected runs that are cancellable (pending or running)
453+
const selectedRuns = useMemo(() => {
454+
return runs.filter((run) => selection.selectedIds.has(run.runId));
455+
}, [runs, selection.selectedIds]);
456+
457+
const cancellableSelectedRuns = useMemo(() => {
458+
return selectedRuns.filter(
459+
(run) => run.status === 'pending' || run.status === 'running'
460+
);
461+
}, [selectedRuns]);
462+
463+
const hasCancellableSelection = cancellableSelectedRuns.length > 0;
464+
465+
const handleBulkCancel = useCallback(async () => {
466+
if (isBulkCancelling || cancellableSelectedRuns.length === 0) return;
467+
468+
setIsBulkCancelling(true);
469+
try {
470+
const results = await Promise.allSettled(
471+
cancellableSelectedRuns.map((run) => cancelRun(env, run.runId))
472+
);
473+
474+
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
475+
const failed = results.filter((r) => r.status === 'rejected').length;
476+
477+
if (failed === 0) {
478+
toast.success(
479+
`Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}`
480+
);
481+
} else if (succeeded === 0) {
482+
toast.error(`Failed to cancel ${failed} run${failed !== 1 ? 's' : ''}`);
483+
} else {
484+
toast.warning(
485+
`Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}, ${failed} failed`
486+
);
487+
}
488+
489+
selection.clearSelection();
490+
onReload();
491+
} catch (err) {
492+
toast.error('Failed to cancel runs', {
493+
description: err instanceof Error ? err.message : 'Unknown error',
494+
});
495+
} finally {
496+
setIsBulkCancelling(false);
497+
}
498+
}, [env, cancellableSelectedRuns, isBulkCancelling, selection, onReload]);
499+
437500
const toggleSortOrder = () => {
438501
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
439502
};
@@ -501,6 +564,14 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
501564
<Table>
502565
<TableHeader>
503566
<TableRow>
567+
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10 w-10">
568+
<Checkbox
569+
checked={selection.isAllSelected(runs)}
570+
indeterminate={selection.isSomeSelected(runs)}
571+
onCheckedChange={() => selection.toggleSelectAll(runs)}
572+
aria-label="Select all runs"
573+
/>
574+
</TableHead>
504575
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
505576
Workflow
506577
</TableHead>
@@ -520,12 +591,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
520591
</TableRow>
521592
</TableHeader>
522593
<TableBody>
523-
{data.data?.map((run) => (
594+
{runs.map((run) => (
524595
<TableRow
525596
key={run.runId}
526597
className="cursor-pointer group relative"
527598
onClick={() => onRunClick(run.runId)}
599+
data-selected={selection.isSelected(run)}
528600
>
601+
<TableCell className="py-2">
602+
<Checkbox
603+
checked={selection.isSelected(run)}
604+
onCheckedChange={() => selection.toggleSelection(run)}
605+
aria-label={`Select run ${run.runId}`}
606+
/>
607+
</TableCell>
529608
<TableCell className="py-2">
530609
<CopyableText text={run.workflowName} overlay>
531610
{parseWorkflowName(run.workflowName)?.shortName ||
@@ -606,6 +685,34 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
606685
</div>
607686
</>
608687
)}
688+
689+
<SelectionBar
690+
selectionCount={selection.selectionCount}
691+
onClearSelection={selection.clearSelection}
692+
itemLabel="runs"
693+
actions={
694+
hasCancellableSelection && (
695+
<Button
696+
variant="ghost"
697+
size="sm"
698+
className="h-7 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
699+
onClick={handleBulkCancel}
700+
disabled={isBulkCancelling}
701+
>
702+
{isBulkCancelling ? (
703+
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
704+
) : (
705+
<XCircle className="h-4 w-4 mr-1" />
706+
)}
707+
Cancel{' '}
708+
{cancellableSelectedRuns.length !== selection.selectionCount
709+
? `${cancellableSelectedRuns.length} `
710+
: ''}
711+
{isBulkCancelling ? 'cancelling...' : ''}
712+
</Button>
713+
)
714+
}
715+
/>
609716
</div>
610717
);
611718
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client';
2+
3+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
4+
import { Check, Minus } from 'lucide-react';
5+
import * as React from 'react';
6+
import { cn } from '@/lib/utils';
7+
8+
export interface CheckboxProps
9+
extends Omit<
10+
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
11+
'checked' | 'onCheckedChange'
12+
> {
13+
/** Whether the checkbox is checked */
14+
checked?: boolean;
15+
/** Whether the checkbox is in indeterminate state (for "select all" with partial selection) */
16+
indeterminate?: boolean;
17+
/** Callback when checked state changes */
18+
onCheckedChange?: (checked: boolean) => void;
19+
}
20+
21+
const Checkbox = React.forwardRef<
22+
React.ElementRef<typeof CheckboxPrimitive.Root>,
23+
CheckboxProps
24+
>(({ className, checked, indeterminate, onCheckedChange, ...props }, ref) => {
25+
// Generate a unique id for associating label with checkbox
26+
const id = React.useId();
27+
const checkboxId = props.id ?? id;
28+
29+
// Convert our boolean props to Radix's CheckedState
30+
const checkedState: CheckboxPrimitive.CheckedState = indeterminate
31+
? 'indeterminate'
32+
: (checked ?? false);
33+
34+
return (
35+
// Label provides the click grace area and proper a11y association
36+
<label
37+
htmlFor={checkboxId}
38+
className="p-2 -m-2 inline-flex items-center justify-center cursor-pointer"
39+
onClick={(e) => e.stopPropagation()}
40+
onKeyDown={(e) => e.stopPropagation()}
41+
>
42+
<CheckboxPrimitive.Root
43+
ref={ref}
44+
id={checkboxId}
45+
checked={checkedState}
46+
onCheckedChange={(state: CheckboxPrimitive.CheckedState) => {
47+
// Convert Radix's CheckedState back to boolean
48+
// 'indeterminate' becomes false on click (standard behavior)
49+
onCheckedChange?.(state === true);
50+
}}
51+
className={cn(
52+
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow',
53+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
54+
'disabled:cursor-not-allowed disabled:opacity-50',
55+
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
56+
'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
57+
className
58+
)}
59+
{...props}
60+
>
61+
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
62+
{indeterminate ? (
63+
<Minus className="h-3.5 w-3.5" />
64+
) : (
65+
<Check className="h-3.5 w-3.5" />
66+
)}
67+
</CheckboxPrimitive.Indicator>
68+
</CheckboxPrimitive.Root>
69+
</label>
70+
);
71+
});
72+
Checkbox.displayName = 'Checkbox';
73+
74+
export { Checkbox };

0 commit comments

Comments
 (0)