diff --git a/frontend/package.json b/frontend/package.json index f81faeb9b0d..ea2af27862d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -159,14 +159,14 @@ "@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.21", "@patternfly/react-core": "~6.4.2", "@patternfly/react-data-view": "~6.4.0-prerelease.12", "@patternfly/react-drag-drop": "~6.5.0-prerelease.38", "@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", @@ -324,6 +324,12 @@ "glob-parent": "^5.1.2", "hosted-git-info": "^3.0.8", "lodash-es": "^4.17.23", + "@patternfly/react-component-groups": "6.4.0-prerelease.21", + "@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/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index ce594b3c6a8..06542b6a31c 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}} nodes as unschedulable?": "Mark <1>{{count}} 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,6 +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.", + "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", "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..63e2b82b8b3 --- /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 + +- [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 + +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..87ee5c74dd6 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, @@ -80,6 +87,10 @@ export const ConsoleDataView = < mock, isResizable, resetAllColumnWidths, + additionalActions, + customActions, + selection, + actionsBreakpoint = 'md', }: ConsoleDataViewProps) => { const { t } = useTranslation(); const launchModal = useOverlay(); @@ -100,7 +111,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 +140,7 @@ export const ConsoleDataView = < columnManagementID, customRowData, isResizable, + selection, }); const bodyLoading = useMemo(() => , [ @@ -139,14 +167,71 @@ 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]); + + const handleSelectAllMatching = useCallback(() => { + if (selection?.onSelectAll) { + selection.onSelectAll(true, filteredData); + } + }, [selection, filteredData]); + + const handleUnselectAll = useCallback(() => { + if (selection?.onSelectAll) { + selection.onSelectAll(false, filteredData); + } + }, [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[] = []; @@ -199,44 +284,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/dataViewSelectionHelpers.ts b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts new file mode 100644 index 00000000000..8085a173a38 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/dataViewSelectionHelpers.ts @@ -0,0 +1,81 @@ +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); + }, + // 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 3fcf269d1a4..3a08e1b8865 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,61 @@ 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 + const totalCount = filteredData.length; - 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, + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView to avoid + // React controlled/uncontrolled warnings when the prop value changes + }; + } + + 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 +186,102 @@ 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, + onSelect: (_event: any, isSelecting: boolean) => { + selection.onSelectAll(isSelecting, visibleItems); }, + isSelected: Boolean(allVisibleSelected), + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView }, - } - : 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, + onSelect: (_event: any, isSelecting: boolean) => { + selection.onSelectAll(isSelecting, visibleItems); + }, + isSelected: Boolean(allVisibleSelected), + // NOTE: isIndeterminate is set via DOM manipulation in ConsoleDataView + }, + }, + }; + } + + 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/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index 37fcba8096b..34c2e970e83 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, }; }); }); @@ -637,21 +665,57 @@ 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(); + // 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 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, 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[]) @@ -853,19 +917,20 @@ const NodeList: FC = ({ initialFilters={initialFilters} additionalFilterNodes={additionalFilterNodes} matchesAdditionalFilters={matchesAdditionalFilters} - getDataViewRows={(rowData, tableColumns) => - getNodeDataViewRows( - (rowData as unknown) as RowProps[], - tableColumns, - nodeMetrics, - statusExtensions, - ) - } + getDataViewRows={getDataViewRows} hideNameLabelFilters={hideNameLabelFilters} hideLabelFilter={hideLabelFilter} hideColumnManagement={hideColumnManagement} isResizable resetAllColumnWidths={resetAllColumnWidths} + customActions={customActions} + selection={{ + selectedItems: selectedIds, + onSelect: onSelectItem, + onSelectAll, + getItemId: getUID, + onFilteredSelectionChange: handleFilteredSelectionChange, + }} /> ); @@ -972,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-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..2af2d5508a4 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,70 @@ 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 { isNodeUnschedulable } from '@console/shared/src/selectors/node'; 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]); + + // Filter nodes that will actually be affected (not already unschedulable) + const nodesToMark = useMemo(() => targetNodes.filter((node) => !isNodeUnschedulable(node)), [ + targetNodes, + ]); + + 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: nodesToMark.length }} 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.", + )} +