From 5ff9201cfed63a34a289971729bd04224dcd5eaa Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Thu, 7 May 2026 15:35:28 -0400 Subject: [PATCH 1/5] CONSOLE-5091: Add bulk selection and schedulable actions to Nodes page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bulk selection support to Nodes page with checkbox column - Add ResponsiveActionDropdown shared component for responsive action menus - Uses PatternFly OverflowMenu for automatic breakpoint detection - Switches between primary button (desktop) and kebab menu (mobile) - Configurable variant, breakpoint, and accessibility props - Full test coverage with 5 passing tests - Implement useCustomNodeActions hook using ResponsiveActionDropdown - Provides bulk scheduling actions (Mark schedulable/unschedulable) - Shows applicable action counts in descriptions - Handles batch operations with Promise.allSettled - Clears selection after successful operations - Track filtered selected nodes for accurate bulk action targeting - Reduced useCustomNodeActions code by 35% (232 → 151 lines) Co-Authored-By: Claude Sonnet 4.5 --- frontend/package.json | 3 +- .../console-app/locales/en/console-app.json | 8 + .../data-view/BULK_SELECTION_GUIDE.md | 633 ++++++++++++++++++ .../components/data-view/ConsoleDataView.tsx | 249 +++++-- .../data-view/INDETERMINATE_CHECKBOX.md | 95 +++ .../data-view/dataViewSelectionHelpers.ts | 80 +++ .../data-view/useConsoleDataViewData.tsx | 214 ++++-- .../data-view/useDataViewSelection.ts | 106 +++ .../data-view/useIndeterminateCheckbox.ts | 35 + .../src/components/nodes/NodesPage.tsx | 89 ++- .../components/nodes/useCustomNodeActions.tsx | 150 +++++ .../src/components/nodes/useNodeActions.tsx | 117 ++++ .../src/api/internal-api.ts | 6 + .../src/api/internal-types.ts | 21 + .../dropdown/ResponsiveActionDropdown.tsx | 129 ++++ .../ResponsiveActionDropdown.spec.tsx | 77 +++ .../public/components/droppable-edit-yaml.tsx | 2 +- frontend/public/locales/en/public.json | 6 +- .../style/ancillary/_bootstrap-residual.scss | 2 +- frontend/yarn.lock | 10 +- 20 files changed, 1898 insertions(+), 134 deletions(-) create mode 100644 frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md create mode 100644 frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md create mode 100644 frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts create mode 100644 frontend/packages/console-app/src/components/data-view/useDataViewSelection.ts create mode 100644 frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts create mode 100644 frontend/packages/console-app/src/components/nodes/useCustomNodeActions.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/useNodeActions.tsx create mode 100644 frontend/packages/console-shared/src/components/dropdown/ResponsiveActionDropdown.tsx create mode 100644 frontend/packages/console-shared/src/components/dropdown/__tests__/ResponsiveActionDropdown.spec.tsx diff --git a/frontend/package.json b/frontend/package.json index f81faeb9b0d..11c3359f22f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -159,7 +159,7 @@ "@patternfly/react-catalog-view-extension": "~6.3.0", "@patternfly/react-charts": "~8.4.1", "@patternfly/react-code-editor": "~6.4.2", - "@patternfly/react-component-groups": "~6.4.0", + "@patternfly/react-component-groups": "6.4.0-prerelease.17", "@patternfly/react-core": "~6.4.2", "@patternfly/react-data-view": "~6.4.0-prerelease.12", "@patternfly/react-drag-drop": "~6.5.0-prerelease.38", @@ -324,6 +324,7 @@ "glob-parent": "^5.1.2", "hosted-git-info": "^3.0.8", "lodash-es": "^4.17.23", + "@patternfly/react-component-groups": "6.4.0-prerelease.17", "postcss": "^8.2.13" }, "lint-staged": { diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index ce594b3c6a8..862e3c459b1 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -465,6 +465,14 @@ "Certificate approval required": "Certificate approval required", "An error occurred. Please try again": "An error occurred. Please try again", "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.", + "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable", + "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable", + "Applies to {{nodeCount}} selected node(s) that are currently unschedulable.": "Applies to {{nodeCount}} selected node(s) that are currently unschedulable.", + "Mark schedulable": "Mark schedulable", + "Applies to {{nodeCount}} selected node(s) that are currently schedulable.": "Applies to {{nodeCount}} selected node(s) that are currently schedulable.", + "Scheduling": "Scheduling", + "Mark as schedulable ({{nodeCount}})": "Mark as schedulable ({{nodeCount}})", + "Mark as unschedulable ({{nodeCount}})": "Mark as unschedulable ({{nodeCount}})", "Identity providers": "Identity providers", "Mapping method": "Mapping method", "Remove identity provider": "Remove identity provider", diff --git a/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md b/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md new file mode 100644 index 00000000000..2c7b981f195 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md @@ -0,0 +1,633 @@ +# Bulk Selection and Actions in ConsoleDataView + +This guide explains how to add bulk selection and bulk actions to an existing `ConsoleDataView` instance. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Step 1: Set Up Selection State](#step-1-set-up-selection-state) +3. [Step 2: Add Selection Column](#step-2-add-selection-column) +4. [Step 3: Pass Selection to Row Renderer](#step-3-pass-selection-to-row-renderer) +5. [Step 4: Create Bulk Actions](#step-4-create-bulk-actions) +6. [Step 5: Wire Everything Together](#step-5-wire-everything-together) +7. [Complete Example](#complete-example) +8. [Advanced Usage](#advanced-usage) + +## Prerequisites + +Before adding bulk selection, ensure you have: + +- An existing `ConsoleDataView` component +- A unique identifier function for your data items (e.g., `getUID`) +- Understanding of your data type and which items should be selectable + +## Step 1: Set Up Selection State + +Use the `useDataViewSelection` hook to manage selection state: + +```typescript +import { useDataViewSelection } from '@console/app/src/components/data-view/useDataViewSelection'; +import { getUID } from '@console/shared/src/selectors/common'; + +const { selectedIds, selectedItems, onSelectItem, onSelectAll, clearSelection } = + useDataViewSelection({ + data, // Your data array + getItemId: getUID, // Function to extract unique ID from an item + filterSelectable: (item) => !isSpecialType(item), // Optional: exclude certain items + }); +``` + +### Parameters: + +- **`data`**: Array of all items in your view +- **`getItemId`**: Function that extracts a unique string ID from each item +- **`filterSelectable`** (optional): Filter function to exclude items from selection (e.g., CSRs, pending items) + +### Returns: + +- **`selectedIds`**: Set of selected item IDs +- **`selectedItems`**: Array of selected item objects +- **`onSelectItem`**: Callback to select/deselect a single item +- **`onSelectAll`**: Callback to select/deselect all filtered items +- **`clearSelection`**: Function to clear all selections + +## Step 2: Add Selection Column + +Add a selection column to your columns array using the `createSelectionColumn` helper: + +```typescript +import { createSelectionColumn } from '@console/app/src/components/data-view/dataViewSelectionHelpers'; + +const columns = useMemo(() => { + return [ + createSelectionColumn(), // Add this as the first column + { + title: t('Name'), + id: 'name', + sort: 'metadata.name', + // ... other column config + }, + // ... rest of your columns + ]; +}, [/* dependencies */]); +``` + +**Important**: The selection column should be the **first column** in your columns array. + +## Step 3: Pass Selection to Row Renderer + +Update your row rendering function to include selection cells: + +```typescript +import { + createSelectionCell +} from '@console/app/src/components/data-view/dataViewSelectionHelpers'; + +const getDataViewRows = ( + rowData: RowProps[], + tableColumns: ConsoleDataViewColumn[], + selection?: { + selectedItems: Set; + onSelect: (itemId: string, isSelecting: boolean) => void; + }, +): ConsoleDataViewRow[] => { + return rowData.map(({ obj }, rowIndex) => { + const itemId = getUID(obj); + const isSelectable = !isSpecialType(obj); // Optional filtering + + const rowCells = { + select: selection && isSelectable + ? createSelectionCell({ + rowIndex, + itemId, + isSelected: selection.selectedItems.has(itemId), + onSelect: selection.onSelect, + }) + : undefined, + name: { + cell: , + // ... cell config + }, + // ... other cells + }; + + return tableColumns.map(({ id }) => { + const rowCell = rowCells[id]; + if (!rowCell) { + return { id, cell: DASH }; + } + // For select column, don't default to DASH - checkbox is rendered via props + const cellContent = id === 'select' ? rowCell.cell ?? '' : rowCell.cell ?? DASH; + return { + id, + props: rowCell.props, + cell: cellContent, + }; + }); + }); +}; +``` + +## Step 4: Create Bulk Actions + +Create a custom hook to define bulk actions using PatternFly's `ResponsiveAction`: + +```typescript +import { useMemo, useCallback } from 'react'; +import { ResponsiveAction } from '@patternfly/react-component-groups'; +import { useTranslation } from 'react-i18next'; +import { usePromiseHandler } from '@console/shared/src/hooks/usePromiseHandler'; + +type UseBulkActionsOptions = { + selectedItems: YourDataType[]; + onComplete: () => void; // Called after successful action +}; + +export const useBulkActions = ({ selectedItems, onComplete }: UseBulkActionsOptions) => { + const { t } = useTranslation(); + const [handlePromise, inProgress] = usePromiseHandler(); + + const handleBulkDelete = useCallback(() => { + const promises = selectedItems.map((item) => k8sDelete({ + model: YourModel, + resource: item, + })); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t('Failed to delete {{failureCount}} of {{totalCount}} items', { + failureCount: failures.length, + totalCount: results.length, + }), + ); + } + }), + ) + .then(() => onComplete()) + .catch(() => { + // Errors are handled by usePromiseHandler + }); + }, [selectedItems, handlePromise, t, onComplete]); + + return useMemo(() => { + return [ + + {t('Delete ({{count}})', { count: selectedItems.length })} + , + // Add more actions as needed + ]; + }, [selectedItems.length, handleBulkDelete, inProgress, t]); +}; +``` + +### Action Best Practices: + +1. **Disable during operations**: Use `isDisabled={inProgress || selectedItems.length === 0}` +2. **Show count in label**: Include `({{count}})` to show how many items will be affected +3. **Handle failures gracefully**: Use `Promise.allSettled` to handle partial failures +4. **Clear selection on completion**: Call `onComplete()` after successful operations +5. **Use `isPinned`**: For important actions that should always be visible + +## Step 5: Wire Everything Together + +Pass the selection state and actions to `ConsoleDataView`: + +```typescript +const YourList: FC = ({ data, loaded, loadError }) => { + // 1. Set up selection state + const { selectedIds, selectedItems, onSelectItem, onSelectAll, clearSelection } = + useDataViewSelection({ + data, + getItemId: getUID, + filterSelectable: (item) => !isSpecialType(item), + }); + + // 2. Track filtered selected items (optional, for additional actions) + const [filteredSelectedItems, setFilteredSelectedItems] = useState([]); + + const handleFilteredSelectionChange = useCallback((items: YourDataType[]) => { + const filtered = items.filter((item) => !isSpecialType(item)); + setFilteredSelectedItems(filtered); + }, []); + + // 3. Create bulk actions + const bulkActions = useBulkActions({ + selectedItems: filteredSelectedItems, + onComplete: clearSelection, + }); + + return ( + + data={data} + loaded={loaded} + loadError={loadError} + columns={columns} + getDataViewRows={(rowData, tableColumns) => + getYourDataViewRows( + rowData, + tableColumns, + { + selectedItems: selectedIds, + onSelect: onSelectItem, + }, + ) + } + additionalActions={bulkActions} + selection={{ + selectedItems: selectedIds, + onSelect: onSelectItem, + onSelectAll, + getItemId: getUID, + onFilteredSelectionChange: handleFilteredSelectionChange, + }} + // ... other props + /> + ); +}; +``` + +## Complete Example + +Here's a complete example based on the Nodes page implementation: + +```typescript +import { FC, useMemo, useCallback, useState } from 'react'; +import { ResponsiveAction } from '@patternfly/react-component-groups'; +import { useTranslation } from 'react-i18next'; +import { + ConsoleDataView, + createSelectionColumn, + createSelectionCell, +} from '@console/app/src/components/data-view'; +import { useDataViewSelection } from '@console/app/src/components/data-view/useDataViewSelection'; +import { getUID } from '@console/shared/src/selectors/common'; +import { usePromiseHandler } from '@console/shared/src/hooks/usePromiseHandler'; + +type MyItem = { + metadata: { + name: string; + uid: string; + }; + spec?: { + special?: boolean; + }; +}; + +// Custom hook for bulk actions +const useBulkActions = ({ selectedItems, onComplete }) => { + const { t } = useTranslation(); + const [handlePromise, inProgress] = usePromiseHandler(); + + const handleBulkAction = useCallback(() => { + const promises = selectedItems.map((item) => + // Your API call here + doSomethingWith(item) + ); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t('Failed to process {{failureCount}} of {{totalCount}} items', { + failureCount: failures.length, + totalCount: results.length, + }), + ); + } + }), + ) + .then(() => onComplete()) + .catch(() => {}); + }, [selectedItems, handlePromise, t, onComplete]); + + return useMemo(() => { + return [ + + {t('Process ({{count}})', { count: selectedItems.length })} + , + ]; + }, [selectedItems.length, handleBulkAction, inProgress, t]); +}; + +const MyList: FC<{ data: MyItem[]; loaded: boolean; loadError?: unknown }> = ({ + data, + loaded, + loadError, +}) => { + const { t } = useTranslation(); + + // Selection state + const { selectedIds, selectedItems, onSelectItem, onSelectAll, clearSelection } = + useDataViewSelection({ + data, + getItemId: getUID, + filterSelectable: (item) => !item.spec?.special, + }); + + // Track filtered selected items + const [filteredSelectedItems, setFilteredSelectedItems] = useState([]); + + const handleFilteredSelectionChange = useCallback((items: MyItem[]) => { + const filtered = items.filter((item) => !item.spec?.special); + setFilteredSelectedItems(filtered); + }, []); + + // Bulk actions + const bulkActions = useBulkActions({ + selectedItems: filteredSelectedItems, + onComplete: clearSelection, + }); + + // Columns with selection + const columns = useMemo(() => { + return [ + createSelectionColumn(), + { + title: t('Name'), + id: 'name', + sort: 'metadata.name', + }, + // ... more columns + ]; + }, [t]); + + // Row renderer + const getDataViewRows = useCallback( + (rowData, tableColumns) => { + return rowData.map(({ obj }, rowIndex) => { + const itemId = getUID(obj); + const isSelectable = !obj.spec?.special; + + const rowCells = { + select: isSelectable + ? createSelectionCell({ + rowIndex, + itemId, + isSelected: selectedIds.has(itemId), + onSelect: onSelectItem, + }) + : undefined, + name: { + cell: obj.metadata.name, + }, + }; + + return tableColumns.map(({ id }) => { + const rowCell = rowCells[id]; + if (!rowCell) { + return { id, cell: '-' }; + } + const cellContent = id === 'select' ? rowCell.cell ?? '' : rowCell.cell ?? '-'; + return { + id, + props: rowCell.props, + cell: cellContent, + }; + }); + }); + }, + [selectedIds, onSelectItem], + ); + + return ( + + ); +}; +``` + +## additionalActions vs customActions + +ConsoleDataView supports two different props for providing actions to the toolbar: + +### `additionalActions` (Recommended for Bulk Selection) + +Use `additionalActions` when you want to provide actions that appear **in addition to** the default actions provided by the DataView. This is the recommended approach for bulk selection actions. + +```typescript +const bulkActions = useBulkActions({ + selectedItems: filteredSelectedItems, + onComplete: clearSelection, +}); + + +``` + +**When to use:** + +- You want bulk selection actions to appear alongside default DataView actions +- You're adding functionality without removing existing behavior +- Most common use case for bulk selection + +### `customActions` (Advanced Use Cases) + +Use `customActions` when you want to **completely replace** the default actions with your own custom implementation. This gives you full control over the actions toolbar but requires you to manage all actions yourself. + +```typescript +const customActions = useCustomActions({ + selectedItems: filteredSelectedItems, + onComplete: clearSelection, +}); + + +``` + +**When to use:** + +- You need complete control over all toolbar actions +- You're extending the actions via the Dynamic Plugin SDK +- You want to hide or replace default DataView actions entirely + +#### Example: Extending via Dynamic Plugin SDK + +The Nodes page uses `customActions` to allow dynamic plugins to contribute custom node actions: + +```typescript +// In your component +const customActions = useCustomNodeActions({ + selectedNodes: filteredSelectedNodes, + onComplete: clearSelection, +}); + + + +// In useCustomNodeActions.tsx +export const useCustomNodeActions = ({ selectedNodes, onComplete }) => { + const { t } = useTranslation(); + + // Get actions from dynamic plugins via SDK + const [actionProviders] = useResolvedExtensions>( + isActionProvider, + ); + + // Built-in actions + const builtInActions = useNodeActions({ selectedNodes, onComplete }); + + // Custom actions from plugins + const customActions = useMemo(() => { + return actionProviders.flatMap((provider) => + provider.properties.provider({ selectedNodes, onComplete }) + ); + }, [actionProviders, selectedNodes, onComplete]); + + // Combine built-in and custom actions + return useMemo(() => { + return [...builtInActions, ...customActions]; + }, [builtInActions, customActions]); +}; +``` + +#### Key Differences + +| Feature | `additionalActions` | `customActions` | +| ------- | ------------------- | --------------- | +| **Default actions** | Preserved | Replaced | +| **Use case** | Add bulk selection actions | Full control or plugin extension | +| **Complexity** | Simple | Advanced | +| **Plugin SDK** | Not extensible | Can be extended via SDK | + +## Advanced Usage + +### Filtering Selectable Items + +Some items may not be eligible for selection (e.g., pending resources, special types): + +```typescript +const { selectedIds, onSelectItem, onSelectAll } = useDataViewSelection({ + data, + getItemId: getUID, + filterSelectable: (item) => { + // Exclude Certificate Signing Requests + if (isCSRResource(item)) return false; + + // Exclude items in pending state + if (item.status?.phase === 'Pending') return false; + + return true; + }, +}); +``` + +### Conditional Actions Based on Selection + +Actions can be dynamically enabled/disabled based on the selected items: + +```typescript +const { schedulableCount, unschedulableCount } = useMemo(() => { + let schedulable = 0; + let unschedulable = 0; + selectedNodes.forEach((node) => { + if (isNodeUnschedulable(node)) { + unschedulable++; + } else { + schedulable++; + } + }); + return { schedulableCount: schedulable, unschedulableCount: unschedulable }; +}, [selectedNodes]); + +return [ + + {t('Mark as schedulable ({{nodeCount}})', { nodeCount: unschedulableCount })} + , + + {t('Mark as unschedulable ({{nodeCount}})', { nodeCount: schedulableCount })} + , +]; +``` + +### Handling Filtered Selection Changes + +The `onFilteredSelectionChange` callback is called when the filtered data changes (e.g., after applying filters). This is useful for updating custom actions to only operate on visible, filtered items: + +```typescript +const handleFilteredSelectionChange = useCallback((items: YourDataType[]) => { + // Filter out non-selectable items + const selectableItems = items.filter((item) => !isSpecialType(item)); + setFilteredSelectedItems(selectableItems); +}, []); +``` + +### Automatically Clearing Invalid Selections + +The `useDataViewSelection` hook automatically removes selections for items that no longer exist in the data: + +```typescript +// If data changes and a selected item is removed, it will be automatically +// deselected. No manual cleanup needed! +``` + +## Related Documentation + +- [INDETERMINATE_CHECKBOX.md](./INDETERMINATE_CHECKBOX.md) - Details on the indeterminate checkbox pattern +- [ConsoleDataView](./ConsoleDataView.tsx) - Main data view component +- [dataViewSelectionHelpers.ts](./dataViewSelectionHelpers.ts) - Selection helper functions + +## Real-World Examples + +For complete working examples, see: + +- [NodesPage.tsx](../nodes/NodesPage.tsx) - Full implementation with bulk selection and schedulable actions +- [useNodeActions.tsx](../nodes/useNodeActions.tsx) - Example of bulk action hooks diff --git a/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx b/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx index 13c130923f3..c1d7ae76397 100644 --- a/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx +++ b/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx @@ -1,12 +1,19 @@ import type { FC, ReactNode } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; import './ConsoleDataView.scss'; import { ResponsiveAction, ResponsiveActions, SkeletonTableBody, } from '@patternfly/react-component-groups'; -import { Bullseye, Pagination, PaginationVariant, Tooltip } from '@patternfly/react-core'; +import { + Banner, + Bullseye, + Button, + Pagination, + PaginationVariant, + Tooltip, +} from '@patternfly/react-core'; import { DataView, DataViewFilters, @@ -17,7 +24,7 @@ import { import { ColumnsIcon, UndoIcon } from '@patternfly/react-icons'; import { css } from '@patternfly/react-styles'; import { InnerScrollContainer, Tbody, Td, Tr } from '@patternfly/react-table'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import type { ResourceFilters, ConsoleDataViewProps, @@ -30,6 +37,7 @@ import { DataViewLabelFilter } from './DataViewLabelFilter'; import { DataViewTextFilter } from './DataViewTextFilter'; import { useConsoleDataViewData } from './useConsoleDataViewData'; import { useConsoleDataViewFilters } from './useConsoleDataViewFilters'; +import { useIndeterminateCheckbox } from './useIndeterminateCheckbox'; export const initialFiltersDefault: ResourceFilters = { name: '', label: '' }; @@ -80,6 +88,10 @@ export const ConsoleDataView = < mock, isResizable, resetAllColumnWidths, + additionalActions, + customActions, + selection, + actionsBreakpoint = 'md', }: ConsoleDataViewProps) => { const { t } = useTranslation(); const launchModal = useOverlay(); @@ -100,7 +112,23 @@ export const ConsoleDataView = < matchesAdditionalFilters, }); - const { dataViewColumns, dataViewRows, pagination } = useConsoleDataViewData< + // Notify parent of filtered selected items when filters or selection changes + useEffect(() => { + if (selection?.onFilteredSelectionChange) { + const filteredSelectedItems = filteredData.filter((item) => + selection.selectedItems.has(selection.getItemId(item)), + ); + selection.onFilteredSelectionChange(filteredSelectedItems); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + filteredData, + selection?.selectedItems, + selection?.getItemId, + selection?.onFilteredSelectionChange, + ]); + + const { dataViewColumns, dataViewRows, pagination, visibleItems } = useConsoleDataViewData< TData, TCustomRowData, TFilters @@ -113,6 +141,7 @@ export const ConsoleDataView = < columnManagementID, customRowData, isResizable, + selection, }); const bodyLoading = useMemo(() => , [ @@ -139,14 +168,63 @@ export const ConsoleDataView = < ofWord: t('public~of'), itemsPerPage: t('public~Items per page'), perPageSuffix: t('public~per page'), - toFirstPageAriaLabel: t('public~Go to first page'), toPreviousPageAriaLabel: t('public~Go to previous page'), toNextPageAriaLabel: t('public~Go to next page'), - toLastPageAriaLabel: t('public~Go to last page'), }), [t], ); + // Calculate banner state and indeterminate checkbox state + const bannerState = useMemo(() => { + if (!selection || !loaded || filteredData.length === 0) { + return { show: false, allSelected: false, isIndeterminate: false }; + } + + // Check if all visible items are selected + const allVisibleSelected = visibleItems.every((item) => + selection.selectedItems.has(selection.getItemId(item)), + ); + + // Count selected items among visible items + const visibleSelectedCount = visibleItems.filter((item) => + selection.selectedItems.has(selection.getItemId(item)), + ).length; + + const visibleCount = visibleItems.length; + const totalCount = filteredData.length; + const selectedCount = filteredData.filter((item) => + selection.selectedItems.has(selection.getItemId(item)), + ).length; + + // Show banner if all visible items are selected and there are more items than visible + const shouldShow = allVisibleSelected && visibleCount > 0 && totalCount > visibleCount; + + // All items are selected if selected count equals total count + const allSelected = selectedCount === totalCount; + + // Indeterminate state: some (but not all) visible items are selected + const isIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < visibleCount; + + return { show: shouldShow, allSelected, isIndeterminate }; + }, [selection, loaded, filteredData, visibleItems]); + + // Set indeterminate state on the select-all checkbox via DOM manipulation + // This is a workaround until PatternFly adds native support for isSelected: null + // See: https://github.com/patternfly/patternfly-react/issues/12404 + useIndeterminateCheckbox(bannerState.isIndeterminate); + + const handleSelectAllMatching = useCallback(() => { + if (selection?.onSelectAll) { + selection.onSelectAll(true, filteredData); + } + }, [selection, filteredData]); + + const handleUnselectAll = useCallback(() => { + if (selection?.onSelectAll) { + selection.onSelectAll(false, filteredData); + } + }, [selection, filteredData]); + const dataViewFilterNodes = useMemo(() => { const basicFilters: ReactNode[] = []; @@ -199,44 +277,85 @@ export const ConsoleDataView = < } clearAllFilters={clearAllFilters} actions={ - - {!hideColumnManagement && ( - - launchModal(LazyColumnManagementModalOverlay, { - columnLayout, - noLimit: true, - }) - } - aria-label={t('public~Column management')} - data-test="manage-columns" - > - - - - - )} - {isResizable && resetAllColumnWidths && ( - - - - - - )} - + <> + + {!hideColumnManagement && ( + + launchModal(LazyColumnManagementModalOverlay, { + columnLayout, + noLimit: true, + }) + } + aria-label={t('public~Column management')} + data-test="manage-columns" + > + + + + + )} + {isResizable && resetAllColumnWidths && ( + + + + + + )} + {additionalActions} + + {customActions} + } pagination={ - + } /> + {bannerState.show && ( + + {bannerState.allSelected ? ( + <> + + All {{ count: filteredData.length }} matching{' '} + {{ label: label || 'items' }} are selected. + {' '} + + + ) : ( + <> + + All {{ count: visibleItems.length }}{' '} + {{ label: label || 'items' }} on this page are selected. + {' '} + + + )} + + )} - - } + ); }; +export const SELECTION_COLUMN_WIDTH = '45px'; + export const cellIsStickyProps = { isStickyColumn: true, stickyMinWidth: '0', }; +export const selectionColumnProps = { + ...cellIsStickyProps, + stickyLeftOffset: '0', +}; + export const nameCellProps = { ...cellIsStickyProps, hasRightBorder: true, }; -export const getNameCellProps = (name: string) => { - return { - ...nameCellProps, - 'data-test': `data-view-cell-${name}-name`, - }; -}; +/** + * Returns name column props with appropriate offset based on whether bulk select is enabled. + * Use this for column definitions. + * @param hasRightBorder - Whether to include hasRightBorder (default: true) + * @param withBulkSelect - Whether the table has bulk selection enabled (default: false) + */ +export const getNameColumnProps = (hasRightBorder = true, withBulkSelect = false) => ({ + ...cellIsStickyProps, + ...(hasRightBorder && { hasRightBorder: true }), + ...(withBulkSelect && { stickyLeftOffset: SELECTION_COLUMN_WIDTH }), +}); + +/** + * Returns name cell props with appropriate offset based on whether bulk select is enabled. + * Use this for row cell definitions. + * @param name - The name to use in the data-test attribute + * @param withBulkSelect - Whether the table has bulk selection enabled (default: false) + */ +export const getNameCellProps = (name: string, withBulkSelect = false) => ({ + ...getNameColumnProps(true, withBulkSelect), + 'data-test': `data-view-cell-${name}-name`, +}); export const actionsCellProps = { ...cellIsStickyProps, diff --git a/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md b/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md new file mode 100644 index 00000000000..7227cc7d334 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md @@ -0,0 +1,95 @@ +# Indeterminate Checkbox Implementation + +This directory contains a workaround to add indeterminate checkbox support to PatternFly DataView tables until native support is added. + +## Problem + +PatternFly v6's Table component does not support indeterminate state for the select-all checkbox in the header. The `ThSelectType` interface only supports `isSelected: boolean`, which means: +- When **all** items are selected → checkbox is checked ✓ +- When **some** items are selected → checkbox is unchecked (looks like none selected) +- When **no** items are selected → checkbox is unchecked + +This creates poor UX because users can't tell if some items are selected vs none selected. + +## Solution + +We've implemented a hook-based workaround that sets the indeterminate state via DOM manipulation after PatternFly renders the checkbox. + +### Files Modified + +1. **`useIndeterminateCheckbox.ts`** - New hook that finds and updates the checkbox DOM element +2. **`ConsoleDataView.tsx`** - Calculates indeterminate state and uses the hook +3. **`useConsoleDataViewData.tsx`** - Handles select-all logic for visible items + +### How It Works + +1. **Selection state calculation** (`ConsoleDataView.tsx`): + + ```typescript + const visibleSelectedCount = visibleItems.filter((item) => + selection.selectedItems.has(selection.getItemId(item)), + ).length; + const isIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < visibleCount; + ``` + +2. **Hook invocation** (`ConsoleDataView.tsx`): + + ```typescript + useIndeterminateCheckbox(bannerState.isIndeterminate); + ``` + +3. **DOM manipulation** (`useIndeterminateCheckbox.ts`): + + ```typescript + const checkbox = document.querySelector('th.pf-v6-c-table__check input[name="check-all"]'); + if (checkbox) { + checkbox.indeterminate = isIndeterminate; + } + ``` + +### Behavior + +The checkbox state is based on the **current page/visible items** only: + +- **Unchecked** (`isSelected: false`) - No visible items selected + - **Clicking**: Selects all visible items on current page +- **Indeterminate** (`isSelected: null`) - Some (but not all) visible items on current page selected + - **Clicking**: Selects all remaining visible items on current page (completes page selection) +- **Checked** (`isSelected: true`) - All visible items on current page selected + - **Clicking**: Deselects all visible items on current page + +### Selection Logic + +1. **Checkbox states** are determined by visible items only (not all filtered items): + - **Checked** (`true`): All items on the current page are selected + - **Indeterminate** (`null`): Some (but not all) items on the current page are selected + - **Unchecked** (`false`): No items on the current page are selected + +2. **Click behavior**: + - Unchecked → Click → Select all visible items (on current page) + - Indeterminate → Click → Select all visible items (completes the page selection) + - Checked → Click → Deselect all visible items (on current page) + +3. **Banner behavior** (separate from checkbox): + - When all visible items are selected AND there are more pages, a banner appears + - The banner shows two states: + - "All X items on this page are selected. [Select all Y matching items]" + - "All X matching items are selected. [Unselect all]" + +## Testing + +To test the indeterminate state: + +1. Navigate to Compute → Nodes (or any page with a selectable DataView table) +2. Select **no items** → header checkbox should be unchecked +3. Select **some items** (not all visible) → header checkbox should show dash/minus (indeterminate) +4. Select **all visible items** → header checkbox should be checked +5. With all visible items selected and multiple pages, verify banner appears +6. Click "Select all X matching items" in banner → all items across all pages selected +7. Verify banner changes to "All X matching items are selected. [Unselect all]" + +## Notes + +- Uses PatternFly's native `isSelected: boolean | null` support (not DOM manipulation) +- The indeterminate state is scoped to visible items on the current page only +- Selection state is recalculated in `dataViewColumnsWithSortApplied` to use visible items diff --git a/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts new file mode 100644 index 00000000000..83a71b9fdce --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts @@ -0,0 +1,80 @@ +import type { TableColumn } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; + +const selectionColumnProps = { + isStickyColumn: true, + stickyMinWidth: '0', + stickyLeftOffset: '0', +} as const; + +/** + * Creates a selection column definition for DataView tables. + * This column displays checkboxes for row selection. + * The select-all checkbox in the header is automatically added by ConsoleDataView + * when the selection prop is provided. + * + * @example + * ```typescript + * const columns = [ + * createSelectionColumn(), + * { title: 'Name', id: 'name', ... }, + * ... + * ]; + * ``` + */ +export const createSelectionColumn = (): TableColumn => ({ + title: '', + id: 'select', + props: selectionColumnProps, +}); + +type CreateSelectionCellOptions = { + /** Row index in the table */ + rowIndex: number; + /** Unique ID for the item being selected */ + itemId: string; + /** Whether the item is currently selected */ + isSelected: boolean; + /** Callback when selection state changes */ + onSelect: (itemId: string, isSelecting: boolean) => void; + /** Whether the checkbox should be disabled */ + disabled?: boolean; +}; + +/** + * Creates a selection cell object for a DataView row. + * This cell contains the checkbox for row selection. + * + * @example + * ```typescript + * const rowCells = { + * select: createSelectionCell({ + * rowIndex: 0, + * itemId: getUID(node), + * isSelected: selectedIds.has(getUID(node)), + * onSelect: onSelectItem, + * }), + * name: { cell: }, + * ... + * }; + * ``` + */ +export const createSelectionCell = ({ + rowIndex, + itemId, + isSelected, + onSelect, + disabled = false, +}: CreateSelectionCellOptions) => ({ + cell: '', // Checkbox is rendered via props, no content needed + props: { + ...selectionColumnProps, + select: { + rowIndex, + onSelect: (_event: any, isSelecting: boolean) => { + onSelect(itemId, isSelecting); + }, + isSelected, + isDisabled: disabled, + }, + }, +}); diff --git a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx index 3fcf269d1a4..e8052acea43 100644 --- a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx +++ b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx @@ -37,6 +37,7 @@ export const useConsoleDataViewData = < columnManagementID, customRowData, isResizable = true, + selection, }: { columns: TableColumn[]; filteredData: TData[]; @@ -46,6 +47,11 @@ export const useConsoleDataViewData = < columnManagementID?: string; customRowData?: TCustomRowData; isResizable?: boolean; + selection?: { + selectedItems: Set; + onSelectAll?: (isSelecting: boolean, filteredItems: TData[]) => void; + getItemId: (item: TData) => string; + }; }) => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); @@ -87,43 +93,68 @@ export const useConsoleDataViewData = < columnManagementID, }); - const dataViewColumns = useMemo[]>( - () => - activeColumns.map(({ id, title, sort, props, resizableProps }, index) => { - // Filter out custom Console props that aren't valid PatternFly ThProps - const { isActionCell, ...validThProps } = props || {}; + const dataViewColumns = useMemo[]>(() => { + // Calculate selection state across all filtered items (for indeterminate state) + const selectedCount = selection + ? filteredData.filter((item) => selection.selectedItems.has(selection.getItemId(item))).length + : 0; + const totalCount = filteredData.length; + const someSelected = selectedCount > 0 && selectedCount < totalCount; - const headerProps: ThProps = { - ...validThProps, - dataLabel: title, - }; + return activeColumns.map(({ id, title, sort, props, resizableProps }, index) => { + // Filter out custom Console props that aren't valid PatternFly ThProps + const { isActionCell, ...validThProps } = props || {}; - if (sort) { - headerProps.sort = { - columnIndex: index, - sortBy: { - index: 0, - direction: SortByDirection.asc, - defaultDirection: SortByDirection.asc, - }, - }; - } + const headerProps: ThProps = { + ...validThProps, + dataLabel: title, + }; - return { - id, - title, - sortFunction: sort, - props: headerProps, - resizableProps: isResizable ? resizableProps : undefined, - cell: title ? ( - {title} - ) : ( - {t('public~Actions')} - ), + if (sort) { + headerProps.sort = { + columnIndex: index, + sortBy: { + index: 0, + direction: SortByDirection.asc, + defaultDirection: SortByDirection.asc, + }, }; - }), - [activeColumns, t, isResizable], - ); + } + + // Add select-all checkbox to selection column header + // Note: onSelect handler is updated later with visibleItems via dataViewColumnsWithSortApplied + // The checkbox state is determined by visible items only, not all items + if (id === 'select' && selection?.onSelectAll) { + // Initial state - will be updated with actual visible items state in dataViewColumnsWithSortApplied + headerProps.select = { + onSelect: (_event: any, isSelecting: boolean) => { + // This will be replaced with the actual handler in dataViewColumnsWithSortApplied + selection.onSelectAll(isSelecting, filteredData); + }, + isSelected: false, // Will be updated based on visible items + isDisabled: totalCount === 0, + // Pass indeterminate state through props (custom extension until PF supports it) + // See: https://github.com/patternfly/patternfly-react/issues/12404 + props: { + isIndeterminate: someSelected, + }, + }; + } + + return { + id, + title, + sortFunction: sort, + props: headerProps, + resizableProps: isResizable ? resizableProps : undefined, + cell: title ? ( + {title} + ) : ( + {t('public~Actions')} + ), + }; + }); + }, [activeColumns, t, isResizable, selection, filteredData]); const { sortBy, onSort } = useConsoleDataViewSort({ columns: dataViewColumns, @@ -162,37 +193,108 @@ export const useConsoleDataViewData = < (pagination.page - 1) * pagination.perPage + pagination.perPage, ); + const visibleItems = transformedData.map((item) => item.obj); const dataViewRows = getDataViewRows(transformedData, dataViewColumns); - // This code fixes a sorting issue but should be revisited to add more clarity + // This code fixes a sorting issue and updates select-all to use visible items const dataViewColumnsWithSortApplied = useMemo( () => dataViewColumns.map((column) => { - const shouldApplySort = - isDataViewConfigurableColumn(column) && - column.sortFunction !== undefined && - column.props.sort; - - return shouldApplySort - ? { - ...column, - props: { - ...column.props, - sort: { - ...column.props.sort, - sortBy: { - ...column.props.sort.sortBy, - index: sortBy.index, - direction: sortBy.direction, - }, - onSort, + if (!isDataViewConfigurableColumn(column)) { + return column; + } + + const shouldApplySort = column.sortFunction !== undefined && column.props.sort; + const shouldUpdateSelect = + column.id === 'select' && column.props.select && selection?.onSelectAll; + + if (shouldApplySort && shouldUpdateSelect) { + // Calculate if all visible items are selected + const allVisibleSelected = + visibleItems.length > 0 && + visibleItems.every((item) => selection.selectedItems.has(selection.getItemId(item))); + + // Both sort and select need updating + return { + ...column, + props: { + ...column.props, + sort: { + ...column.props.sort, + sortBy: { + ...column.props.sort.sortBy, + index: sortBy.index, + direction: sortBy.direction, + }, + onSort, + }, + select: { + ...column.props.select, + // Checkbox is checked only when ALL visible items are selected + // Indeterminate state is handled via DOM manipulation in ConsoleDataView + isSelected: allVisibleSelected, + onSelect: (_event: any, isSelecting: boolean) => { + // When unchecked or indeterminate, clicking selects all visible items + // When checked, clicking deselects all visible items + selection.onSelectAll(isSelecting, visibleItems); }, }, - } - : column; + }, + }; + } + + if (shouldApplySort) { + return { + ...column, + props: { + ...column.props, + sort: { + ...column.props.sort, + sortBy: { + ...column.props.sort.sortBy, + index: sortBy.index, + direction: sortBy.direction, + }, + onSort, + }, + }, + }; + } + + if (shouldUpdateSelect) { + // Calculate if all visible items are selected + const allVisibleSelected = + visibleItems.length > 0 && + visibleItems.every((item) => selection.selectedItems.has(selection.getItemId(item))); + + return { + ...column, + props: { + ...column.props, + select: { + ...column.props.select, + // Checkbox is checked only when ALL visible items are selected + // Indeterminate state is handled via DOM manipulation in ConsoleDataView + isSelected: allVisibleSelected, + onSelect: (_event: any, isSelecting: boolean) => { + // When unchecked or indeterminate, clicking selects all visible items + // When checked, clicking deselects all visible items + selection.onSelectAll(isSelecting, visibleItems); + }, + }, + }, + }; + } + + return column; }), - [dataViewColumns, sortBy.index, sortBy.direction, onSort], + [dataViewColumns, sortBy.index, sortBy.direction, onSort, selection, visibleItems], ); - return { dataViewRows, dataViewColumns: dataViewColumnsWithSortApplied, pagination }; + return { + dataViewRows, + dataViewColumns: dataViewColumnsWithSortApplied, + pagination, + visibleItems, + }; }; diff --git a/frontend/packages/console-app/src/components/data-view/useDataViewSelection.ts b/frontend/packages/console-app/src/components/data-view/useDataViewSelection.ts new file mode 100644 index 00000000000..da9840b493a --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useDataViewSelection.ts @@ -0,0 +1,106 @@ +import { useState, useCallback, useMemo, useEffect } from 'react'; + +type UseDataViewSelectionOptions = { + /** All data items */ + data: T[]; + /** Function to extract unique ID from an item */ + getItemId: (item: T) => string; + /** Optional filter to exclude certain items from selection (e.g., filter out CSRs) */ + filterSelectable?: (item: T) => boolean; +}; + +type UseDataViewSelectionResult = { + /** Set of selected item IDs */ + selectedIds: Set; + /** Array of selected item objects */ + selectedItems: T[]; + /** Callback to select/deselect a single item */ + onSelectItem: (itemId: string, isSelecting: boolean) => void; + /** Callback to select/deselect all filtered items */ + onSelectAll: (isSelecting: boolean, filteredItems: T[]) => void; + /** Clear all selections */ + clearSelection: () => void; +}; + +/** + * Custom hook for managing selection state in DataView components. + * Provides selection state, callbacks, and selected item objects. + * + * @example + * ```typescript + * const { selectedIds, selectedItems, onSelectItem, onSelectAll, clearSelection } = + * useDataViewSelection({ + * data, + * getItemId: (node) => getUID(node), + * filterSelectable: (item) => !isCSRResource(item), + * }); + * ``` + */ +export const useDataViewSelection = ({ + data, + getItemId, + filterSelectable, +}: UseDataViewSelectionOptions): UseDataViewSelectionResult => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Update selection to only include items that still exist in the current data + useEffect(() => { + const selectableData = filterSelectable ? data.filter(filterSelectable) : data; + const currentValidIds = new Set(selectableData.map(getItemId)); + + setSelectedIds((prev) => { + const filtered = new Set(); + prev.forEach((id) => { + if (currentValidIds.has(id)) { + filtered.add(id); + } + }); + // Only update if the selection actually changed + return filtered.size === prev.size ? prev : filtered; + }); + }, [data, getItemId, filterSelectable]); + + const onSelectItem = useCallback((itemId: string, isSelecting: boolean) => { + setSelectedIds((prev) => { + const newSet = new Set(prev); + if (isSelecting) { + newSet.add(itemId); + } else { + newSet.delete(itemId); + } + return newSet; + }); + }, []); + + const onSelectAll = useCallback( + (isSelecting: boolean, filteredItems: T[]) => { + if (isSelecting) { + const selectableItems = filterSelectable + ? filteredItems.filter(filterSelectable) + : filteredItems; + const itemIds = selectableItems.map(getItemId); + setSelectedIds(new Set(itemIds)); + } else { + setSelectedIds(new Set()); + } + }, + [getItemId, filterSelectable], + ); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const selectedItems = useMemo(() => { + const selectableData = filterSelectable ? data.filter(filterSelectable) : data; + return selectableData.filter((item) => selectedIds.has(getItemId(item))); + }, [data, selectedIds, getItemId, filterSelectable]); + + return { + selectedIds, + selectedItems, + onSelectItem, + onSelectAll, + clearSelection, + }; +}; diff --git a/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts b/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts new file mode 100644 index 00000000000..61d90e5e81f --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +/** + * Hook to set indeterminate state on select-all checkbox in DataView table. + * This is a workaround until PatternFly adds native support for indeterminate state. + * See: https://github.com/patternfly/patternfly-react/issues/12404 + * + * When the checkbox is clicked while in indeterminate state, it should: + * 1. Clear the indeterminate state + * 2. Become checked (selecting all visible items) + * + * This happens automatically because: + * - When indeterminate, isSelected prop is false + * - Clicking passes isSelecting=true to the handler + * - Handler selects all visible items + * - On next render, all visible items are selected so isSelected becomes true + * + * @param isIndeterminate - Whether the checkbox should be in indeterminate state + */ +export const useIndeterminateCheckbox = (isIndeterminate: boolean) => { + useEffect(() => { + // Find the select-all checkbox in the table header + // PatternFly DataView uses: th.pf-v6-c-table__check input[type="checkbox"][name="check-all"] + const checkbox = document.querySelector( + 'th.pf-v6-c-table__check input[type="checkbox"][name="check-all"]', + ); + + if (checkbox) { + // Set indeterminate state via DOM property + // Note: indeterminate doesn't change the checked state, it's a separate visual state + // When clicked, the checkbox will toggle based on the checked property + checkbox.indeterminate = isIndeterminate; + } + }, [isIndeterminate]); +}; diff --git a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index 37fcba8096b..8476b2a1cfc 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { useMemo, useCallback, useEffect, Suspense } from 'react'; +import { useMemo, useCallback, useEffect, useState, Suspense } from 'react'; import { Button, ButtonVariant } from '@patternfly/react-core'; import { DataViewCheckboxFilter } from '@patternfly/react-data-view'; import type { DataViewFilterOption } from '@patternfly/react-data-view/dist/esm/DataViewFilters'; @@ -8,16 +8,21 @@ import { useTranslation } from 'react-i18next'; import { actionsCellProps, getNameCellProps, + getNameColumnProps, initialFiltersDefault, ConsoleDataView, - nameCellProps, getLabelsColumnWidthStyleProp, } from '@console/app/src/components/data-view/ConsoleDataView'; +import { + createSelectionColumn, + createSelectionCell, +} from '@console/app/src/components/data-view/dataViewSelectionHelpers'; import type { ConsoleDataViewColumn, ConsoleDataViewRow, ResourceFilters, } from '@console/app/src/components/data-view/types'; +import { useDataViewSelection } from '@console/app/src/components/data-view/useDataViewSelection'; import { useColumnWidthSettings } from '@console/app/src/components/data-view/useResizableColumnProps'; import { FLAG_NODE_MGMT_V1 } from '@console/app/src/consts'; import type { K8sModel } from '@console/dynamic-plugin-sdk/src/api/core-api'; @@ -105,6 +110,7 @@ import { useWatchVirtualMachineInstances, } from './NodeVmUtils'; import ClientCSRStatus from './status/CSRStatus'; +import { useCustomNodeActions } from './useCustomNodeActions'; import type { GetNodeStatusExtensions } from './useNodeStatusExtensions'; import { useNodeStatusExtensions } from './useNodeStatusExtensions'; @@ -179,13 +185,14 @@ const useNodesColumns = ( const columns = useMemo(() => { return [ + createSelectionColumn(), { title: t('console-app~Name'), id: nodeColumnInfo.name.id, sort: 'metadata.name', resizableProps: getResizableProps(nodeColumnInfo.name.id), props: { - ...nameCellProps, + ...getNameColumnProps(true, true), modifier: 'nowrap', }, }, @@ -401,8 +408,12 @@ const getNodeDataViewRows = ( tableColumns: ConsoleDataViewColumn[], nodeMetrics: NodeMetrics, statusExtensions: GetNodeStatusExtensions, + selection?: { + selectedItems: Set; + onSelect: (itemId: string, isSelecting: boolean) => void; + }, ): ConsoleDataViewRow[] => { - return rowData.map(({ obj }) => { + return rowData.map(({ obj }, rowIndex) => { const isCSR = isCSRResource(obj); const node = isCSR ? null : (obj as NodeKind); const csr = isCSR ? (obj as NodeCertificateSigningRequestKind) : null; @@ -434,6 +445,15 @@ const getNodeDataViewRows = ( const context = node ? { [resourceKind]: node } : {}; const rowCells = { + select: + selection && node + ? createSelectionCell({ + rowIndex, + itemId: nodeUID, + isSelected: selection.selectedItems.has(nodeUID), + onSelect: selection.onSelect, + }) + : undefined, [nodeColumnInfo.name.id]: { cell: node ? ( { - const cell = rowCells[id]?.cell || DASH; + const rowCell = rowCells[id]; + if (!rowCell) { + return { + id, + cell: DASH, + }; + } + // For select column, don't default to DASH - checkbox is rendered via props + const cellContent = id === 'select' ? rowCell.cell ?? '' : rowCell.cell ?? DASH; return { id, - props: rowCells[id]?.props, - cell, + props: rowCell.props, + cell: cellContent, }; }); }); @@ -643,15 +671,38 @@ const NodeList: FC = ({ const columnManagementID = referenceForModel(NodeModel); const statusExtensions = useNodeStatusExtensions(); + // Selection state + const { selectedIds, onSelectItem, onSelectAll, clearSelection } = useDataViewSelection({ + data, + getItemId: getUID, + filterSelectable: (item) => !isCSRResource(item), + }); + + // Track filtered selected nodes for custom actions + const [filteredSelectedNodes, setFilteredSelectedNodes] = useState([]); + + const handleFilteredSelectionChange = useCallback((items: NodeRowItem[]) => { + // Filter out CSRs and cast to NodeKind + const nodes = items.filter((item) => !isCSRResource(item)) as NodeKind[]; + setFilteredSelectedNodes(nodes); + }, []); + + const customActions = useCustomNodeActions({ + selectedNodes: filteredSelectedNodes, + onComplete: clearSelection, + }); + const columnLayout = useMemo( () => ({ id: columnManagementID, type: t('console-app~Node'), - columns: columns.map((col) => ({ - id: col.id, - title: col.title, - additional: col.additional, - })), + columns: columns + .filter((col) => col.id !== 'select' && col.id !== nodeColumnInfo.actions.id) + .map((col) => ({ + id: col.id, + title: col.title, + additional: col.additional, + })), selectedColumns: selectedColumns?.[columnManagementID]?.length > 0 ? new Set(selectedColumns[columnManagementID] as string[]) @@ -859,6 +910,10 @@ const NodeList: FC = ({ tableColumns, nodeMetrics, statusExtensions, + { + selectedItems: selectedIds, + onSelect: onSelectItem, + }, ) } hideNameLabelFilters={hideNameLabelFilters} @@ -866,6 +921,14 @@ const NodeList: FC = ({ hideColumnManagement={hideColumnManagement} isResizable resetAllColumnWidths={resetAllColumnWidths} + customActions={customActions} + selection={{ + selectedItems: selectedIds, + onSelect: onSelectItem, + onSelectAll, + getItemId: getUID, + onFilteredSelectionChange: handleFilteredSelectionChange, + }} /> ); diff --git a/frontend/packages/console-app/src/components/nodes/useCustomNodeActions.tsx b/frontend/packages/console-app/src/components/nodes/useCustomNodeActions.tsx new file mode 100644 index 00000000000..90cb2bfb96d --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/useCustomNodeActions.tsx @@ -0,0 +1,150 @@ +import { useMemo, useCallback } from 'react'; +import { DropdownItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { ResponsiveActionDropdown } from '@console/shared/src/components/dropdown/ResponsiveActionDropdown'; +import { usePromiseHandler } from '@console/shared/src/hooks/usePromiseHandler'; +import { isNodeUnschedulable } from '@console/shared/src/selectors/node'; +import { makeNodeSchedulable, makeNodeUnschedulable } from '../../k8s/requests/nodes'; + +type UseCustomNodeActionsOptions = { + selectedNodes: NodeKind[]; + onComplete: () => void; +}; + +/** + * Hook for custom node actions dropdown. + * Returns a ResponsiveActionDropdown that should be used with customActions prop. + * Shows as primary button on desktop (md breakpoint and above), kebab button on mobile. + */ +export const useCustomNodeActions = ({ + selectedNodes, + onComplete, +}: UseCustomNodeActionsOptions) => { + const { t } = useTranslation(); + const [handlePromise, inProgress] = usePromiseHandler(); + + const { schedulableCount, unschedulableCount } = useMemo(() => { + let schedulable = 0; + let unschedulable = 0; + selectedNodes.forEach((node) => { + if (isNodeUnschedulable(node)) { + unschedulable++; + } else { + schedulable++; + } + }); + return { schedulableCount: schedulable, unschedulableCount: unschedulable }; + }, [selectedNodes]); + + const handleMarkSchedulable = useCallback(() => { + const promises = selectedNodes + .filter((node) => isNodeUnschedulable(node)) + .map((node) => makeNodeSchedulable(node)); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t( + 'console-app~Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable', + { failureCount: failures.length, totalCount: results.length }, + ), + ); + } + }), + ) + .then(() => { + onComplete(); + }) + .catch(() => { + // Errors are handled by usePromiseHandler + }); + }, [selectedNodes, handlePromise, t, onComplete]); + + const handleMarkUnschedulable = useCallback(() => { + const promises = selectedNodes + .filter((node) => !isNodeUnschedulable(node)) + .map((node) => makeNodeUnschedulable(node)); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t( + 'console-app~Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable', + { failureCount: failures.length, totalCount: results.length }, + ), + ); + } + }), + ) + .then(() => { + onComplete(); + }) + .catch(() => { + // Errors are handled by usePromiseHandler + }); + }, [selectedNodes, handlePromise, t, onComplete]); + + return useMemo(() => { + const dropdownItems: JSX.Element[] = []; + + if (unschedulableCount > 0) { + dropdownItems.push( + + {t('console-app~Mark schedulable')} + , + ); + } + + if (schedulableCount > 0) { + dropdownItems.push( + + {t('console-app~Mark unschedulable')} + , + ); + } + + const hasNoApplicableActions = dropdownItems.length === 0; + const isDisabled = inProgress || selectedNodes.length === 0 || hasNoApplicableActions; + + return ( + + {dropdownItems} + + ); + }, [ + unschedulableCount, + schedulableCount, + inProgress, + selectedNodes.length, + t, + handleMarkSchedulable, + handleMarkUnschedulable, + ]); +}; diff --git a/frontend/packages/console-app/src/components/nodes/useNodeActions.tsx b/frontend/packages/console-app/src/components/nodes/useNodeActions.tsx new file mode 100644 index 00000000000..4593f091431 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/useNodeActions.tsx @@ -0,0 +1,117 @@ +import { useMemo, useCallback } from 'react'; +import { ResponsiveAction } from '@patternfly/react-component-groups'; +import { useTranslation } from 'react-i18next'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { usePromiseHandler } from '@console/shared/src/hooks/usePromiseHandler'; +import { isNodeUnschedulable } from '@console/shared/src/selectors/node'; +import { makeNodeSchedulable, makeNodeUnschedulable } from '../../k8s/requests/nodes'; + +type UseBulkNodeActionsOptions = { + selectedNodes: NodeKind[]; + onComplete: () => void; +}; + +// DO NOT MERGE. THIS IS JUST AN EXAMPLE. + +export const useAdditionalNodeActions = ({ + selectedNodes, + onComplete, +}: UseBulkNodeActionsOptions) => { + const { t } = useTranslation(); + const [handlePromise, inProgress] = usePromiseHandler(); + + const { schedulableCount, unschedulableCount } = useMemo(() => { + let schedulable = 0; + let unschedulable = 0; + selectedNodes.forEach((node) => { + if (isNodeUnschedulable(node)) { + unschedulable++; + } else { + schedulable++; + } + }); + return { schedulableCount: schedulable, unschedulableCount: unschedulable }; + }, [selectedNodes]); + + const handleMarkSchedulable = useCallback(() => { + const promises = selectedNodes + .filter((node) => isNodeUnschedulable(node)) + .map((node) => makeNodeSchedulable(node)); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t( + 'console-app~Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable', + { failureCount: failures.length, totalCount: results.length }, + ), + ); + } + }), + ) + .then(() => { + onComplete(); + }) + .catch(() => { + // Errors are handled by usePromiseHandler + }); + }, [selectedNodes, handlePromise, t, onComplete]); + + const handleMarkUnschedulable = useCallback(() => { + const promises = selectedNodes + .filter((node) => !isNodeUnschedulable(node)) + .map((node) => makeNodeUnschedulable(node)); + + handlePromise( + Promise.allSettled(promises).then((results) => { + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + throw new Error( + t( + 'console-app~Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable', + { failureCount: failures.length, totalCount: results.length }, + ), + ); + } + }), + ) + .then(() => { + onComplete(); + }) + .catch(() => { + // Errors are handled by usePromiseHandler + }); + }, [selectedNodes, handlePromise, t, onComplete]); + + return useMemo(() => { + return [ + + {t('console-app~Mark as schedulable ({{nodeCount}})', { nodeCount: unschedulableCount })} + , + + {t('console-app~Mark as unschedulable ({{nodeCount}})', { nodeCount: schedulableCount })} + , + ]; + }, [ + unschedulableCount, + schedulableCount, + handleMarkSchedulable, + handleMarkUnschedulable, + inProgress, + t, + ]); +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-api.ts b/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-api.ts index c6913252676..c2c8b096ada 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-api.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-api.ts @@ -99,6 +99,12 @@ export const ConsoleDataView: < export const cellIsStickyProps: CellIsStickyProps = require('@console/app/src/components/data-view/ConsoleDataView') .cellIsStickyProps; +export const getNameColumnProps: ( + hasRightBorder?: boolean, + withBulkSelect?: boolean, +) => CellIsStickyProps = require('@console/app/src/components/data-view/ConsoleDataView') + .getNameColumnProps; + export const getNameCellProps: GetNameCellProps = require('@console/app/src/components/data-view/ConsoleDataView') .getNameCellProps; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-types.ts b/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-types.ts index 36d124fe460..2562a2e3ddc 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-types.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/api/internal-types.ts @@ -1,5 +1,6 @@ import type { ReactNode, ComponentType, SetStateAction, Dispatch } from 'react'; import type { QuickStart } from '@patternfly/quickstarts'; +import type { OverflowMenuProps } from '@patternfly/react-core'; import type { DataViewTh } from '@patternfly/react-data-view/dist/esm/DataViewTable/DataViewTable'; import type { SortByDirection, ThProps } from '@patternfly/react-table'; import type { Map as ImmutableMap } from 'immutable'; @@ -358,6 +359,25 @@ export type ConsoleDataViewProps< isResizable?: boolean; /** When provided and isResizable is true, a toolbar action is shown to reset all column widths. */ resetAllColumnWidths?: () => void; + /** Additional actions to display in the toolbar when items are selected (inside ResponsiveActions). */ + additionalActions?: ReactNode; + /** Custom actions to display in the toolbar outside ResponsiveActions (for actions that should not be responsive via ResponsiveActions). */ + customActions?: ReactNode; + /** Selection configuration for enabling row selection via checkboxes. When provided, a checkbox column is added to the table with select-all in the header. */ + selection?: { + /** Set of selected item IDs. */ + selectedItems: Set; + /** Callback when a single row is selected/deselected. */ + onSelect: (itemId: string, isSelecting: boolean) => void; + /** Callback when select all is toggled. Receives filtered items matching current filters. */ + onSelectAll?: (isSelecting: boolean, filteredItems: TData[]) => void; + /** Function to extract unique ID from an item for selection tracking. */ + getItemId: (item: TData) => string; + /** Callback to receive filtered selected items whenever filters or selection changes. */ + onFilteredSelectionChange?: (filteredSelectedItems: TData[]) => void; + }; + /** Breakpoint at which toolbar actions switch between horizontal and dropdown layout. Default is 'lg'. */ + actionsBreakpoint?: OverflowMenuProps['breakpoint']; }; // ConsoleDataView helper types @@ -368,6 +388,7 @@ export type CellIsStickyProps = { export type GetNameCellProps = ( name: string, + withBulkSelect?: boolean, ) => CellIsStickyProps & { hasRightBorder: true; 'data-test': string; diff --git a/frontend/packages/console-shared/src/components/dropdown/ResponsiveActionDropdown.tsx b/frontend/packages/console-shared/src/components/dropdown/ResponsiveActionDropdown.tsx new file mode 100644 index 00000000000..a96173dddba --- /dev/null +++ b/frontend/packages/console-shared/src/components/dropdown/ResponsiveActionDropdown.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { + Dropdown, + DropdownList, + MenuToggle, + OverflowMenu, + OverflowMenuContent, +} from '@patternfly/react-core'; +import type { MenuToggleElement } from '@patternfly/react-core'; +// Import context from source since it's not in public API +import { OverflowMenuContext } from '@patternfly/react-core/dist/esm/components/OverflowMenu/OverflowMenuContext'; +import { EllipsisVIcon } from '@patternfly/react-icons'; + +export type ResponsiveActionDropdownProps = { + /** Label for the action button (shown on desktop) */ + label: string; + /** Dropdown items to display */ + children: React.ReactNode; + /** Whether the dropdown is disabled */ + isDisabled?: boolean; + /** Optional test ID for the toggle button */ + 'data-test'?: string; + /** Optional aria-label for the kebab toggle (mobile). If not provided, uses label. */ + 'aria-label'?: string; + /** Breakpoint at which to switch between primary button and kebab menu */ + breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + /** Button variant for desktop view. Mobile always uses 'plain' for kebab menu. Defaults to 'primary'. */ + variant?: 'default' | 'primary' | 'secondary' | 'plainText' | 'typeahead'; +}; + +type InternalDropdownProps = { + label: string; + children: React.ReactNode; + isDisabled?: boolean; + 'data-test'?: string; + 'aria-label'?: string; + variant: 'default' | 'primary' | 'secondary' | 'plainText' | 'typeahead'; +}; + +/** + * Internal component that consumes OverflowMenuContext to render the dropdown + * with breakpoint-aware styling. + */ +const InternalDropdown: React.FC = ({ + label, + children, + isDisabled, + 'data-test': dataTest, + 'aria-label': ariaLabel, + variant, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const { isBelowBreakpoint } = React.useContext(OverflowMenuContext); + + const onToggle = React.useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const onSelect = React.useCallback(() => { + setIsOpen(false); + }, []); + + return ( + ) => ( + + {isBelowBreakpoint ? : label} + + )} + > + {children} + + ); +}; + +/** + * A responsive action dropdown that switches between a button (desktop) + * and a kebab menu (mobile) based on breakpoint. + * + * Uses PatternFly's OverflowMenu to handle breakpoint detection automatically. + * + * @example + * ```tsx + * + * Action 1 + * Action 2 + * + * ``` + */ +export const ResponsiveActionDropdown: React.FC = ({ + label, + children, + isDisabled = false, + 'data-test': dataTest, + 'aria-label': ariaLabel, + breakpoint = 'md', + variant = 'primary', +}) => { + return ( + + + + {children} + + + + ); +}; diff --git a/frontend/packages/console-shared/src/components/dropdown/__tests__/ResponsiveActionDropdown.spec.tsx b/frontend/packages/console-shared/src/components/dropdown/__tests__/ResponsiveActionDropdown.spec.tsx new file mode 100644 index 00000000000..d39d443b8e3 --- /dev/null +++ b/frontend/packages/console-shared/src/components/dropdown/__tests__/ResponsiveActionDropdown.spec.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { DropdownItem } from '@patternfly/react-core'; +import { render, screen } from '@testing-library/react'; +import { ResponsiveActionDropdown } from '../ResponsiveActionDropdown'; + +// Mock OverflowMenuContext with createContext +jest.mock('@patternfly/react-core/dist/esm/components/OverflowMenu/OverflowMenuContext', () => ({ + OverflowMenuContext: React.createContext({ isBelowBreakpoint: false }), +})); + +describe('ResponsiveActionDropdown', () => { + beforeAll(() => { + // Mock window.innerWidth for OverflowMenu + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }); + }); + + it('renders with label', () => { + render( + + Action 1 + Action 2 + , + ); + + expect(screen.getByTestId('test-dropdown')).toBeInTheDocument(); + }); + + it('renders in disabled state', () => { + render( + + Action 1 + , + ); + + const toggle = screen.getByTestId('test-dropdown'); + expect(toggle).toBeDisabled(); + }); + + it('uses default md breakpoint', () => { + render( + + Action 1 + , + ); + + // Component renders successfully with default breakpoint + expect(screen.getByTestId('test-dropdown')).toBeInTheDocument(); + }); + + it('applies custom variant prop', () => { + render( + + Action 1 + , + ); + + // MenuToggle should have secondary variant class + const toggle = screen.getByTestId('actions-dropdown'); + expect(toggle).toHaveClass('pf-m-secondary'); + }); + + it('defaults to primary variant', () => { + render( + + Action 1 + , + ); + + // MenuToggle should have primary variant class + const toggle = screen.getByTestId('actions-dropdown'); + expect(toggle).toHaveClass('pf-m-primary'); + }); +}); diff --git a/frontend/public/components/droppable-edit-yaml.tsx b/frontend/public/components/droppable-edit-yaml.tsx index d3443046198..3f67e7f5189 100644 --- a/frontend/public/components/droppable-edit-yaml.tsx +++ b/frontend/public/components/droppable-edit-yaml.tsx @@ -141,7 +141,7 @@ export const DroppableEditYAML: FC = ({ onDropRejected(fileRejections, event); } if (acceptedFiles.length > 0) { - onFileDrop(event, acceptedFiles as File[]); + onFileDrop(event as DropEvent, acceptedFiles as File[]); } }, accept: { diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 8f88dcc2ff4..8987ee9d14f 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1765,12 +1765,14 @@ "Admission Webhook Warning": "Admission Webhook Warning", "{{kind}} {{name}} violates policy {{warning}}": "{{kind}} {{name}} violates policy {{warning}}", "Learn more": "Learn more", - "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", "Go to next page": "Go to next page", - "Go to last page": "Go to last page", "Filter by name": "Filter by name", "Reset column widths": "Reset column widths", + "All <1>{{count}} matching {{label}} are selected.": "All <1>{{count}} matching {{label}} are selected.", + "Unselect all": "Unselect all", + "All <1>{{count}} {{label}} on this page are selected.": "All <1>{{count}} {{label}} on this page are selected.", + "Select all <1>{{count}} matching {{label}}": "Select all <1>{{count}} matching {{label}}", "{{label}} table": "{{label}} table", "Filter by label": "Filter by label", "Selected group is unavailable": "Selected group is unavailable", diff --git a/frontend/public/style/ancillary/_bootstrap-residual.scss b/frontend/public/style/ancillary/_bootstrap-residual.scss index ab3ea0e0eab..b34588b1817 100644 --- a/frontend/public/style/ancillary/_bootstrap-residual.scss +++ b/frontend/public/style/ancillary/_bootstrap-residual.scss @@ -8,7 +8,7 @@ margin-top: var(--pf-t--global--spacer--xs); } -:where(:not([class*='pf-v6-c-'])) { +:where(:not([class*='pf-v6-c-'], .pf-v6-c-table label)) { @at-root label#{&} { display: inline-block; max-width: 100%; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 53d86b1145f..4a09b7fce50 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3579,9 +3579,9 @@ __metadata: languageName: node linkType: hard -"@patternfly/react-component-groups@npm:^6.1.0, @patternfly/react-component-groups@npm:~6.4.0": - version: 6.4.0 - resolution: "@patternfly/react-component-groups@npm:6.4.0" +"@patternfly/react-component-groups@npm:6.4.0-prerelease.17": + version: 6.4.0-prerelease.17 + resolution: "@patternfly/react-component-groups@npm:6.4.0-prerelease.17" dependencies: "@patternfly/react-core": "npm:^6.0.0" "@patternfly/react-icons": "npm:^6.0.0" @@ -3592,7 +3592,7 @@ __metadata: "@patternfly/react-drag-drop": ^6.0.0 react: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19 - checksum: 10c0/97e6e910206b6c4c0e04a44b1b62fb7dbf24538aa367e01948abe0b27ae3f58a0b2316d150a22a885a566c10e065093388111710dd45a7a5d85817aabfb05b50 + checksum: 10c0/6fbaf722bc6a3e9e101043cb0749cef8fce89b1f054b1ce447538bbf413ceeec5e777bcfbcdae4f6d4e9a1d4969f922566f045bfd9b76a43ad45064b86b5f3ff languageName: node linkType: hard @@ -17944,7 +17944,7 @@ __metadata: "@patternfly/react-catalog-view-extension": "npm:~6.3.0" "@patternfly/react-charts": "npm:~8.4.1" "@patternfly/react-code-editor": "npm:~6.4.2" - "@patternfly/react-component-groups": "npm:~6.4.0" + "@patternfly/react-component-groups": "npm:6.4.0-prerelease.17" "@patternfly/react-core": "npm:~6.4.2" "@patternfly/react-data-view": "npm:~6.4.0-prerelease.12" "@patternfly/react-drag-drop": "npm:~6.5.0-prerelease.38" From 4edf6e1b3141dc8733984a25fde38eb633bbd435 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Fri, 8 May 2026 16:03:06 -0400 Subject: [PATCH 2/5] CONSOLE-5091: Upgrade to PatternFly v6.5 with native indeterminate checkbox support Upgrade to @patternfly/react-table@6.5.0-prerelease.77 which includes native indeterminate checkbox support via PR patternfly/patternfly-react#12411. Replace custom DOM manipulation hook with inline useEffect that sets checkbox indeterminate state directly. This avoids React controlled/uncontrolled input warnings that occur when passing isIndeterminate as a prop. Changes: - Upgrade PatternFly packages to v6.5 prerelease with peer dependency resolutions - Delete useIndeterminateCheckbox custom hook - Add inline useEffect in ConsoleDataView for indeterminate state via DOM manipulation - Fix icon-utils to handle both IconDefinition and IconData formats from PF v6.5 - Add Boolean coercion for checkbox isSelected prop to prevent controlled warnings - Wrap getDataViewRows in useCallback for performance - Remove @ts-expect-error comments now that NotificationBadge types variant prop - Document known reselect dev warning from legacy connect() HOC in kinds.ts Co-Authored-By: Claude Sonnet 4.5 --- frontend/package.json | 7 +- .../data-view/BULK_SELECTION_GUIDE.md | 2 +- .../components/data-view/ConsoleDataView.tsx | 19 ++-- .../data-view/INDETERMINATE_CHECKBOX.md | 95 ------------------- .../data-view/dataViewSelectionHelpers.ts | 3 +- .../data-view/useConsoleDataViewData.tsx | 27 ++---- .../data-view/useIndeterminateCheckbox.ts | 35 ------- .../src/components/nodes/NodesPage.tsx | 34 +++---- .../console-shared/src/utils/icon-utils.ts | 17 +++- .../components/masthead/masthead-toolbar.tsx | 4 - frontend/public/kinds.ts | 6 ++ frontend/yarn.lock | 87 +++++++++-------- 12 files changed, 114 insertions(+), 222 deletions(-) delete mode 100644 frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md delete mode 100644 frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts diff --git a/frontend/package.json b/frontend/package.json index 11c3359f22f..6558f5ea9e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -166,7 +166,7 @@ "@patternfly/react-icons": "~6.4.0", "@patternfly/react-log-viewer": "~6.3.0", "@patternfly/react-styles": "~6.4.0", - "@patternfly/react-table": "~6.4.2", + "@patternfly/react-table": "6.5.0-prerelease.77", "@patternfly/react-templates": "~6.4.2", "@patternfly/react-tokens": "~6.4.0", "@patternfly/react-topology": "~6.4.0", @@ -325,6 +325,11 @@ "hosted-git-info": "^3.0.8", "lodash-es": "^4.17.23", "@patternfly/react-component-groups": "6.4.0-prerelease.17", + "@patternfly/react-core": "6.5.0-prerelease.73", + "@patternfly/react-icons": "6.5.0-prerelease.34", + "@patternfly/react-styles": "6.5.0-prerelease.24", + "@patternfly/react-table": "6.5.0-prerelease.77", + "@patternfly/react-tokens": "6.5.0-prerelease.23", "postcss": "^8.2.13" }, "lint-staged": { diff --git a/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md b/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md index 2c7b981f195..63e2b82b8b3 100644 --- a/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md +++ b/frontend/packages/console-app/src/components/data-view/BULK_SELECTION_GUIDE.md @@ -621,9 +621,9 @@ The `useDataViewSelection` hook automatically removes selections for items that ## Related Documentation -- [INDETERMINATE_CHECKBOX.md](./INDETERMINATE_CHECKBOX.md) - Details on the indeterminate checkbox pattern - [ConsoleDataView](./ConsoleDataView.tsx) - Main data view component - [dataViewSelectionHelpers.ts](./dataViewSelectionHelpers.ts) - Selection helper functions +- [useDataViewSelection.ts](./useDataViewSelection.ts) - Selection state management hook ## Real-World Examples diff --git a/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx b/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx index c1d7ae76397..87ee5c74dd6 100644 --- a/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx +++ b/frontend/packages/console-app/src/components/data-view/ConsoleDataView.tsx @@ -37,7 +37,6 @@ import { DataViewLabelFilter } from './DataViewLabelFilter'; import { DataViewTextFilter } from './DataViewTextFilter'; import { useConsoleDataViewData } from './useConsoleDataViewData'; import { useConsoleDataViewFilters } from './useConsoleDataViewFilters'; -import { useIndeterminateCheckbox } from './useIndeterminateCheckbox'; export const initialFiltersDefault: ResourceFilters = { name: '', label: '' }; @@ -208,11 +207,6 @@ export const ConsoleDataView = < return { show: shouldShow, allSelected, isIndeterminate }; }, [selection, loaded, filteredData, visibleItems]); - // Set indeterminate state on the select-all checkbox via DOM manipulation - // This is a workaround until PatternFly adds native support for isSelected: null - // See: https://github.com/patternfly/patternfly-react/issues/12404 - useIndeterminateCheckbox(bannerState.isIndeterminate); - const handleSelectAllMatching = useCallback(() => { if (selection?.onSelectAll) { selection.onSelectAll(true, filteredData); @@ -225,6 +219,19 @@ export const ConsoleDataView = < } }, [selection, filteredData]); + // Set indeterminate state via DOM manipulation since PatternFly's controlled prop + // causes React controlled/uncontrolled warnings when toggling + useEffect(() => { + if (selection && loaded && filteredData.length > 0) { + const checkbox = document.querySelector( + '[data-label=""] input[type="checkbox"]', + ) as HTMLInputElement; + if (checkbox) { + checkbox.indeterminate = bannerState.isIndeterminate; + } + } + }, [selection, loaded, filteredData.length, bannerState.isIndeterminate]); + const dataViewFilterNodes = useMemo(() => { const basicFilters: ReactNode[] = []; diff --git a/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md b/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md deleted file mode 100644 index 7227cc7d334..00000000000 --- a/frontend/packages/console-app/src/components/data-view/INDETERMINATE_CHECKBOX.md +++ /dev/null @@ -1,95 +0,0 @@ -# Indeterminate Checkbox Implementation - -This directory contains a workaround to add indeterminate checkbox support to PatternFly DataView tables until native support is added. - -## Problem - -PatternFly v6's Table component does not support indeterminate state for the select-all checkbox in the header. The `ThSelectType` interface only supports `isSelected: boolean`, which means: -- When **all** items are selected → checkbox is checked ✓ -- When **some** items are selected → checkbox is unchecked (looks like none selected) -- When **no** items are selected → checkbox is unchecked - -This creates poor UX because users can't tell if some items are selected vs none selected. - -## Solution - -We've implemented a hook-based workaround that sets the indeterminate state via DOM manipulation after PatternFly renders the checkbox. - -### Files Modified - -1. **`useIndeterminateCheckbox.ts`** - New hook that finds and updates the checkbox DOM element -2. **`ConsoleDataView.tsx`** - Calculates indeterminate state and uses the hook -3. **`useConsoleDataViewData.tsx`** - Handles select-all logic for visible items - -### How It Works - -1. **Selection state calculation** (`ConsoleDataView.tsx`): - - ```typescript - const visibleSelectedCount = visibleItems.filter((item) => - selection.selectedItems.has(selection.getItemId(item)), - ).length; - const isIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < visibleCount; - ``` - -2. **Hook invocation** (`ConsoleDataView.tsx`): - - ```typescript - useIndeterminateCheckbox(bannerState.isIndeterminate); - ``` - -3. **DOM manipulation** (`useIndeterminateCheckbox.ts`): - - ```typescript - const checkbox = document.querySelector('th.pf-v6-c-table__check input[name="check-all"]'); - if (checkbox) { - checkbox.indeterminate = isIndeterminate; - } - ``` - -### Behavior - -The checkbox state is based on the **current page/visible items** only: - -- **Unchecked** (`isSelected: false`) - No visible items selected - - **Clicking**: Selects all visible items on current page -- **Indeterminate** (`isSelected: null`) - Some (but not all) visible items on current page selected - - **Clicking**: Selects all remaining visible items on current page (completes page selection) -- **Checked** (`isSelected: true`) - All visible items on current page selected - - **Clicking**: Deselects all visible items on current page - -### Selection Logic - -1. **Checkbox states** are determined by visible items only (not all filtered items): - - **Checked** (`true`): All items on the current page are selected - - **Indeterminate** (`null`): Some (but not all) items on the current page are selected - - **Unchecked** (`false`): No items on the current page are selected - -2. **Click behavior**: - - Unchecked → Click → Select all visible items (on current page) - - Indeterminate → Click → Select all visible items (completes the page selection) - - Checked → Click → Deselect all visible items (on current page) - -3. **Banner behavior** (separate from checkbox): - - When all visible items are selected AND there are more pages, a banner appears - - The banner shows two states: - - "All X items on this page are selected. [Select all Y matching items]" - - "All X matching items are selected. [Unselect all]" - -## Testing - -To test the indeterminate state: - -1. Navigate to Compute → Nodes (or any page with a selectable DataView table) -2. Select **no items** → header checkbox should be unchecked -3. Select **some items** (not all visible) → header checkbox should show dash/minus (indeterminate) -4. Select **all visible items** → header checkbox should be checked -5. With all visible items selected and multiple pages, verify banner appears -6. Click "Select all X matching items" in banner → all items across all pages selected -7. Verify banner changes to "All X matching items are selected. [Unselect all]" - -## Notes - -- Uses PatternFly's native `isSelected: boolean | null` support (not DOM manipulation) -- The indeterminate state is scoped to visible items on the current page only -- Selection state is recalculated in `dataViewColumnsWithSortApplied` to use visible items diff --git a/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts index 83a71b9fdce..8085a173a38 100644 --- a/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts +++ b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts @@ -73,7 +73,8 @@ export const createSelectionCell = ({ onSelect: (_event: any, isSelecting: boolean) => { onSelect(itemId, isSelecting); }, - isSelected, + // Ensure isSelected is always a boolean to prevent controlled/uncontrolled warnings + isSelected: Boolean(isSelected), isDisabled: disabled, }, }, diff --git a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx index e8052acea43..3a08e1b8865 100644 --- a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx +++ b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewData.tsx @@ -94,12 +94,8 @@ export const useConsoleDataViewData = < }); const dataViewColumns = useMemo[]>(() => { - // Calculate selection state across all filtered items (for indeterminate state) - const selectedCount = selection - ? filteredData.filter((item) => selection.selectedItems.has(selection.getItemId(item))).length - : 0; + // Calculate selection state across all filtered items const totalCount = filteredData.length; - const someSelected = selectedCount > 0 && selectedCount < totalCount; return activeColumns.map(({ id, title, sort, props, resizableProps }, index) => { // Filter out custom Console props that aren't valid PatternFly ThProps @@ -133,11 +129,8 @@ export const useConsoleDataViewData = < }, isSelected: false, // Will be updated based on visible items isDisabled: totalCount === 0, - // Pass indeterminate state through props (custom extension until PF supports it) - // See: https://github.com/patternfly/patternfly-react/issues/12404 - props: { - isIndeterminate: someSelected, - }, + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView to avoid + // React controlled/uncontrolled warnings when the prop value changes }; } @@ -230,14 +223,11 @@ export const useConsoleDataViewData = < }, select: { ...column.props.select, - // Checkbox is checked only when ALL visible items are selected - // Indeterminate state is handled via DOM manipulation in ConsoleDataView - isSelected: allVisibleSelected, onSelect: (_event: any, isSelecting: boolean) => { - // When unchecked or indeterminate, clicking selects all visible items - // When checked, clicking deselects all visible items selection.onSelectAll(isSelecting, visibleItems); }, + isSelected: Boolean(allVisibleSelected), + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView }, }, }; @@ -273,14 +263,11 @@ export const useConsoleDataViewData = < ...column.props, select: { ...column.props.select, - // Checkbox is checked only when ALL visible items are selected - // Indeterminate state is handled via DOM manipulation in ConsoleDataView - isSelected: allVisibleSelected, onSelect: (_event: any, isSelecting: boolean) => { - // When unchecked or indeterminate, clicking selects all visible items - // When checked, clicking deselects all visible items selection.onSelectAll(isSelecting, visibleItems); }, + isSelected: Boolean(allVisibleSelected), + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView }, }, }; diff --git a/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts b/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts deleted file mode 100644 index 61d90e5e81f..00000000000 --- a/frontend/packages/console-app/src/components/data-view/useIndeterminateCheckbox.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Hook to set indeterminate state on select-all checkbox in DataView table. - * This is a workaround until PatternFly adds native support for indeterminate state. - * See: https://github.com/patternfly/patternfly-react/issues/12404 - * - * When the checkbox is clicked while in indeterminate state, it should: - * 1. Clear the indeterminate state - * 2. Become checked (selecting all visible items) - * - * This happens automatically because: - * - When indeterminate, isSelected prop is false - * - Clicking passes isSelecting=true to the handler - * - Handler selects all visible items - * - On next render, all visible items are selected so isSelected becomes true - * - * @param isIndeterminate - Whether the checkbox should be in indeterminate state - */ -export const useIndeterminateCheckbox = (isIndeterminate: boolean) => { - useEffect(() => { - // Find the select-all checkbox in the table header - // PatternFly DataView uses: th.pf-v6-c-table__check input[type="checkbox"][name="check-all"] - const checkbox = document.querySelector( - 'th.pf-v6-c-table__check input[type="checkbox"][name="check-all"]', - ); - - if (checkbox) { - // Set indeterminate state via DOM property - // Note: indeterminate doesn't change the checked state, it's a separate visual state - // When clicked, the checkbox will toggle based on the checked property - checkbox.indeterminate = isIndeterminate; - } - }, [isIndeterminate]); -}; diff --git a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index 8476b2a1cfc..34c2e970e83 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -665,9 +665,7 @@ const NodeList: FC = ({ }) => { const { t } = useTranslation(); const { columns, resetAllColumnWidths } = useNodesColumns(vmsEnabled, nodeMgmtV1Enabled); - const nodeMetrics = useConsoleSelector(({ UI }) => { - return UI.getIn(['metrics', 'node']); - }); + const nodeMetrics = useConsoleSelector(({ UI }) => UI.getIn(['metrics', 'node'])); const columnManagementID = referenceForModel(NodeModel); const statusExtensions = useNodeStatusExtensions(); @@ -692,6 +690,21 @@ const NodeList: FC = ({ onComplete: clearSelection, }); + const getDataViewRows = useCallback( + (rowData: any, tableColumns: any) => + getNodeDataViewRows( + (rowData as unknown) as RowProps[], + tableColumns, + nodeMetrics, + statusExtensions, + { + selectedItems: selectedIds, + onSelect: onSelectItem, + }, + ), + [nodeMetrics, statusExtensions, selectedIds, onSelectItem], + ); + const columnLayout = useMemo( () => ({ id: columnManagementID, @@ -904,18 +917,7 @@ const NodeList: FC = ({ initialFilters={initialFilters} additionalFilterNodes={additionalFilterNodes} matchesAdditionalFilters={matchesAdditionalFilters} - getDataViewRows={(rowData, tableColumns) => - getNodeDataViewRows( - (rowData as unknown) as RowProps[], - tableColumns, - nodeMetrics, - statusExtensions, - { - selectedItems: selectedIds, - onSelect: onSelectItem, - }, - ) - } + getDataViewRows={getDataViewRows} hideNameLabelFilters={hideNameLabelFilters} hideLabelFilter={hideLabelFilter} hideColumnManagement={hideColumnManagement} @@ -1035,7 +1037,7 @@ export const NodesPage: FC = ({ selector }) => { filterVirtualMachineInstancesByNode(vmis, node.metadata.name), ]), ); - }, [isKubevirtPluginActive, nodes, nodesLoadError, nodesLoaded, vmis, vmisLoadError, vmisLoaded]); + }, [isKubevirtPluginActive, nodes, nodesLoaded, nodesLoadError, vmis, vmisLoaded, vmisLoadError]); useEffect(() => { const updateMetrics = async () => { diff --git a/frontend/packages/console-shared/src/utils/icon-utils.ts b/frontend/packages/console-shared/src/utils/icon-utils.ts index 3535e748c70..1b1dec359d5 100644 --- a/frontend/packages/console-shared/src/utils/icon-utils.ts +++ b/frontend/packages/console-shared/src/utils/icon-utils.ts @@ -1,4 +1,4 @@ -import type { IconDefinition } from '@patternfly/react-icons/dist/esm/createIcon'; +import type { IconDefinition, IconData } from '@patternfly/react-icons/dist/esm/createIcon'; const ICON_OPERATOR = 'icon-operator'; export type CSVIcon = { base64data: string; mediatype: string }; @@ -10,11 +10,22 @@ export const getDefaultOperatorIcon = () => ICON_OPERATOR; /** * Modified from PF createIcon, returns a string with the SVG element instead of a React component. + * Supports both old IconDefinition format and new IconConfig format with nested icon property. */ export const getSvgFromPfIconConfig = ( - { xOffset = 0, yOffset = 0, width, height, svgPath }: IconDefinition, + iconConfig: IconDefinition | { icon: IconData; [key: string]: any }, className?: string, ): string => { + // Handle new format where icon data is nested under 'icon' property (IconData type) + const iconDef: IconDefinition | IconData = 'icon' in iconConfig ? iconConfig.icon : iconConfig; + const { xOffset = 0, yOffset = 0, width, height } = iconDef; + // IconDefinition uses 'svgPath', IconData uses 'svgPathData' + const pathData = + 'svgPath' in iconDef && iconDef.svgPath + ? iconDef.svgPath + : 'svgPathData' in iconDef + ? iconDef.svgPathData + : ''; const viewBox = [xOffset, yOffset, width, height].join(' '); return ` @@ -25,6 +36,6 @@ export const getSvgFromPfIconConfig = ( width="1em" height="1em" > - + `; }; diff --git a/frontend/public/components/masthead/masthead-toolbar.tsx b/frontend/public/components/masthead/masthead-toolbar.tsx index 58f1ced8b25..ba3fa017ca1 100644 --- a/frontend/public/components/masthead/masthead-toolbar.tsx +++ b/frontend/public/components/masthead/masthead-toolbar.tsx @@ -775,8 +775,6 @@ const MastheadToolbarContents: FC = ({ = ({ Date: Mon, 11 May 2026 14:57:16 -0400 Subject: [PATCH 3/5] CONSOLE-5091: Align bulk and individual node scheduling actions Refactor node scheduling actions to share logic between bulk operations and individual node actions, and improve UX consistency. Changes: - Extract shared scheduling logic into nodeSchedulingActions.ts module - markNodesSchedulable/markNodesUnschedulable accept single node or array - getSchedulingCounts helper for counting schedulable vs unschedulable nodes - Update ConfigureUnschedulableModal to support both single and bulk operations - Show count and plural messaging for bulk operations - Add onComplete callback support - Update individual node menu action to use shared markNodesSchedulable - Update bulk actions dropdown to use shared logic and modal - Delete useNodeActions.tsx (replaced by shared logic) - Remove unused i18n keys for per-action error messages This unifies the scheduling action implementation so both individual and bulk operations use the same underlying functions and modal UI. Co-Authored-By: Claude Sonnet 4.5 --- .../console-app/locales/en/console-app.json | 6 +- .../src/components/nodes/menu-actions.tsx | 4 +- .../modals/ConfigureUnschedulableModal.tsx | 72 +++++++++-- .../components/nodes/nodeSchedulingActions.ts | 60 +++++++++ .../components/nodes/useCustomNodeActions.tsx | 71 +++-------- .../src/components/nodes/useNodeActions.tsx | 117 ------------------ 6 files changed, 138 insertions(+), 192 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/nodeSchedulingActions.ts delete mode 100644 frontend/packages/console-app/src/components/nodes/useNodeActions.tsx diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 862e3c459b1..a671ad81246 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -355,6 +355,8 @@ "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.", "Mark as schedulable": "Mark as schedulable", "Mark as unschedulable": "Mark as unschedulable", + "Mark <1>{{count}} selected nodes as unschedulable?": "Mark <1>{{count}} selected nodes as unschedulable?", + "Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate nodes to perform maintenance or decommission them without disrupting new traffic.": "Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate nodes to perform maintenance or decommission them without disrupting new traffic.", "Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate a node to perform maintenance or decommission it without disrupting new traffic.": "Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate a node to perform maintenance or decommission it without disrupting new traffic.", "Mark unschedulable": "Mark unschedulable", "Error updating {{nodeName}}": "Error updating {{nodeName}}", @@ -465,14 +467,10 @@ "Certificate approval required": "Certificate approval required", "An error occurred. Please try again": "An error occurred. Please try again", "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.", - "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as schedulable", - "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable": "Failed to mark {{failureCount}} of {{totalCount}} nodes as unschedulable", "Applies to {{nodeCount}} selected node(s) that are currently unschedulable.": "Applies to {{nodeCount}} selected node(s) that are currently unschedulable.", "Mark schedulable": "Mark schedulable", "Applies to {{nodeCount}} selected node(s) that are currently schedulable.": "Applies to {{nodeCount}} selected node(s) that are currently schedulable.", "Scheduling": "Scheduling", - "Mark as schedulable ({{nodeCount}})": "Mark as schedulable ({{nodeCount}})", - "Mark as unschedulable ({{nodeCount}})": "Mark as unschedulable ({{nodeCount}})", "Identity providers": "Identity providers", "Mapping method": "Mapping method", "Remove identity provider": "Remove identity provider", diff --git a/frontend/packages/console-app/src/components/nodes/menu-actions.tsx b/frontend/packages/console-app/src/components/nodes/menu-actions.tsx index 3f51abca933..8086f391957 100644 --- a/frontend/packages/console-app/src/components/nodes/menu-actions.tsx +++ b/frontend/packages/console-app/src/components/nodes/menu-actions.tsx @@ -14,8 +14,8 @@ import type { } from '@console/internal/module/k8s'; import { referenceFor } from '@console/internal/module/k8s'; import { isNodeUnschedulable } from '@console/shared/src/selectors/node'; -import { makeNodeSchedulable } from '../../k8s/requests/nodes'; import { LazyConfigureUnschedulableModalOverlay } from './modals'; +import { markNodesSchedulable } from './nodeSchedulingActions'; const updateCSR = (csr: CertificateSigningRequestKind, type: 'Approved' | 'Denied') => { const approvedCSR = { @@ -66,7 +66,7 @@ export const useNodeActions: ExtensionHook = (obj) => { actions.push({ id: 'mark-as-schedulable', label: t('console-app~Mark as schedulable'), - cta: () => makeNodeSchedulable(obj), + cta: () => markNodesSchedulable(obj), accessReview: asAccessReview(kindObj, obj, 'patch'), }); } else { diff --git a/frontend/packages/console-app/src/components/nodes/modals/ConfigureUnschedulableModal.tsx b/frontend/packages/console-app/src/components/nodes/modals/ConfigureUnschedulableModal.tsx index ff06e0c9113..1351df6cf4f 100644 --- a/frontend/packages/console-app/src/components/nodes/modals/ConfigureUnschedulableModal.tsx +++ b/frontend/packages/console-app/src/components/nodes/modals/ConfigureUnschedulableModal.tsx @@ -1,32 +1,64 @@ import type { FC } from 'react'; -import { useState } from 'react'; -import { Button, Modal, ModalBody, ModalHeader, ModalVariant } from '@patternfly/react-core'; -import { useTranslation } from 'react-i18next'; +import { useState, useMemo } from 'react'; +import { + Button, + Content, + ContentVariants, + Modal, + ModalBody, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core'; +import { Trans, useTranslation } from 'react-i18next'; import type { OverlayComponent } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider'; import type { NodeKind } from '@console/internal/module/k8s'; import { ModalFooterWithAlerts } from '@console/shared/src/components/modals/ModalFooterWithAlerts'; import { usePromiseHandler } from '@console/shared/src/hooks/usePromiseHandler'; import type { ModalComponentProps } from '@console/shared/src/types/modal'; -import { makeNodeUnschedulable } from '../../../k8s/requests/nodes'; +import { markNodesUnschedulable } from '../nodeSchedulingActions'; type ConfigureUnschedulableModalProps = { - resource: NodeKind; + /** Single node or array of nodes to mark as unschedulable */ + resource?: NodeKind; + /** Array of nodes to mark as unschedulable (for bulk operations) */ + nodes?: NodeKind[]; + /** Callback invoked after successful operation */ + onComplete?: () => void; } & ModalComponentProps; const ConfigureUnschedulableModal: FC = ({ resource, + nodes, + onComplete, close, cancel, }) => { const [handlePromise, inProgress, errorMessage] = usePromiseHandler(); + const { t } = useTranslation(); + + // Support both single node (resource) and multiple nodes (nodes array) + const targetNodes = useMemo(() => { + if (nodes) { + return nodes; + } + if (resource) { + return [resource]; + } + return []; + }, [resource, nodes]); + + const isBulk = targetNodes.length > 1; const handleSubmit = (): void => { - handlePromise(makeNodeUnschedulable(resource)) - .then(() => close()) + handlePromise(markNodesUnschedulable(targetNodes)) + .then(() => { + onComplete?.(); + close(); + }) // Errors are surfaced by usePromiseHandler/ModalFooterWithAlerts .catch(() => {}); }; - const { t } = useTranslation(); + return ( <> = ({ labelId="configure-unschedulable-modal-title" /> -

- {t( - "console-app~Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate a node to perform maintenance or decommission it without disrupting new traffic.", - )} -

+ {isBulk && ( + + + Mark {{ count: targetNodes.length }} selected nodes as unschedulable? + + + )} + + {isBulk + ? t( + "console-app~Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate nodes to perform maintenance or decommission them without disrupting new traffic.", + ) + : t( + "console-app~Unschedulable nodes won't accept new pods. By blocking new pod assignments, you can isolate a node to perform maintenance or decommission it without disrupting new traffic.", + )} +