-
Notifications
You must be signed in to change notification settings - Fork 705
CONSOLE-5091: Add bulk selection and schedulable actions to Nodes page #16203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -365,6 +365,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}}</1> nodes as unschedulable?": "Mark <1>{{count}}</1> 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}}", | ||
|
|
@@ -475,6 +477,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 nodes that are currently unschedulable.": "Applies to {{nodeCount}} selected nodes that are currently unschedulable.", | ||
| "Mark schedulable": "Mark schedulable", | ||
| "Applies to {{nodeCount}} selected nodes that are currently schedulable.": "Applies to {{nodeCount}} selected nodes that are currently schedulable.", | ||
| "Scheduling": "Scheduling", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 483: Confirming "Scheduling" intended as section heading/column header. if so, fine.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "Identity providers": "Identity providers", | ||
| "Mapping method": "Mapping method", | ||
| "Remove identity provider": "Remove identity provider", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TData, TCustomRowData, TFilters>) => { | ||
| 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 -- the rule cannot statically verify optional chaining on `selection`; listing the full object would cause re-runs on every render | ||
| }, [ | ||
| 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(() => <BodyLoading columns={dataViewColumns.length} />, [ | ||
|
|
@@ -136,17 +164,52 @@ export const ConsoleDataView = < | |
|
|
||
| const paginationTitles = useMemo( | ||
| () => ({ | ||
| paginationAriaLabel: t('public~Pagination'), | ||
| ofWord: t('public~of'), | ||
| itemsPerPage: t('public~Items per page'), | ||
| perPageSuffix: t('public~per page'), | ||
| toFirstPageAriaLabel: t('public~Go to first page'), | ||
| optionsToggleAriaLabel: t('public~Items per page'), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. change from "Items per page" to "Select items per page"? active verb phrase is clearer, more actionable instruction for screen-readers.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is for pagination and not selection. Was missing before. |
||
| toPreviousPageAriaLabel: t('public~Go to previous page'), | ||
| toNextPageAriaLabel: t('public~Go to next page'), | ||
| toLastPageAriaLabel: t('public~Go to last page'), | ||
| }), | ||
| [t], | ||
| ); | ||
|
|
||
| // Calculate whether to show the "select all" banner | ||
| const bannerState = useMemo(() => { | ||
| if (!selection || !loaded || filteredData.length === 0) { | ||
| return { show: false, allSelected: false }; | ||
| } | ||
|
|
||
| const allVisibleSelected = visibleItems.every((item) => | ||
| selection.selectedItems.has(selection.getItemId(item)), | ||
| ); | ||
|
|
||
| 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; | ||
| const allSelected = selectedCount === totalCount; | ||
|
|
||
| return { show: shouldShow, allSelected }; | ||
| }, [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]); | ||
|
|
||
| const dataViewFilterNodes = useMemo<React.ReactNode[]>(() => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. aria-label={t('public~Column management')} being removed here along w/old code block, yes? confirm that screen-reader label moved to new layout further down/elsewhere so we don't drop accessibility coverage for col management feature.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was just moved further down the file. |
||
| const basicFilters: ReactNode[] = []; | ||
|
|
||
|
|
@@ -199,44 +262,93 @@ export const ConsoleDataView = < | |
| } | ||
| clearAllFilters={clearAllFilters} | ||
| actions={ | ||
| <ResponsiveActions breakpoint="lg"> | ||
| {!hideColumnManagement && ( | ||
| <ResponsiveAction | ||
| isPersistent | ||
| variant="plain" | ||
| onClick={() => | ||
| launchModal(LazyColumnManagementModalOverlay, { | ||
| columnLayout, | ||
| noLimit: true, | ||
| }) | ||
| } | ||
| aria-label={t('public~Column management')} | ||
| data-test="manage-columns" | ||
| > | ||
| <Tooltip content={t('public~Manage columns')} trigger="mouseenter"> | ||
| <ColumnsIcon /> | ||
| </Tooltip> | ||
| </ResponsiveAction> | ||
| )} | ||
| {isResizable && resetAllColumnWidths && ( | ||
| <ResponsiveAction | ||
| isPersistent | ||
| variant="plain" | ||
| onClick={handleResetColumnWidths} | ||
| aria-label={t('public~Reset column widths')} | ||
| data-test="reset-column-widths" | ||
| > | ||
| <Tooltip content={t('public~Reset column widths')} trigger="mouseenter"> | ||
| <UndoIcon /> | ||
| </Tooltip> | ||
| </ResponsiveAction> | ||
| )} | ||
| </ResponsiveActions> | ||
| <> | ||
| <ResponsiveActions breakpoint={actionsBreakpoint}> | ||
| {!hideColumnManagement && ( | ||
| <ResponsiveAction | ||
| isPersistent | ||
| variant="plain" | ||
| onClick={() => | ||
| launchModal(LazyColumnManagementModalOverlay, { | ||
| columnLayout, | ||
| noLimit: true, | ||
| }) | ||
| } | ||
| aria-label={t('public~Column management')} | ||
| data-test="manage-columns" | ||
| > | ||
| <Tooltip content={t('public~Manage columns')} trigger="mouseenter"> | ||
| <ColumnsIcon /> | ||
| </Tooltip> | ||
| </ResponsiveAction> | ||
| )} | ||
| {isResizable && resetAllColumnWidths && ( | ||
| <ResponsiveAction | ||
| isPersistent | ||
| variant="plain" | ||
| onClick={handleResetColumnWidths} | ||
| aria-label={t('public~Reset column widths')} | ||
| data-test="reset-column-widths" | ||
| > | ||
| <Tooltip content={t('public~Reset column widths')} trigger="mouseenter"> | ||
| <UndoIcon /> | ||
| </Tooltip> | ||
| </ResponsiveAction> | ||
| )} | ||
| {additionalActions} | ||
| </ResponsiveActions> | ||
| {customActions} | ||
| </> | ||
| } | ||
| pagination={ | ||
| <Pagination itemCount={filteredData.length} titles={paginationTitles} {...pagination} /> | ||
| <Pagination | ||
| itemCount={filteredData.length} | ||
| titles={paginationTitles} | ||
| variant={PaginationVariant.top} | ||
| isCompact | ||
| {...pagination} | ||
| /> | ||
| } | ||
| /> | ||
| {bannerState.show && ( | ||
| <Banner | ||
| className="pf-v6-u-mb-md" | ||
| screenReaderText={ | ||
| bannerState.allSelected | ||
| ? t('public~You selected all {{count}} {{label}}.', { | ||
| count: filteredData.length, | ||
| label: label || t('public~items'), | ||
| }) | ||
| : t('public~You selected all {{label}} on this page.', { | ||
| label: label || t('public~items'), | ||
| }) | ||
| } | ||
| > | ||
| {bannerState.allSelected ? ( | ||
| <> | ||
| <Trans ns="public" i18nKey="You selected all <1>{{count}}</1> {{label}}."> | ||
| You selected all <strong>{{ count: filteredData.length }}</strong>{' '} | ||
| {{ label: label || t('public~items') }}. | ||
| </Trans>{' '} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 330-333 AND 342-346: update translation strings to support conditional pluralization keys instead of hard-coding plural structure? as you know, if selection count equals 1, UI will display grammatically incorrect strings like "All 1 matching items are selected." |
||
| <Button variant="link" isInline onClick={handleUnselectAll}> | ||
| {t('public~Clear all.')} | ||
| </Button> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <Trans ns="public" i18nKey="You selected all {{label}} on this page."> | ||
| You selected all {{ label: label || t('public~items') }} on this page. | ||
| </Trans>{' '} | ||
| <Button variant="link" isInline onClick={handleSelectAllMatching}> | ||
| <Trans ns="public" i18nKey="Select all <1>{{count}}</1> {{label}}."> | ||
| Select all <strong>{{ count: filteredData.length }}</strong>{' '} | ||
| {{ label: label || t('public~items') }}. | ||
| </Trans> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 347-351: add period to end of text string inside i18nKey so it reads "Select all <1>{{count}}</1> matching {{label}}." + make sure it handles singular/plural states. |
||
| </Button> | ||
| </> | ||
| )} | ||
| </Banner> | ||
| )} | ||
| <InnerScrollContainer> | ||
| <DataViewTable | ||
| key={tableKey} | ||
|
|
@@ -255,29 +367,53 @@ export const ConsoleDataView = < | |
| itemCount={filteredData.length} | ||
| titles={paginationTitles} | ||
| variant={PaginationVariant.bottom} | ||
| isCompact | ||
| {...pagination} | ||
| /> | ||
| </DataView> | ||
| </StatusBox> | ||
| ); | ||
| }; | ||
|
|
||
| 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, | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
368: question mark inconsistent w/other action labels here. remove for consistency?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is in the context of the modal, so it probably makes sense to keep it.