Skip to content

Commit 652aeae

Browse files
rhamiltoclaude
andcommitted
CONSOLE-5091: Add bulk selection and schedulable actions to Nodes page
Implements row selection with checkboxes for the Nodes list page, allowing users to select multiple nodes and perform bulk actions to mark them as schedulable or unschedulable. Features: - Selection column with checkboxes for each node row - Bulk select control in toolbar to select/deselect all filtered nodes - Bulk actions kebab menu with context-aware options based on node state - Sticky selection and name columns for better UX during horizontal scroll - Automatic bulk select creation when selection.onSelectAll is provided - Configurable actions breakpoint for responsive toolbar layout The bulk select correctly respects filters - selecting only visible/ filtered nodes rather than the entire dataset. Implementation details: - Generic dataViewSelectionHelpers for reusable selection logic - useDataViewSelection hook for managing selection state - useBulkNodeActions hook for node-specific bulk operations - BulkSelect component inlined directly in ConsoleDataView - actionsBreakpoint prop added to ConsoleDataViewProps (default: 'lg') - Type imported from PatternFly's OverflowMenuProps['breakpoint'] - Nodes page uses 'lg' breakpoint for toolbar actions Updates: - Updated to @patternfly/react-component-groups@6.4.0-prerelease.15 - Includes ResponsiveActions fix for empty dropdowns - Installed via npm registry URL - Mark schedulable/unschedulable actions as isPinned Error handling: - Uses Promise.allSettled() for robust error handling in bulk operations - Provides granular feedback for partial failures (e.g., "Failed to mark 2 of 5 nodes as schedulable") - All error messages are internationalized Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5e9e080 commit 652aeae

9 files changed

Lines changed: 3353 additions & 292 deletions

File tree

frontend/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
"@patternfly/react-catalog-view-extension": "~6.3.0",
157157
"@patternfly/react-charts": "~8.4.0",
158158
"@patternfly/react-code-editor": "~6.4.0",
159-
"@patternfly/react-component-groups": "~6.4.0",
159+
"@patternfly/react-component-groups": "https://registry.npmjs.org/@patternfly/react-component-groups/-/react-component-groups-6.4.0-prerelease.15.tgz",
160160
"@patternfly/react-core": "~6.4.0",
161161
"@patternfly/react-data-view": "6.4.0-prerelease.12",
162162
"@patternfly/react-drag-drop": "~6.4.0",
@@ -322,7 +322,8 @@
322322
"glob-parent": "^5.1.2",
323323
"hosted-git-info": "^3.0.8",
324324
"lodash-es": "^4.17.23",
325-
"postcss": "^8.2.13"
325+
"postcss": "^8.2.13",
326+
"@patternfly/react-component-groups": "https://registry.npmjs.org/@patternfly/react-component-groups/-/react-component-groups-6.4.0-prerelease.15.tgz"
326327
},
327328
"lint-staged": {
328329
"*.{js,jsx,ts,tsx,json,gql,graphql}": "eslint --color --fix"

frontend/packages/console-app/locales/en/console-app.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,10 @@
350350
"Unpin": "Unpin",
351351
"Remove from navigation?": "Remove from navigation?",
352352
"Remove": "Remove",
353+
"Mark as schedulable ({{count}})": "Mark as schedulable ({{count}})",
354+
"Mark as unschedulable ({{count}})": "Mark as unschedulable ({{count}})",
355+
"Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable",
356+
"Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable",
353357
"This action cannot be undone. Deleting a node will instruct Kubernetes that the node is down or unrecoverable and delete all pods scheduled to that node. If the node is still running but unresponsive and the node is deleted, stateful workloads and persistent volumes may suffer corruption or data loss. Only delete a node that you have confirmed is completely stopped and cannot be restored.": "This action cannot be undone. Deleting a node will instruct Kubernetes that the node is down or unrecoverable and delete all pods scheduled to that node. If the node is still running but unresponsive and the node is deleted, stateful workloads and persistent volumes may suffer corruption or data loss. Only delete a node that you have confirmed is completely stopped and cannot be restored.",
354358
"Mark as schedulable": "Mark as schedulable",
355359
"Mark as unschedulable": "Mark as unschedulable",

frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { FC, ReactNode } from 'react';
22
import { useCallback, useMemo, useState } from 'react';
33
import './ConsoleDataView.scss';
44
import {
5+
BulkSelect,
6+
BulkSelectValue,
57
ResponsiveAction,
68
ResponsiveActions,
79
SkeletonTableBody,
@@ -80,6 +82,10 @@ export const ConsoleDataView = <
8082
mock,
8183
isResizable,
8284
resetAllColumnWidths,
85+
bulkSelect,
86+
bulkActions,
87+
selection,
88+
actionsBreakpoint = 'lg',
8389
}: ConsoleDataViewProps<TData, TCustomRowData, TFilters>) => {
8490
const { t } = useTranslation();
8591
const launchModal = useOverlay();
@@ -100,6 +106,31 @@ export const ConsoleDataView = <
100106
matchesAdditionalFilters,
101107
});
102108

109+
// Create bulkSelect component if selection is provided but bulkSelect prop is not
110+
const defaultBulkSelect = useMemo(() => {
111+
if (!selection?.onSelectAll || bulkSelect) return null;
112+
113+
const totalCount = filteredData.length;
114+
const selectedCount = selection.selectedItems.size;
115+
116+
const handleBulkSelect = (value: BulkSelectValue) => {
117+
if (value === BulkSelectValue.all || value === BulkSelectValue.page) {
118+
selection.onSelectAll(true, filteredData);
119+
} else if (value === BulkSelectValue.none || value === BulkSelectValue.nonePage) {
120+
selection.onSelectAll(false, filteredData);
121+
}
122+
};
123+
124+
return (
125+
<BulkSelect
126+
selectedCount={selectedCount}
127+
totalCount={totalCount}
128+
onSelect={handleBulkSelect}
129+
canSelectAll
130+
/>
131+
);
132+
}, [selection, filteredData, bulkSelect]);
133+
103134
const { dataViewColumns, dataViewRows, pagination } = useConsoleDataViewData<
104135
TData,
105136
TCustomRowData,
@@ -177,6 +208,7 @@ export const ConsoleDataView = <
177208
className={css(dataViewFilterNodes.length === 1 && 'co-console-data-view-single-filter')}
178209
>
179210
<DataViewToolbar
211+
bulkSelect={bulkSelect ?? defaultBulkSelect}
180212
filters={
181213
dataViewFilterNodes.length > 0 && (
182214
<DataViewFilters values={filters} onChange={(_e, values) => onSetFilters(values)}>
@@ -186,7 +218,8 @@ export const ConsoleDataView = <
186218
}
187219
clearAllFilters={clearAllFilters}
188220
actions={
189-
<ResponsiveActions breakpoint="lg">
221+
<ResponsiveActions breakpoint={actionsBreakpoint}>
222+
{bulkActions}
190223
{!hideColumnManagement && (
191224
<ResponsiveAction
192225
isPersistent
@@ -247,13 +280,25 @@ export const ConsoleDataView = <
247280
);
248281
};
249282

283+
export const SELECTION_COLUMN_WIDTH = '45px';
284+
250285
export const cellIsStickyProps = {
251286
isStickyColumn: true,
252287
stickyMinWidth: '0',
253288
};
254289

255-
export const nameCellProps = {
290+
export const selectionColumnProps = {
291+
...cellIsStickyProps,
292+
stickyLeftOffset: '0',
293+
};
294+
295+
export const nameColumnProps = {
256296
...cellIsStickyProps,
297+
stickyLeftOffset: SELECTION_COLUMN_WIDTH,
298+
};
299+
300+
export const nameCellProps = {
301+
...nameColumnProps,
257302
hasRightBorder: true,
258303
};
259304

@@ -264,8 +309,12 @@ export const getNameCellProps = (name: string) => {
264309
};
265310
};
266311

267-
export const actionsCellProps = {
312+
export const actionsColumnProps = {
268313
...cellIsStickyProps,
314+
};
315+
316+
export const actionsCellProps = {
317+
...actionsColumnProps,
269318
hasLeftBorder: true,
270319
isActionCell: true,
271320
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { TableColumn } from '@console/dynamic-plugin-sdk/src/extensions/console-types';
2+
3+
const selectionColumnProps = {
4+
isStickyColumn: true,
5+
stickyMinWidth: '0',
6+
stickyLeftOffset: '0',
7+
} as const;
8+
9+
/**
10+
* Creates a selection column definition for DataView tables.
11+
* This column displays checkboxes for row selection.
12+
*
13+
* @example
14+
* ```typescript
15+
* const columns = [
16+
* createSelectionColumn(),
17+
* { title: 'Name', id: 'name', ... },
18+
* ...
19+
* ];
20+
* ```
21+
*/
22+
export const createSelectionColumn = <T>(): TableColumn<T> => ({
23+
title: '',
24+
id: 'select',
25+
props: selectionColumnProps,
26+
});
27+
28+
type CreateSelectionCellOptions = {
29+
/** Row index in the table */
30+
rowIndex: number;
31+
/** Unique ID for the item being selected */
32+
itemId: string;
33+
/** Whether the item is currently selected */
34+
isSelected: boolean;
35+
/** Callback when selection state changes */
36+
onSelect: (itemId: string, isSelecting: boolean) => void;
37+
/** Whether the checkbox should be disabled */
38+
disabled?: boolean;
39+
};
40+
41+
/**
42+
* Creates a selection cell object for a DataView row.
43+
* This cell contains the checkbox for row selection.
44+
*
45+
* @example
46+
* ```typescript
47+
* const rowCells = {
48+
* select: createSelectionCell({
49+
* rowIndex: 0,
50+
* itemId: getUID(node),
51+
* isSelected: selectedIds.has(getUID(node)),
52+
* onSelect: onSelectItem,
53+
* }),
54+
* name: { cell: <NodeName node={node} /> },
55+
* ...
56+
* };
57+
* ```
58+
*/
59+
export const createSelectionCell = ({
60+
rowIndex,
61+
itemId,
62+
isSelected,
63+
onSelect,
64+
disabled = false,
65+
}: CreateSelectionCellOptions) => ({
66+
cell: '', // Checkbox is rendered via props, no content needed
67+
props: {
68+
...selectionColumnProps,
69+
select: {
70+
rowIndex,
71+
onSelect: (_event: any, isSelecting: boolean) => {
72+
onSelect(itemId, isSelecting);
73+
},
74+
isSelected,
75+
isDisabled: disabled,
76+
},
77+
},
78+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState, useCallback, useMemo } from 'react';
2+
3+
type UseDataViewSelectionOptions<T> = {
4+
/** All data items */
5+
data: T[];
6+
/** Function to extract unique ID from an item */
7+
getItemId: (item: T) => string;
8+
/** Optional filter to exclude certain items from selection (e.g., filter out CSRs) */
9+
filterSelectable?: (item: T) => boolean;
10+
};
11+
12+
type UseDataViewSelectionResult<T> = {
13+
/** Set of selected item IDs */
14+
selectedIds: Set<string>;
15+
/** Array of selected item objects */
16+
selectedItems: T[];
17+
/** Callback to select/deselect a single item */
18+
onSelectItem: (itemId: string, isSelecting: boolean) => void;
19+
/** Callback to select/deselect all filtered items */
20+
onSelectAll: (isSelecting: boolean, filteredItems: T[]) => void;
21+
/** Clear all selections */
22+
clearSelection: () => void;
23+
};
24+
25+
/**
26+
* Custom hook for managing selection state in DataView components.
27+
* Provides selection state, callbacks, and selected item objects.
28+
*
29+
* @example
30+
* ```typescript
31+
* const { selectedIds, selectedItems, onSelectItem, onSelectAll, clearSelection } =
32+
* useDataViewSelection({
33+
* data,
34+
* getItemId: (node) => getUID(node),
35+
* filterSelectable: (item) => !isCSRResource(item),
36+
* });
37+
* ```
38+
*/
39+
export const useDataViewSelection = <T>({
40+
data,
41+
getItemId,
42+
filterSelectable,
43+
}: UseDataViewSelectionOptions<T>): UseDataViewSelectionResult<T> => {
44+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
45+
46+
const onSelectItem = useCallback((itemId: string, isSelecting: boolean) => {
47+
setSelectedIds((prev) => {
48+
const newSet = new Set(prev);
49+
if (isSelecting) {
50+
newSet.add(itemId);
51+
} else {
52+
newSet.delete(itemId);
53+
}
54+
return newSet;
55+
});
56+
}, []);
57+
58+
const onSelectAll = useCallback(
59+
(isSelecting: boolean, filteredItems: T[]) => {
60+
if (isSelecting) {
61+
const selectableItems = filterSelectable
62+
? filteredItems.filter(filterSelectable)
63+
: filteredItems;
64+
const itemIds = selectableItems.map(getItemId);
65+
setSelectedIds(new Set(itemIds));
66+
} else {
67+
setSelectedIds(new Set());
68+
}
69+
},
70+
[getItemId, filterSelectable],
71+
);
72+
73+
const clearSelection = useCallback(() => {
74+
setSelectedIds(new Set());
75+
}, []);
76+
77+
const selectedItems = useMemo(() => {
78+
const selectableData = filterSelectable ? data.filter(filterSelectable) : data;
79+
return selectableData.filter((item) => selectedIds.has(getItemId(item)));
80+
}, [data, selectedIds, getItemId, filterSelectable]);
81+
82+
return {
83+
selectedIds,
84+
selectedItems,
85+
onSelectItem,
86+
onSelectAll,
87+
clearSelection,
88+
};
89+
};

0 commit comments

Comments
 (0)