From fae78e5bebb9978180a637d3e245b108a04528c4 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Mon, 24 Mar 2025 13:19:27 +0100 Subject: [PATCH 1/9] CONSOLE-4529: PodList using DataView PF extension --- frontend/package.json | 3 + .../data-view-poc/DataViewPodList.tsx | 801 ++++++++++++++++++ frontend/public/components/pod.tsx | 92 +- frontend/tsconfig.json | 3 +- frontend/yarn.lock | 14 +- 5 files changed, 880 insertions(+), 33 deletions(-) create mode 100644 frontend/public/components/data-view-poc/DataViewPodList.tsx diff --git a/frontend/package.json b/frontend/package.json index 31c4c4db001..cb2d10e1534 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -143,6 +143,7 @@ "@patternfly/react-component-groups": "6.2.0-prerelease.10", "@patternfly/react-console": "^6.0.0", "@patternfly/react-core": "^6.2.2", + "@patternfly/react-data-view": "^6.2.0", "@patternfly/react-icons": "^6.2.2", "@patternfly/react-log-viewer": "^6.1.0", "@patternfly/react-styles": "^6.2.2", @@ -327,6 +328,8 @@ "ua-parser-js": "^0.7.24", "glob-parent": "^5.1.2", "postcss": "^8.2.13", + "@patternfly/react-component-groups": "6.2.0-prerelease.10", + "@patternfly/react-data-view": "^6.2.0", "async": "^3.2.5" }, "lint-staged": { diff --git a/frontend/public/components/data-view-poc/DataViewPodList.tsx b/frontend/public/components/data-view-poc/DataViewPodList.tsx new file mode 100644 index 00000000000..79ece8f484f --- /dev/null +++ b/frontend/public/components/data-view-poc/DataViewPodList.tsx @@ -0,0 +1,801 @@ +/* eslint-disable no-console */ +import * as React from 'react'; +import { + DataView, + DataViewCheckboxFilter, + DataViewState, + DataViewTable, + DataViewTd, + DataViewTextFilter, + DataViewTh, + DataViewToolbar, + useDataViewFilters, + useDataViewPagination, +} from '@patternfly/react-data-view'; +import { sortable, SortByDirection, Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; +import { useNavigate, useSearchParams } from 'react-router-dom-v5-compat'; +import { + PodKind, + podPhase, + podReadiness, + podRestarts, + referenceFor, + referenceForModel, + RowProps, + TableColumn, +} from '../../module/k8s'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../redux'; +import * as classNames from 'classnames'; +import { PROMETHEUS_BASE_PATH, PROMETHEUS_TENANCY_BASE_PATH } from '../graphs'; +import { + formatBytesAsMiB, + formatCores, + Kebab, + LabelList, + OwnerReferences, + ResourceLink, + Timestamp, +} from '../utils'; +import { PodTraffic } from '../pod-traffic'; +import { LazyActionMenu, useDebounceCallback } from '@console/shared'; +import { PodStatus } from '../pod'; +import { sortResourceByValue } from '../factory/Table/sort'; +import * as UIActions from '../../actions/ui'; +import { TFunction } from 'i18next'; +import * as _ from 'lodash'; +import { Bullseye, Pagination, ToolbarFilter, Tooltip } from '@patternfly/react-core'; +import { + ResponsiveAction, + ResponsiveActions, + SkeletonTableBody, +} from '@patternfly/react-component-groups'; +import { RowFilter } from '../filter-toolbar'; +import DataViewFilters from '@patternfly/react-data-view/dist/cjs/DataViewFilters'; +import { + ColumnLayout, + OnFilterChange, +} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import useLabelSelectionFix from '../useLabelSelectionFix'; +import AutocompleteInput from '../autocomplete'; +import { ColumnsIcon } from '@patternfly/react-icons'; +import { createColumnManagementModal } from '../modals'; +import { useActiveColumns } from '../factory/Table/active-columns-hook'; +import { PodModel } from '../../models'; + +/** + * Copy paste section + * These are some setup functions and variables that are defined in the public/components/pod.tsx file + * Reason to copy this is not to introduce any introduce any potential cyclic imports + */ + +const columnManagementID = referenceForModel(PodModel); + +type PodRowData = { + showNodes?: boolean; +}; + +const showMetrics = + PROMETHEUS_BASE_PATH && PROMETHEUS_TENANCY_BASE_PATH && window.screen.width >= 1200; + +const kind = 'Pod'; + +const podColumnInfo = Object.freeze({ + name: { + classes: '', + id: 'name', + title: 'public~Name', + }, + namespace: { + classes: '', + id: 'namespace', + title: 'public~Namespace', + }, + status: { + classes: '', + id: 'status', + title: 'public~Status', + }, + ready: { + classes: classNames('pf-m-nowrap', 'pf-v6-u-w-10-on-lg', 'pf-v6-u-w-8-on-xl'), + id: 'ready', + title: 'public~Ready', + }, + restarts: { + classes: classNames('pf-m-nowrap', 'pf-v6-u-w-8-on-2xl'), + id: 'restarts', + title: 'public~Restarts', + }, + owner: { + classes: '', + id: 'owner', + title: 'public~Owner', + }, + node: { + classes: '', + id: 'node', + title: 'public~Node', + }, + memory: { + classes: classNames({ 'pf-v6-u-w-10-on-2xl': showMetrics }), + id: 'memory', + title: 'public~Memory', + }, + cpu: { + classes: classNames({ 'pf-v6-u-w-10-on-2xl': showMetrics }), + id: 'cpu', + title: 'public~CPU', + }, + created: { + classes: classNames('pf-v6-u-w-10-on-2xl'), + id: 'created', + title: 'public~Created', + }, + labels: { + classes: '', + id: 'labels', + title: 'public~Labels', + }, + ipaddress: { + classes: '', + id: 'ipaddress', + title: 'public~IP address', + }, + traffic: { + classes: '', + id: 'trafficStatus', + title: 'public~Receiving Traffic', + }, +}); + +const getColumns = (showNodes: boolean, t: TFunction): TableColumn[] => [ + { + title: t(podColumnInfo.name.title), + id: podColumnInfo.name.id, + sort: 'metadata.name', + transforms: [sortable], + props: { className: podColumnInfo.name.classes }, + }, + { + title: t(podColumnInfo.namespace.title), + id: podColumnInfo.namespace.id, + sort: 'metadata.namespace', + transforms: [sortable], + props: { className: podColumnInfo.namespace.classes }, + }, + { + title: t(podColumnInfo.status.title), + id: podColumnInfo.status.id, + sort: (data, direction) => data.sort(sortResourceByValue(direction, podPhase)), + transforms: [sortable], + props: { className: podColumnInfo.status.classes }, + }, + { + title: t(podColumnInfo.ready.title), + id: podColumnInfo.ready.id, + sort: (data, direction) => + data.sort(sortResourceByValue(direction, (obj) => podReadiness(obj).readyCount)), + transforms: [sortable], + props: { className: podColumnInfo.ready.classes }, + }, + { + title: t(podColumnInfo.restarts.title), + id: podColumnInfo.restarts.id, + sort: (data, direction) => data.sort(sortResourceByValue(direction, podRestarts)), + transforms: [sortable], + props: { className: podColumnInfo.restarts.classes }, + }, + { + title: showNodes ? t(podColumnInfo.node.title) : t(podColumnInfo.owner.title), + id: podColumnInfo.owner.id, + sort: showNodes ? 'spec.nodeName' : 'metadata.ownerReferences[0].name', + transforms: [sortable], + props: { className: podColumnInfo.owner.classes }, + }, + { + title: t(podColumnInfo.memory.title), + id: podColumnInfo.memory.id, + sort: (data, direction) => + data.sort( + sortResourceByValue(direction, (obj) => UIActions.getPodMetric(obj, 'memory')), + ), + transforms: [sortable], + props: { className: podColumnInfo.memory.classes }, + }, + { + title: t(podColumnInfo.cpu.title), + id: podColumnInfo.cpu.id, + sort: (data, direction) => + data.sort( + sortResourceByValue(direction, (obj) => UIActions.getPodMetric(obj, 'cpu')), + ), + transforms: [sortable], + props: { className: podColumnInfo.cpu.classes }, + }, + { + title: t(podColumnInfo.created.title), + id: podColumnInfo.created.id, + sort: 'metadata.creationTimestamp', + transforms: [sortable], + props: { className: podColumnInfo.created.classes }, + }, + { + title: t(podColumnInfo.node.title), + id: podColumnInfo.node.id, + sort: 'spec.nodeName', + transforms: [sortable], + props: { className: podColumnInfo.node.classes }, + additional: true, + }, + { + title: t(podColumnInfo.labels.title), + id: podColumnInfo.labels.id, + sort: 'metadata.labels', + transforms: [sortable], + props: { className: podColumnInfo.labels.classes }, + additional: true, + }, + { + title: t(podColumnInfo.ipaddress.title), + id: podColumnInfo.ipaddress.id, + sort: 'status.podIP', + transforms: [sortable], + props: { className: podColumnInfo.ipaddress.classes }, + additional: true, + }, + { + title: t(podColumnInfo.traffic.title), + id: podColumnInfo.traffic.id, + props: { className: podColumnInfo.traffic.classes }, + additional: true, + }, + { + title: '', + id: '', + props: { className: Kebab.columnClass }, + }, +]; + +/** + * End of copy paste section public/components/pod.tsx file + */ + +/** + * Maps data from RowProps[] to DataViewTd[] + */ +function useDataViewPodRow( + data: RowProps[], + columns: (DataViewTh & { id?: string })[], +) { + const { t } = useTranslation(); + // We have to iterate over the redux state as the dw rows are not separate entities. + // The data is referenced by indexes + // In a finished implementation, the data should combined within the state to avoid extra iteration + const allCores = useSelector(({ UI }) => { + return data.map(({ obj: pod }) => { + const { name, namespace } = pod.metadata; + const metrics = UI.getIn(['metrics', 'pod']); + return [metrics?.cpu?.[namespace]?.[name], metrics?.memory?.[namespace]?.[name]]; + }); + }); + // Ideally, this result should be memoized + return data.map(({ obj: pod, rowData: { showNodes } }, index) => { + const { name, namespace, creationTimestamp, labels } = pod.metadata; + const [cores, bytes] = allCores[index]; + const { readyCount, totalContainers } = podReadiness(pod); + const phase = podPhase(pod); + const restarts = podRestarts(pod); + const resourceKind = referenceFor(pod); + const context = { [resourceKind]: pod }; + const rowCells = { + [podColumnInfo.name.id]: { + id: podColumnInfo.name.id, + props: { className: podColumnInfo.name.classes }, + cell: , + }, + [podColumnInfo.namespace.id]: { + id: podColumnInfo.namespace.id, + props: { className: classNames(podColumnInfo.namespace.classes, 'co-break-word') }, + cell: , + }, + [podColumnInfo.status.id]: { + id: podColumnInfo.status.id, + props: { className: podColumnInfo.status.classes }, + cell: , + }, + [podColumnInfo.ready.id]: { + id: podColumnInfo.ready.id, + props: { className: podColumnInfo.ready.classes }, + cell: `${readyCount}/${totalContainers}`, + }, + [podColumnInfo.restarts.id]: { + id: podColumnInfo.restarts.id, + props: { className: podColumnInfo.restarts.classes }, + cell: restarts, + }, + [podColumnInfo.owner.id]: { + id: podColumnInfo.owner.id, + props: { className: podColumnInfo.owner.classes }, + cell: showNodes ? ( + + ) : ( + + ), + }, + [podColumnInfo.memory.id]: { + id: podColumnInfo.memory.id, + props: { className: podColumnInfo.memory.classes }, + cell: bytes ? `${formatBytesAsMiB(bytes)} MiB` : '-', + }, + [podColumnInfo.cpu.id]: { + id: podColumnInfo.cpu.id, + props: { className: podColumnInfo.cpu.classes }, + cell: cores ? t('public~{{numCores}} cores', { numCores: formatCores(cores) }) : '-', + }, + [podColumnInfo.created.id]: { + id: podColumnInfo.created.id, + props: { className: podColumnInfo.created.classes }, + cell: , + }, + [podColumnInfo.node.id]: { + id: podColumnInfo.node.id, + props: { className: podColumnInfo.node.classes }, + cell: , + }, + [podColumnInfo.labels.id]: { + id: podColumnInfo.labels.id, + props: { className: podColumnInfo.labels.classes }, + cell: , + }, + [podColumnInfo.ipaddress.id]: { + id: podColumnInfo.ipaddress.id, + props: { className: podColumnInfo.ipaddress.classes }, + cell: pod?.status?.podIP ?? '-', + }, + [podColumnInfo.traffic.id]: { + id: podColumnInfo.traffic.id, + props: { className: podColumnInfo.traffic.classes }, + cell: , + }, + }; + const dataViewRow: DataViewTd[] = columns.map(({ id }) => rowCells[id]); + const actionsRow: DataViewTd = { + id: '', + props: { className: Kebab.columnClass }, + cell: , + }; + // Always add the actions column + dataViewRow.push(actionsRow); + return dataViewRow; + }); +} + +/** + * The sorting logic was copied over from public/components/factory/Table/VirtualizedTable.tsx file + * For the most part it is 1:1 + * column removal based on screen size was omitted from POC + * the columnShift attribute was omitted from the POC + */ +function useDataViewSort({ + columns, + sortColumnIndex, + sortDirection, +}: { + columns: (DataViewTh & { title: string })[]; + sortColumnIndex?: number; + sortDirection?: SortByDirection; +}) { + const navigate = useNavigate(); + const [sortBy, setSortBy] = React.useState<{ + index: number; + direction: SortByDirection; + }>({ index: sortColumnIndex ?? 0, direction: sortDirection || SortByDirection.asc }); + + const applySort = React.useCallback( + (index, direction) => { + const url = new URL(window.location.href); + const sp = new URLSearchParams(window.location.search); + + const sortColumn = columns[index]; + if (sortColumn) { + sp.set('orderBy', direction); + sp.set('sortBy', sortColumn.title); + navigate(`${url.pathname}?${sp.toString()}${url.hash}`, { replace: true }); + setSortBy({ + index, + direction, + }); + } + }, + [columns, navigate], + ); + + React.useEffect(() => { + const sp = new URLSearchParams(window.location.search); + const columnIndex = _.findIndex(columns, { title: sp.get('sortBy') }); + + if (!Number.isNaN(columnIndex) && columns[columnIndex]) { + const sortOrder = + sp.get('orderBy') === SortByDirection.desc.valueOf() + ? SortByDirection.desc + : SortByDirection.asc; + setSortBy({ + index: columnIndex, + direction: sortOrder, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSort = React.useCallback( + (event, index, direction) => { + event.preventDefault(); + applySort(index, direction); + }, + [applySort], + ); + return { sortBy, onSort }; +} + +// Simple type guard for checking what type of DW column is used +function isDataViewConfigurableColumn( + column: DataViewTh, +): column is { + cell: React.ReactNode; + props: ThProps; +} { + return (column as any)?.cell !== undefined; +} + +function useDataViewData({ + data, + showNodes, + showNamespaceOverride, +}: { + showNamespaceOverride?: boolean; + showNodes: boolean; + data: PodKind[]; +}) { + const { t } = useTranslation(); + // Couple of hooks to persist the pagination information in URL + const [searchParams, setSearchParams] = useSearchParams(); + const pagination = useDataViewPagination({ + perPage: 50, + searchParams, + setSearchParams, + }); + + const columns = React.useMemo(() => getColumns(showNodes, t), [showNodes, t]); + const [activeColumns] = useActiveColumns({ + columns, + showNamespaceOverride, + columnManagementID, + }); + // Slice 0 - 9 to mimic the column management, will be done later + // extend the type with custom attributes so we can access original sorting function and propagate additional data to lookup filter arguments in the URL + const dataViewColumns: (DataViewTh & { + id: string; + sortFunction?: string | ((data: PodKind[], sortDirection: SortByDirection) => PodKind[]); + title: string; + })[] = activeColumns.map((column, index) => ({ + id: column.id, + sortFunction: column.sort, + title: t(column.title), + props: { + className: column.props.classes, + sort: { + columnIndex: index, + sortBy: { + defaultDirection: SortByDirection.asc, + direction: SortByDirection.asc, + index: 0, + }, + }, + } as ThProps, + cell: {t(column.title)}, + })); + + const { sortBy, onSort } = useDataViewSort({ + columns: dataViewColumns, + }); + + /** + * The sorting logic was copied over from public/components/factory/Table/VirtualizedTable.tsx file + * Again, the logic stays the same, but it slightly adjusted to the new data structure + * */ + + const sortedData = React.useMemo(() => { + const sortColumn = dataViewColumns[sortBy.index]; + if (!isDataViewConfigurableColumn(sortColumn)) { + return data; + } else if ( + sortColumn && + isDataViewConfigurableColumn(sortColumn) && + typeof sortColumn?.props.sort === 'string' + ) { + return data.sort( + sortResourceByValue(sortBy.direction, (obj) => + // In data view sort is never a string but we can keep the code for now + _.get(obj, (sortColumn?.props?.sort as unknown) as string, ''), + ), + ); + } else if (typeof sortColumn?.sortFunction === 'string') { + return data.sort( + sortResourceByValue(sortBy.direction, (obj) => + _.get(obj, sortColumn.sortFunction as string), + ), + ); + } else if (typeof sortColumn?.sortFunction === 'function') { + return sortColumn?.sortFunction?.(data, sortBy.direction); + } + return data; + }, [dataViewColumns, data, sortBy.direction, sortBy.index]); + + const transformedData = sortedData + .map>((pod, index) => ({ + obj: pod, + rowData: { showNodes }, + activeColumnIDs: new Set(), + index, + })) + .slice( + (pagination.page - 1) * pagination.perPage, + (pagination.page - 1) * pagination.perPage + pagination.perPage, + ); + + const dataViewRows = useDataViewPodRow(transformedData, dataViewColumns); + /** + * There is a little bit of chicken and egg problem here + * We have to tack on the sort information to the columns once all data is available + * Once the data structure is adopted, should done in one go + */ + dataViewColumns.forEach((column) => { + if (isDataViewConfigurableColumn(column)) { + column.props.sort.sortBy.index = sortBy.index; + column.props.sort.sortBy.direction = sortBy.direction; + column.props.sort.onSort = onSort; + } + }); + return { dataViewRows, dataViewColumns, pagination }; +} + +// Mostly copied over from public/components/filter-toolbar.tsx +// Only taken the labels filter as it is unique to OCP and not in data view +// Seems like it could be generalized and transferred as a new filter type to data view +const LabelsFilter = ({ + onFilterChange, + data, + title, + filterId, + showToolbarItem, +}: { + onFilterChange: OnFilterChange; + data: PodKind[]; + filterId: string; + title: string; + showToolbarItem?: boolean; +}) => { + const { t } = useTranslation(); + const [labelInputText, setLabelInputText] = React.useState(''); + const [searchParams] = useSearchParams(); + + const [labelSelection, onLabelSelectionChange] = useLabelSelectionFix(searchParams, filterId); + const applyLabelFilters = (values: string[]) => { + setLabelInputText(''); + onLabelSelectionChange(values); + onFilterChange(filterId, { all: values }); + }; + + return ( + { + setLabelInputText(''); + applyLabelFilters([]); + }} + labels={labelSelection} + deleteLabel={(f, chip: string) => { + setLabelInputText(''); + applyLabelFilters(_.difference(labelSelection, [chip])); + }} + categoryName={title} + showToolbarItem={showToolbarItem} + > +
+ { + applyLabelFilters(_.uniq([...labelSelection, selected])); + }} + showSuggestions + textValue={labelInputText} + setTextValue={setLabelInputText} + placeholder={t('public~Search by label...')} + data={data} + /> +
+
+ ); +}; + +type DataViewPodListProps = { + loaded: boolean; + data: PodKind[]; + showNodes?: boolean; + filters?: RowFilter[]; + onFilterChange: OnFilterChange; + columnLayout?: ColumnLayout; + showNamespaceOverride?: boolean; +}; + +const DataViewPodList = ({ + data, + showNodes, + loaded, + filters, + onFilterChange, + columnLayout, + showNamespaceOverride, +}: DataViewPodListProps) => { + const { t } = useTranslation(); + const { dataViewColumns, dataViewRows, pagination } = useDataViewData({ + data, + showNodes, + showNamespaceOverride, + }); + const [searchParams, setSearchParams] = useSearchParams(); + const initialFilters = filters?.reduce((acc, curr) => { + acc[curr.filterGroupName] = searchParams.getAll(curr.filterGroupName); + return acc; + }, {}); + const debouncedOnFilterChange = useDebounceCallback(onFilterChange, 500); + const debouncedSetSearchParams = useDebounceCallback(setSearchParams, 500); + // Currently there is a big that will only return the first item from the query, even though there are multiple items for one group + const { onSetFilters, filters: dataViewFilters } = useDataViewFilters({ + searchParams, + setSearchParams: debouncedSetSearchParams, + initialFilters, + }); + + const bodyLoading = React.useMemo( + () => , + [dataViewColumns.length], + ); + const bodyEmpty = React.useMemo( + () => ( + + + + {t('public~No Pods found')} + + + + ), + [t, dataViewColumns.length], + ); + const activeState = React.useMemo(() => { + if (!loaded) { + return DataViewState.loading; + } + if (data.length === 0) { + return DataViewState.empty; + } + + return undefined; + }, [data.length, loaded]); + const filtersMap = React.useMemo(() => { + return filters.reduce<{ + [filterGroup: string]: Omit, 'reducer'> & { all?: string[] }; + }>( + (acc, filter) => { + acc[filter.filterGroupName] = { ...filter, all: filter.items.map((item) => item.id) }; + return acc; + }, + { + name: { + filterGroupName: 'name', + type: 'Name', + items: [], + }, + }, + ); + }, [filters]); + function handleFilter(_e, filterConfig: { [filterId: string]: unknown }) { + onSetFilters(filterConfig); + Object.entries(filterConfig).map(([filterKey, filterValue]) => { + const filter = filtersMap[filterKey]; + if (filter && filter.all) { + debouncedOnFilterChange(filter.type, { + selected: Array.isArray(filterValue) ? filterValue : [filterValue], + all: filter.all, + }); + } else if (filter && typeof filterValue === 'string') { + debouncedOnFilterChange(filter.filterGroupName, { selected: [filterValue] }); + } + }); + } + + const dataViewFiltersNodes = React.useMemo(() => { + return [ + ...filters?.map((filter, index) => { + if (filter.items) { + return ( + ({ + label: option.title, + value: option.id, + }))} + /> + ); + } + return
Foobar
; + }), + , + , + ]; + // can't use data in the deps array is will re-compute the filters and will cause the selected category to reset + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, t, onFilterChange]); + + return ( + + + {dataViewFiltersNodes} + + } + actions={ + + + createColumnManagementModal({ + columnLayout, + }) + } + aria-label={t('public~Column management')} + data-test="manage-columns" + > + + + + + + } + pagination={} + /> + + + ); +}; + +// props.data is mutating and can change the filters not to work +// Sow e have to wait for the data to be loaded as we have to memoize the filters themselves +const DataViewPodListWrapper = (props: DataViewPodListProps) => { + if (!props.loaded) { + return null; + } + + return ; +}; + +export default DataViewPodListWrapper; diff --git a/frontend/public/components/pod.tsx b/frontend/public/components/pod.tsx index 55c9682e26c..ba0b59de51e 100644 --- a/frontend/public/components/pod.tsx +++ b/frontend/public/components/pod.tsx @@ -23,6 +23,7 @@ import { DescriptionListGroup, DescriptionListTerm, DescriptionListDescription, + Switch, } from '@patternfly/react-core'; import { Status, @@ -123,6 +124,8 @@ import { useActiveColumns } from './factory/Table/active-columns-hook'; import { PodDisruptionBudgetField } from '@console/app/src/components/pdb/PodDisruptionBudgetField'; import { PodTraffic } from './pod-traffic'; import { RootState } from '../redux'; + +import DataViewPodList from './data-view-poc/DataViewPodList'; // Only request metrics if the device's screen width is larger than the // breakpoint where metrics are visible. const showMetrics = @@ -1138,9 +1141,33 @@ export const PodsPage: React.FC = ({ groupVersionKind: resourceKind, namespace: namespace || 'default', }; + // toggle between DataViewPodList and PodList + const [usedw, setUsedw] = React.useState(true); + + const columnLayout = React.useMemo(() => { + return { + columns: getColumns(showNodes, t).map((column) => + _.pick(column, ['title', 'additional', 'id']), + ), + id: columnManagementID, + selectedColumns: + tableColumns?.[columnManagementID]?.length > 0 + ? new Set(tableColumns[columnManagementID]) + : null, + showNamespaceOverride, + type: t('public~Pod'), + }; + }, [showNamespaceOverride, t, tableColumns, showNodes]); return ( userSettingsLoaded && ( <> + { + setUsedw((prev) => !prev); + }} + /> {canCreate && ( @@ -1149,37 +1176,40 @@ export const PodsPage: React.FC = ({ )} - - _.pick(column, ['title', 'additional', 'id']), - ), - id: columnManagementID, - selectedColumns: - tableColumns?.[columnManagementID]?.length > 0 - ? new Set(tableColumns[columnManagementID]) - : null, - showNamespaceOverride, - type: t('public~Pod'), - }} - hideNameLabelFilters={hideNameLabelFilters} - hideLabelFilter={hideLabelFilter} - hideColumnManagement={hideColumnManagement} - /> - + {usedw ? ( + + ) : ( + <> + + + + )} ) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3b93abe96a8..3f33aebf9a4 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -12,7 +12,8 @@ "sourceMap": true, "noUnusedLocals": true, "typeRoots": ["node_modules/@types", "@types"], - "types": ["node", "jest", "console"] + "types": ["node", "jest", "console"], + "strict": false }, "exclude": [ ".yarn", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index be317c7af40..dc0f4a5c9dc 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1773,7 +1773,7 @@ react-dropzone "14.3.5" tslib "^2.8.1" -"@patternfly/react-component-groups@6.2.0-prerelease.10": +"@patternfly/react-component-groups@6.2.0-prerelease.10", "@patternfly/react-component-groups@^6.1.0": version "6.2.0-prerelease.10" resolved "https://registry.yarnpkg.com/@patternfly/react-component-groups/-/react-component-groups-6.2.0-prerelease.10.tgz#26fc16b00dc03927f550bb4138a3d5a0eec3a661" integrity sha512-L2NlFfGFceoKgXofN+yrbtyIlkCGdkuA/pbujFB5k8bOeKhVJl3eNZxergpKScFRLpMz2bAvBZhrhS1eoht2ag== @@ -1810,6 +1810,18 @@ react-dropzone "^14.3.5" tslib "^2.8.1" +"@patternfly/react-data-view@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@patternfly/react-data-view/-/react-data-view-6.2.0.tgz#4205638ea6604fcd9aa5627243b88156d8e1f647" + integrity sha512-d8o1MUD0+s87afV4kxF3dZlhYvCb7H3DZTmrk4Bj0UDLoo2DDi3El4P94ZtjLmUiaJ9dYGUrXSbgZUmz5TIyLg== + dependencies: + "@patternfly/react-component-groups" "^6.1.0" + "@patternfly/react-core" "^6.0.0" + "@patternfly/react-icons" "^6.0.0" + "@patternfly/react-table" "^6.0.0" + clsx "^2.1.1" + react-jss "^10.10.0" + "@patternfly/react-icons@^6.0.0", "@patternfly/react-icons@^6.0.0-prerelease.7", "@patternfly/react-icons@^6.2.2": version "6.2.2" resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-6.2.2.tgz#6b19359df7618ea4ec6aa6da3af2a9a287666f23" From 5155a5767af63f32397558bcef714305ce5e6d99 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Wed, 14 May 2025 17:31:59 -0400 Subject: [PATCH 2/9] Follow on --- frontend/public/components/_autocomplete.scss | 10 +- .../data-view-poc/DataViewPodList.tsx | 287 ++++++++++-------- .../modals/column-management-modal.tsx | 41 +-- frontend/public/components/pod.tsx | 30 +- frontend/public/locales/en/public.json | 15 +- 5 files changed, 210 insertions(+), 173 deletions(-) diff --git a/frontend/public/components/_autocomplete.scss b/frontend/public/components/_autocomplete.scss index cbba42994ad..b5f47209a5c 100644 --- a/frontend/public/components/_autocomplete.scss +++ b/frontend/public/components/_autocomplete.scss @@ -1,7 +1,7 @@ .co-suggestion-box { - background-color: var(--pf-t--global--background--color--primary--default); - z-index: var(--pf-t--global--z-index--sm); + position: relative; width: 100%; + z-index: var(--pf-t--global--z-index--sm); @media (max-width: $screen-xs-min) { max-width: calc(100% - 95px); } @@ -11,12 +11,12 @@ } .co-suggestion-box__suggestions { - background-color: var(--pf-t--global--background--color--primary--default); - /* To make the suggestion box hover on top of the table */ - margin-bottom: -19em; + background-color: var(--pf-t--global--background--color--floating--default); display: flex; flex-direction: column; gap: var(--pf-t--global--spacer--gap--group--vertical); + position: absolute; + width: 100%; @media (min-width: $screen-xs-min) and (max-width: $screen-sm-min) { max-width: 200px; } diff --git a/frontend/public/components/data-view-poc/DataViewPodList.tsx b/frontend/public/components/data-view-poc/DataViewPodList.tsx index 79ece8f484f..88d948ac036 100644 --- a/frontend/public/components/data-view-poc/DataViewPodList.tsx +++ b/frontend/public/components/data-view-poc/DataViewPodList.tsx @@ -12,11 +12,20 @@ import { useDataViewFilters, useDataViewPagination, } from '@patternfly/react-data-view'; -import { sortable, SortByDirection, Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; +import { + InnerScrollContainer, + sortable, + SortByDirection, + Tbody, + Td, + ThProps, + Tr, +} from '@patternfly/react-table'; import { useNavigate, useSearchParams } from 'react-router-dom-v5-compat'; import { PodKind, podPhase, + podPhaseFilterReducer, podReadiness, podRestarts, referenceFor, @@ -27,8 +36,7 @@ import { import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { RootState } from '../../redux'; -import * as classNames from 'classnames'; -import { PROMETHEUS_BASE_PATH, PROMETHEUS_TENANCY_BASE_PATH } from '../graphs'; +import { css } from '@patternfly/react-styles'; import { formatBytesAsMiB, formatCores, @@ -39,7 +47,7 @@ import { Timestamp, } from '../utils'; import { PodTraffic } from '../pod-traffic'; -import { LazyActionMenu, useDebounceCallback } from '@console/shared'; +import { getLabelsAsString, LazyActionMenu } from '@console/shared'; import { PodStatus } from '../pod'; import { sortResourceByValue } from '../factory/Table/sort'; import * as UIActions from '../../actions/ui'; @@ -51,13 +59,10 @@ import { ResponsiveActions, SkeletonTableBody, } from '@patternfly/react-component-groups'; -import { RowFilter } from '../filter-toolbar'; -import DataViewFilters from '@patternfly/react-data-view/dist/cjs/DataViewFilters'; -import { - ColumnLayout, - OnFilterChange, -} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; -import useLabelSelectionFix from '../useLabelSelectionFix'; +import DataViewFilters, { + DataViewFilterOption, +} from '@patternfly/react-data-view/dist/cjs/DataViewFilters'; +import { ColumnLayout } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import AutocompleteInput from '../autocomplete'; import { ColumnsIcon } from '@patternfly/react-icons'; import { createColumnManagementModal } from '../modals'; @@ -76,9 +81,6 @@ type PodRowData = { showNodes?: boolean; }; -const showMetrics = - PROMETHEUS_BASE_PATH && PROMETHEUS_TENANCY_BASE_PATH && window.screen.width >= 1200; - const kind = 'Pod'; const podColumnInfo = Object.freeze({ @@ -98,12 +100,12 @@ const podColumnInfo = Object.freeze({ title: 'public~Status', }, ready: { - classes: classNames('pf-m-nowrap', 'pf-v6-u-w-10-on-lg', 'pf-v6-u-w-8-on-xl'), + classes: 'pf-m-nowrap', id: 'ready', title: 'public~Ready', }, restarts: { - classes: classNames('pf-m-nowrap', 'pf-v6-u-w-8-on-2xl'), + classes: 'pf-m-nowrap', id: 'restarts', title: 'public~Restarts', }, @@ -118,17 +120,17 @@ const podColumnInfo = Object.freeze({ title: 'public~Node', }, memory: { - classes: classNames({ 'pf-v6-u-w-10-on-2xl': showMetrics }), + classes: '', id: 'memory', title: 'public~Memory', }, cpu: { - classes: classNames({ 'pf-v6-u-w-10-on-2xl': showMetrics }), + classes: '', id: 'cpu', title: 'public~CPU', }, created: { - classes: classNames('pf-v6-u-w-10-on-2xl'), + classes: '', id: 'created', title: 'public~Created', }, @@ -155,7 +157,11 @@ const getColumns = (showNodes: boolean, t: TFunction): TableColumn[] => id: podColumnInfo.name.id, sort: 'metadata.name', transforms: [sortable], - props: { className: podColumnInfo.name.classes }, + props: { + className: podColumnInfo.name.classes, + isStickyColumn: true, + hasRightBorder: true, + }, }, { title: t(podColumnInfo.namespace.title), @@ -251,9 +257,14 @@ const getColumns = (showNodes: boolean, t: TFunction): TableColumn[] => additional: true, }, { - title: '', - id: '', - props: { className: Kebab.columnClass }, + title: 'Actions', + id: 'actions', + props: { + isStickyColumn: true, + hasLeftBorder: true, + isActionCell: true, + stickyMinWidth: '0', + }, }, ]; @@ -291,12 +302,18 @@ function useDataViewPodRow( const rowCells = { [podColumnInfo.name.id]: { id: podColumnInfo.name.id, - props: { className: podColumnInfo.name.classes }, + props: { + className: podColumnInfo.name.classes, + isStickyColumn: true, + hasRightBorder: true, + }, cell: , }, [podColumnInfo.namespace.id]: { id: podColumnInfo.namespace.id, - props: { className: classNames(podColumnInfo.namespace.classes, 'co-break-word') }, + props: { + className: css(podColumnInfo.namespace.classes, 'co-break-word'), + }, cell: , }, [podColumnInfo.status.id]: { @@ -362,7 +379,13 @@ function useDataViewPodRow( const dataViewRow: DataViewTd[] = columns.map(({ id }) => rowCells[id]); const actionsRow: DataViewTd = { id: '', - props: { className: Kebab.columnClass }, + props: { + className: Kebab.columnClass, + isStickyColumn: true, + hasLeftBorder: true, + isActionCell: true, + stickyMinWidth: '0', + }, cell: , }; // Always add the actions column @@ -390,7 +413,10 @@ function useDataViewSort({ const [sortBy, setSortBy] = React.useState<{ index: number; direction: SortByDirection; - }>({ index: sortColumnIndex ?? 0, direction: sortDirection || SortByDirection.asc }); + }>({ + index: sortColumnIndex ?? 0, + direction: sortDirection || SortByDirection.asc, + }); const applySort = React.useCallback( (index, direction) => { @@ -401,7 +427,9 @@ function useDataViewSort({ if (sortColumn) { sp.set('orderBy', direction); sp.set('sortBy', sortColumn.title); - navigate(`${url.pathname}?${sp.toString()}${url.hash}`, { replace: true }); + navigate(`${url.pathname}?${sp.toString()}${url.hash}`, { + replace: true, + }); setSortBy({ index, direction, @@ -449,13 +477,13 @@ function isDataViewConfigurableColumn( } function useDataViewData({ - data, + filteredData, showNodes, showNamespaceOverride, }: { - showNamespaceOverride?: boolean; + filteredData: PodKind[]; showNodes: boolean; - data: PodKind[]; + showNamespaceOverride?: boolean; }) { const { t } = useTranslation(); // Couple of hooks to persist the pagination information in URL @@ -472,11 +500,12 @@ function useDataViewData({ showNamespaceOverride, columnManagementID, }); - // Slice 0 - 9 to mimic the column management, will be done later // extend the type with custom attributes so we can access original sorting function and propagate additional data to lookup filter arguments in the URL const dataViewColumns: (DataViewTh & { id: string; - sortFunction?: string | ((data: PodKind[], sortDirection: SortByDirection) => PodKind[]); + sortFunction?: + | string + | ((filteredData: PodKind[], sortDirection: SortByDirection) => PodKind[]); title: string; })[] = activeColumns.map((column, index) => ({ id: column.id, @@ -492,6 +521,7 @@ function useDataViewData({ index: 0, }, }, + isStickyColumn: column.props.isStickyColumn, } as ThProps, cell: {t(column.title)}, })); @@ -508,29 +538,29 @@ function useDataViewData({ const sortedData = React.useMemo(() => { const sortColumn = dataViewColumns[sortBy.index]; if (!isDataViewConfigurableColumn(sortColumn)) { - return data; + return filteredData; } else if ( sortColumn && isDataViewConfigurableColumn(sortColumn) && typeof sortColumn?.props.sort === 'string' ) { - return data.sort( + return filteredData.sort( sortResourceByValue(sortBy.direction, (obj) => - // In data view sort is never a string but we can keep the code for now + // In filteredData view sort is never a string but we can keep the code for now _.get(obj, (sortColumn?.props?.sort as unknown) as string, ''), ), ); } else if (typeof sortColumn?.sortFunction === 'string') { - return data.sort( + return filteredData.sort( sortResourceByValue(sortBy.direction, (obj) => _.get(obj, sortColumn.sortFunction as string), ), ); } else if (typeof sortColumn?.sortFunction === 'function') { - return sortColumn?.sortFunction?.(data, sortBy.direction); + return sortColumn?.sortFunction?.(filteredData, sortBy.direction); } - return data; - }, [dataViewColumns, data, sortBy.direction, sortBy.index]); + return filteredData; + }, [dataViewColumns, filteredData, sortBy.direction, sortBy.index]); const transformedData = sortedData .map>((pod, index) => ({ @@ -563,37 +593,36 @@ function useDataViewData({ // Mostly copied over from public/components/filter-toolbar.tsx // Only taken the labels filter as it is unique to OCP and not in data view // Seems like it could be generalized and transferred as a new filter type to data view -const LabelsFilter = ({ - onFilterChange, +const DataViewLabelFilter = ({ data, title, filterId, showToolbarItem, + onChange, }: { - onFilterChange: OnFilterChange; data: PodKind[]; filterId: string; title: string; showToolbarItem?: boolean; + onChange?: (key: string, newValues) => void; // TODO: add type for newValues }) => { const { t } = useTranslation(); const [labelInputText, setLabelInputText] = React.useState(''); const [searchParams] = useSearchParams(); - const [labelSelection, onLabelSelectionChange] = useLabelSelectionFix(searchParams, filterId); + const labelSelection = searchParams.get(filterId)?.split(',') ?? []; const applyLabelFilters = (values: string[]) => { setLabelInputText(''); - onLabelSelectionChange(values); - onFilterChange(filterId, { all: values }); + onChange?.(filterId, values.join(',')); }; return ( { setLabelInputText(''); applyLabelFilters([]); }} - labels={labelSelection} deleteLabel={(f, chip: string) => { setLabelInputText(''); applyLabelFilters(_.difference(labelSelection, [chip])); @@ -603,14 +632,14 @@ const LabelsFilter = ({ >
{ applyLabelFilters(_.uniq([...labelSelection, selected])); }} showSuggestions textValue={labelInputText} setTextValue={setLabelInputText} - placeholder={t('public~Search by label...')} + placeholder={t('public~Filter by label')} data={data} />
@@ -622,8 +651,6 @@ type DataViewPodListProps = { loaded: boolean; data: PodKind[]; showNodes?: boolean; - filters?: RowFilter[]; - onFilterChange: OnFilterChange; columnLayout?: ColumnLayout; showNamespaceOverride?: boolean; }; @@ -632,30 +659,62 @@ const DataViewPodList = ({ data, showNodes, loaded, - filters, - onFilterChange, columnLayout, showNamespaceOverride, }: DataViewPodListProps) => { const { t } = useTranslation(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ + initialFilters: { status: [], name: '', label: '' }, + searchParams, + setSearchParams, + }); + + const filterOptions: DataViewFilterOption[] = React.useMemo( + () => [ + { value: 'Running', label: t('public~Running') }, + { value: 'Pending', label: t('public~Pending') }, + { value: 'Terminating', label: t('public~Terminating') }, + { value: 'CrashLoopBackOff', label: t('public~CrashLoopBackOff') }, + // Use title "Completed" to match what appears in the status column for the pod. + // The pod phase is "Succeeded," but the container state is "Completed." + { value: 'Succeeded', label: t('public~Completed') }, + { value: 'Failed', label: t('public~Failed') }, + { value: 'Unknown', label: t('public~Unknown') }, + ], + [], + ); + + // TODO: account for filter user preference in name filter + const filteredData = React.useMemo( + () => + data.filter((item) => { + const filterLabelArray = filters.label !== '' ? filters.label.split(',') : []; + const itemLabels = getLabelsAsString(item); + + return ( + (!filters.status || + filters.status.length === 0 || + filters.status.includes( + String( + filterOptions.find((option) => option.value === podPhaseFilterReducer(item))?.value, + ), + )) && + (!filters.name || + item.metadata.name?.toLocaleLowerCase().includes(filters.name?.toLocaleLowerCase())) && + (!filters.label || filterLabelArray.every((label) => itemLabels.includes(label))) + ); + }), + [data, filters.label, filters.name, filters.status, filterOptions], + ); + const { dataViewColumns, dataViewRows, pagination } = useDataViewData({ - data, + filteredData, showNodes, showNamespaceOverride, }); - const [searchParams, setSearchParams] = useSearchParams(); - const initialFilters = filters?.reduce((acc, curr) => { - acc[curr.filterGroupName] = searchParams.getAll(curr.filterGroupName); - return acc; - }, {}); - const debouncedOnFilterChange = useDebounceCallback(onFilterChange, 500); - const debouncedSetSearchParams = useDebounceCallback(setSearchParams, 500); - // Currently there is a big that will only return the first item from the query, even though there are multiple items for one group - const { onSetFilters, filters: dataViewFilters } = useDataViewFilters({ - searchParams, - setSearchParams: debouncedSetSearchParams, - initialFilters, - }); const bodyLoading = React.useMemo( () => , @@ -677,84 +736,38 @@ const DataViewPodList = ({ if (!loaded) { return DataViewState.loading; } - if (data.length === 0) { + if (filteredData.length === 0) { return DataViewState.empty; } return undefined; - }, [data.length, loaded]); - const filtersMap = React.useMemo(() => { - return filters.reduce<{ - [filterGroup: string]: Omit, 'reducer'> & { all?: string[] }; - }>( - (acc, filter) => { - acc[filter.filterGroupName] = { ...filter, all: filter.items.map((item) => item.id) }; - return acc; - }, - { - name: { - filterGroupName: 'name', - type: 'Name', - items: [], - }, - }, - ); - }, [filters]); - function handleFilter(_e, filterConfig: { [filterId: string]: unknown }) { - onSetFilters(filterConfig); - Object.entries(filterConfig).map(([filterKey, filterValue]) => { - const filter = filtersMap[filterKey]; - if (filter && filter.all) { - debouncedOnFilterChange(filter.type, { - selected: Array.isArray(filterValue) ? filterValue : [filterValue], - all: filter.all, - }); - } else if (filter && typeof filterValue === 'string') { - debouncedOnFilterChange(filter.filterGroupName, { selected: [filterValue] }); - } - }); - } + }, [filteredData.length, loaded]); const dataViewFiltersNodes = React.useMemo(() => { return [ - ...filters?.map((filter, index) => { - if (filter.items) { - return ( - ({ - label: option.title, - value: option.id, - }))} - /> - ); - } - return
Foobar
; - }), - , - , + , + , ]; // can't use data in the deps array is will re-compute the filters and will cause the selected category to reset // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, t, onFilterChange]); + }, [filterOptions, t]); return ( + onSetFilters(values)}> {dataViewFiltersNodes} } + clearAllFilters={clearAllFilters} actions={ createColumnManagementModal({ columnLayout, + noLimit: true, }) } aria-label={t('public~Column management')} @@ -774,16 +788,19 @@ const DataViewPodList = ({ } - pagination={} - /> - } /> + + + ); }; diff --git a/frontend/public/components/modals/column-management-modal.tsx b/frontend/public/components/modals/column-management-modal.tsx index e280f2c0b65..fca305ac74a 100644 --- a/frontend/public/components/modals/column-management-modal.tsx +++ b/frontend/public/components/modals/column-management-modal.tsx @@ -64,7 +64,7 @@ const DataListRow: React.FC = ({ export const ColumnManagementModal: React.FC< ColumnManagementModalProps & WithUserSettingsCompatibilityProps -> = ({ cancel, close, columnLayout, setUserSettingState: setTableColumns }) => { +> = ({ cancel, close, columnLayout, setUserSettingState: setTableColumns, noLimit }) => { const { t } = useTranslation(); const defaultColumns = columnLayout.columns.filter((column) => column.id && !column.additional); const additionalColumns = columnLayout.columns.filter((column) => column.additional); @@ -110,20 +110,26 @@ export const ColumnManagementModal: React.FC<
{t('public~Manage columns')} -
-

{t('public~Selected columns will appear in the table.')}

-
-
- - {!columnLayout?.showNamespaceOverride && - t('public~The namespace column is only shown when in "All projects"')} - -
+ {!noLimit && ( + <> +
+

{t('public~Selected columns will appear in the table.')}

+
+
+ + {!columnLayout?.showNamespaceOverride && + t('public~The namespace column is only shown when in "All projects"')} + +
+ + )}