Skip to content

Commit 03e455b

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. Changes: - Add selection column with checkboxes for each node row - Integrate PatternFly BulkSelect component in ConsoleDataView with i18n support - Add bulk actions (schedulable/unschedulable) with context-aware visibility - Implement sticky selection and name columns for better horizontal scroll UX - Add name column offset helpers for bulk select compatibility - Create reusable selection helpers: useDataViewSelection, dataViewSelectionHelpers - Add configurable actionsBreakpoint prop for responsive toolbar layout - Fix DropEvent type conflict in droppable-edit-yaml - Remove isActionCell prop to fix React DOM warning - Update i18n keys to use itemCount/nodeCount instead of reserved 'count' keyword Features: - Filter-aware selection: bulk select respects current filters - Filter-aware bulk actions: actions only apply to filtered selected items - Context-aware actions: only show relevant actions based on selection state - Robust error handling with Promise.allSettled() and granular feedback - Full internationalization support for all user-facing strings - Backward compatible API: existing code using nameCellProps continues to work Selection behavior: - BulkSelect counts only show items in current filtered view - Bulk actions operate only on filtered selected items - Selection automatically updates when filters change to reflect visible items - When "select all" is used with filters, only filtered items are selected Implementation details: - Added getItemId to selection config for proper ID tracking across filters - Added onFilteredSelectionChange callback to notify parent of filtered selection - BulkSelect now receives pageCount for accurate "Select page (N)" labels - Added SELECTION_COLUMN_WIDTH constant and selectionColumnProps for selection column - Added getNameColumnProps(hasRightBorder?, withBulkSelect?) helper for sticky name columns - Enhanced getNameCellProps(name, withBulkSelect?) with optional parameter - Selection column offset (SELECTION_COLUMN_WIDTH) only added when withBulkSelect is true - Maintains full backward compatibility with existing tables (35+ files unchanged) TODO: - Update @patternfly/react-component-groups to published version after patternfly/react-component-groups#896 is merged - Revert temporary local package testing changes in package.json and scripts/check-patternfly-modules.ts Temporary changes for testing: - Using local react-component-groups package for i18n fixes - Disabled version check in check-patternfly-modules.ts Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 67d4348 commit 03e455b

12 files changed

Lines changed: 518 additions & 28 deletions

File tree

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@
154154
"@patternfly/react-catalog-view-extension": "~6.3.0",
155155
"@patternfly/react-charts": "~8.4.1",
156156
"@patternfly/react-code-editor": "~6.4.2",
157-
"@patternfly/react-component-groups": "~6.4.0",
157+
"@patternfly/react-component-groups": "6.4.0-prerelease.17",
158158
"@patternfly/react-core": "~6.4.2",
159159
"@patternfly/react-data-view": "~6.4.0-prerelease.12",
160160
"@patternfly/react-drag-drop": "~6.5.0-prerelease.38",
@@ -318,6 +318,7 @@
318318
"glob-parent": "^5.1.2",
319319
"hosted-git-info": "^3.0.8",
320320
"lodash-es": "^4.17.23",
321+
"@patternfly/react-component-groups": "6.4.0-prerelease.17",
321322
"postcss": "^8.2.13"
322323
},
323324
"lint-staged": {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,10 @@
465465
"Certificate approval required": "Certificate approval required",
466466
"An error occurred. Please try again": "An error occurred. Please try again",
467467
"No new Pods or workloads will be placed on this Node until it's marked as schedulable.": "No new Pods or workloads will be placed on this Node until it's marked as schedulable.",
468+
"Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable",
469+
"Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable",
470+
"Mark as schedulable ({{nodeCount}})": "Mark as schedulable ({{nodeCount}})",
471+
"Mark as unschedulable ({{nodeCount}})": "Mark as unschedulable ({{nodeCount}})",
468472
"Identity providers": "Identity providers",
469473
"Mapping method": "Mapping method",
470474
"Remove identity provider": "Remove identity provider",

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

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { FC, ReactNode } from 'react';
2-
import { useCallback, useMemo, useState } from 'react';
2+
import { useCallback, useMemo, useState, useEffect } 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,22 @@ export const ConsoleDataView = <
100106
matchesAdditionalFilters,
101107
});
102108

109+
// Notify parent of filtered selected items when filters or selection changes
110+
useEffect(() => {
111+
if (selection?.onFilteredSelectionChange) {
112+
const filteredSelectedItems = filteredData.filter((item) =>
113+
selection.selectedItems.has(selection.getItemId(item)),
114+
);
115+
selection.onFilteredSelectionChange(filteredSelectedItems);
116+
}
117+
// eslint-disable-next-line react-hooks/exhaustive-deps
118+
}, [
119+
filteredData,
120+
selection?.selectedItems,
121+
selection?.getItemId,
122+
selection?.onFilteredSelectionChange,
123+
]);
124+
103125
const { dataViewColumns, dataViewRows, pagination } = useConsoleDataViewData<
104126
TData,
105127
TCustomRowData,
@@ -134,6 +156,43 @@ export const ConsoleDataView = <
134156
return undefined;
135157
}, [filteredData.length, loaded]);
136158

159+
// Create bulkSelect component if selection is provided but bulkSelect prop is not
160+
let defaultBulkSelect = null;
161+
if (selection?.onSelectAll && !bulkSelect) {
162+
const totalCount = filteredData.length;
163+
const pageCount = dataViewRows.length;
164+
// Count only selected items that are in the current filtered dataset
165+
const selectedCount = filteredData.filter((item) =>
166+
selection.selectedItems.has(selection.getItemId(item)),
167+
).length;
168+
169+
const handleBulkSelect = (value: BulkSelectValue) => {
170+
if (value === BulkSelectValue.all || value === BulkSelectValue.page) {
171+
selection.onSelectAll(true, filteredData);
172+
} else if (value === BulkSelectValue.none || value === BulkSelectValue.nonePage) {
173+
selection.onSelectAll(false, filteredData);
174+
}
175+
};
176+
177+
defaultBulkSelect = (
178+
<BulkSelect
179+
pageCount={pageCount}
180+
selectedCount={selectedCount}
181+
totalCount={totalCount}
182+
onSelect={handleBulkSelect}
183+
canSelectAll
184+
selectNoneLabel={t('public~Select none (0)')}
185+
selectPageLabel={(itemCount) =>
186+
`${t('public~Select page')}${itemCount ? ` (${itemCount})` : ''}`
187+
}
188+
selectAllLabel={(itemCount) =>
189+
`${t('public~Select all')}${itemCount ? ` (${itemCount})` : ''}`
190+
}
191+
selectedLabel={(itemCount) => t('public~{{itemCount}} selected', { itemCount })}
192+
/>
193+
);
194+
}
195+
137196
const dataViewFilterNodes = useMemo<React.ReactNode[]>(() => {
138197
const basicFilters: ReactNode[] = [];
139198

@@ -177,6 +236,7 @@ export const ConsoleDataView = <
177236
className={css(dataViewFilterNodes.length === 1 && 'co-console-data-view-single-filter')}
178237
>
179238
<DataViewToolbar
239+
bulkSelect={bulkSelect ?? defaultBulkSelect}
180240
filters={
181241
dataViewFilterNodes.length > 0 && (
182242
<DataViewFilters values={filters} onChange={(_e, values) => onSetFilters(values)}>
@@ -186,7 +246,8 @@ export const ConsoleDataView = <
186246
}
187247
clearAllFilters={clearAllFilters}
188248
actions={
189-
<ResponsiveActions breakpoint="lg">
249+
<ResponsiveActions breakpoint={actionsBreakpoint}>
250+
{bulkActions}
190251
{!hideColumnManagement && (
191252
<ResponsiveAction
192253
isPersistent
@@ -251,22 +312,45 @@ export const ConsoleDataView = <
251312
);
252313
};
253314

315+
export const SELECTION_COLUMN_WIDTH = '45px';
316+
254317
export const cellIsStickyProps = {
255318
isStickyColumn: true,
256319
stickyMinWidth: '0',
257320
};
258321

322+
export const selectionColumnProps = {
323+
...cellIsStickyProps,
324+
stickyLeftOffset: '0',
325+
};
326+
259327
export const nameCellProps = {
260328
...cellIsStickyProps,
261329
hasRightBorder: true,
262330
};
263331

264-
export const getNameCellProps = (name: string) => {
265-
return {
266-
...nameCellProps,
267-
'data-test': `data-view-cell-${name}-name`,
268-
};
269-
};
332+
/**
333+
* Returns name column props with appropriate offset based on whether bulk select is enabled.
334+
* Use this for column definitions.
335+
* @param hasRightBorder - Whether to include hasRightBorder (default: true)
336+
* @param withBulkSelect - Whether the table has bulk selection enabled (default: false)
337+
*/
338+
export const getNameColumnProps = (hasRightBorder = true, withBulkSelect = false) => ({
339+
...cellIsStickyProps,
340+
...(hasRightBorder && { hasRightBorder: true }),
341+
...(withBulkSelect && { stickyLeftOffset: SELECTION_COLUMN_WIDTH }),
342+
});
343+
344+
/**
345+
* Returns name cell props with appropriate offset based on whether bulk select is enabled.
346+
* Use this for row cell definitions.
347+
* @param name - The name to use in the data-test attribute
348+
* @param withBulkSelect - Whether the table has bulk selection enabled (default: false)
349+
*/
350+
export const getNameCellProps = (name: string, withBulkSelect = false) => ({
351+
...getNameColumnProps(true, withBulkSelect),
352+
'data-test': `data-view-cell-${name}-name`,
353+
});
270354

271355
export const actionsCellProps = {
272356
...cellIsStickyProps,
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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useState, useCallback, useMemo, useEffect } 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+
// Update selection to only include items that still exist in the current data
47+
useEffect(() => {
48+
const selectableData = filterSelectable ? data.filter(filterSelectable) : data;
49+
const currentValidIds = new Set(selectableData.map(getItemId));
50+
51+
setSelectedIds((prev) => {
52+
const filtered = new Set<string>();
53+
prev.forEach((id) => {
54+
if (currentValidIds.has(id)) {
55+
filtered.add(id);
56+
}
57+
});
58+
// Only update if the selection actually changed
59+
return filtered.size === prev.size ? prev : filtered;
60+
});
61+
}, [data, getItemId, filterSelectable]);
62+
63+
const onSelectItem = useCallback((itemId: string, isSelecting: boolean) => {
64+
setSelectedIds((prev) => {
65+
const newSet = new Set(prev);
66+
if (isSelecting) {
67+
newSet.add(itemId);
68+
} else {
69+
newSet.delete(itemId);
70+
}
71+
return newSet;
72+
});
73+
}, []);
74+
75+
const onSelectAll = useCallback(
76+
(isSelecting: boolean, filteredItems: T[]) => {
77+
if (isSelecting) {
78+
const selectableItems = filterSelectable
79+
? filteredItems.filter(filterSelectable)
80+
: filteredItems;
81+
const itemIds = selectableItems.map(getItemId);
82+
setSelectedIds(new Set(itemIds));
83+
} else {
84+
setSelectedIds(new Set());
85+
}
86+
},
87+
[getItemId, filterSelectable],
88+
);
89+
90+
const clearSelection = useCallback(() => {
91+
setSelectedIds(new Set());
92+
}, []);
93+
94+
const selectedItems = useMemo(() => {
95+
const selectableData = filterSelectable ? data.filter(filterSelectable) : data;
96+
return selectableData.filter((item) => selectedIds.has(getItemId(item)));
97+
}, [data, selectedIds, getItemId, filterSelectable]);
98+
99+
return {
100+
selectedIds,
101+
selectedItems,
102+
onSelectItem,
103+
onSelectAll,
104+
clearSelection,
105+
};
106+
};

0 commit comments

Comments
 (0)