From 3d4fdc6cfd9ce9e65f64c64dad6d9fd8f1078276 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Fri, 13 Jun 2025 17:17:21 +0000 Subject: [PATCH 1/7] Add ResourceDataView component and related code --- frontend/package.json | 3 + .../data-view/DataViewLabelFilter.tsx | 64 ++++++ .../components/data-view/ResourceDataView.tsx | 183 ++++++++++++++++++ .../src/components/data-view/types.ts | 18 ++ .../data-view/useResourceDataViewData.tsx | 140 ++++++++++++++ .../data-view/useResourceDataViewFilters.ts | 58 ++++++ .../data-view/useResourceDataViewSort.ts | 67 +++++++ frontend/public/components/_autocomplete.scss | 10 +- .../modals/column-management-modal.tsx | 41 ++-- frontend/public/locales/en/public.json | 2 + frontend/tsconfig.json | 1 + frontend/yarn.lock | 14 +- 12 files changed, 578 insertions(+), 23 deletions(-) create mode 100644 frontend/packages/console-app/src/components/data-view/DataViewLabelFilter.tsx create mode 100644 frontend/packages/console-app/src/components/data-view/ResourceDataView.tsx create mode 100644 frontend/packages/console-app/src/components/data-view/types.ts create mode 100644 frontend/packages/console-app/src/components/data-view/useResourceDataViewData.tsx create mode 100644 frontend/packages/console-app/src/components/data-view/useResourceDataViewFilters.ts create mode 100644 frontend/packages/console-app/src/components/data-view/useResourceDataViewSort.ts diff --git a/frontend/package.json b/frontend/package.json index e6a69065d4d..b7140fe360a 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.3.0-prerelease.2", "@patternfly/react-styles": "^6.2.2", @@ -315,6 +316,8 @@ "node": ">=22.x" }, "resolutions": { + "@patternfly/react-component-groups": "6.2.0-prerelease.10", + "@patternfly/react-data-view": "^6.2.0", "@types/react-router": "^5.1.20", "@types/react-router-dom": "5.3.x", "hosted-git-info": "^3.0.8", diff --git a/frontend/packages/console-app/src/components/data-view/DataViewLabelFilter.tsx b/frontend/packages/console-app/src/components/data-view/DataViewLabelFilter.tsx new file mode 100644 index 00000000000..11d5fd20512 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/DataViewLabelFilter.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { ToolbarFilter } from '@patternfly/react-core'; +import * as _ from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import AutocompleteInput from '@console/internal/components/autocomplete'; + +type DataViewLabelFilterProps = { + data: TData[]; + title: string; + filterId: string; + onChange?: (key: string, selectedValues: string) => void; + showToolbarItem?: boolean; +}; + +export const DataViewLabelFilter = ({ + data, + title, + filterId, + onChange, + showToolbarItem, +}: DataViewLabelFilterProps) => { + const { t } = useTranslation(); + + const [searchParams] = useSearchParams(); + const [labelInputText, setLabelInputText] = React.useState(''); + const labelSelection = searchParams.get(filterId)?.split(',') ?? []; + + const applyLabelFilters = (values: string[]) => { + setLabelInputText(''); + onChange?.(filterId, values.join(',')); + }; + + return ( + { + setLabelInputText(''); + applyLabelFilters(_.difference(labelSelection, [label])); + }} + deleteLabelGroup={() => { + setLabelInputText(''); + applyLabelFilters([]); + }} + > +
+ { + applyLabelFilters(_.uniq([...labelSelection, selected])); + }} + showSuggestions + textValue={labelInputText} + setTextValue={setLabelInputText} + placeholder={t('public~Filter by label')} + data={data} + /> +
+
+ ); +}; diff --git a/frontend/packages/console-app/src/components/data-view/ResourceDataView.tsx b/frontend/packages/console-app/src/components/data-view/ResourceDataView.tsx new file mode 100644 index 00000000000..7dd45eb3bf5 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/ResourceDataView.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { + ResponsiveAction, + ResponsiveActions, + SkeletonTableBody, +} from '@patternfly/react-component-groups'; +import { Bullseye, Pagination, Tooltip } from '@patternfly/react-core'; +import { + DataView, + DataViewState, + DataViewTable, + DataViewTextFilter, + DataViewToolbar, +} from '@patternfly/react-data-view'; +import DataViewFilters from '@patternfly/react-data-view/dist/cjs/DataViewFilters'; +import { ColumnsIcon } from '@patternfly/react-icons'; +import { InnerScrollContainer, Tbody, Td, Tr } from '@patternfly/react-table'; +import { useTranslation } from 'react-i18next'; +import { + ColumnLayout, + K8sResourceCommon, +} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { createColumnManagementModal } from '@console/internal/components/modals'; +import { TableColumn } from '@console/internal/module/k8s'; +import { DataViewLabelFilter } from './DataViewLabelFilter'; +import { ResourceFilters } from './types'; +import { useResourceDataViewData, GetDataViewRows } from './useResourceDataViewData'; +import { useResourceDataViewFilters } from './useResourceDataViewFilters'; + +export type ResourceDataViewProps = { + data: TData[]; + loaded: boolean; + columns: TableColumn[]; + columnLayout?: ColumnLayout; + columnManagementID?: string; + initialFilters: TFilters; + additionalFilterNodes?: React.ReactNode[]; + matchesAdditionalFilters?: (resource: TData, filters: TFilters) => boolean; + getDataViewRows: GetDataViewRows; + customRowData?: TCustomRowData; + showNamespaceOverride?: boolean; + hideNameLabelFilters?: boolean; + hideLabelFilter?: boolean; + hideColumnManagement?: boolean; +}; + +/** + * Console DataView component based on PatternFly DataView. + */ +export const ResourceDataView = < + TData extends K8sResourceCommon = K8sResourceCommon, + TCustomRowData = any, + TFilters extends ResourceFilters = ResourceFilters +>({ + data, + loaded, + columns, + columnLayout, + columnManagementID, + initialFilters, + additionalFilterNodes, + matchesAdditionalFilters, + getDataViewRows, + customRowData, + showNamespaceOverride, + hideNameLabelFilters, + hideLabelFilter, + hideColumnManagement, +}: ResourceDataViewProps) => { + const { t } = useTranslation(); + + const { filters, onSetFilters, clearAllFilters, filteredData } = useResourceDataViewFilters< + TData, + TFilters + >({ + data, + initialFilters, + matchesAdditionalFilters, + }); + + const { dataViewColumns, dataViewRows, pagination } = useResourceDataViewData< + TData, + TCustomRowData + >({ + columns, + filteredData, + getDataViewRows, + showNamespaceOverride, + columnManagementID, + customRowData, + }); + + 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 (filteredData.length === 0) { + return DataViewState.empty; + } + return undefined; + }, [filteredData.length, loaded]); + + const dataViewFiltersNodes = React.useMemo(() => { + const basicFilters = [ + !hideNameLabelFilters && ( + + ), + !hideNameLabelFilters && hideLabelFilter !== true && ( + + ), + ]; + + return additionalFilterNodes?.length > 0 + ? [...additionalFilterNodes, ...basicFilters] + : basicFilters; + + // Can't use data in the deps array as it will recompute the filters and will cause the selected category to reset + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [additionalFilterNodes, t]); + + return ( + + onSetFilters(values)}> + {dataViewFiltersNodes} + + } + clearAllFilters={clearAllFilters} + actions={ + !hideColumnManagement && ( + + + createColumnManagementModal({ + columnLayout, + noLimit: true, + }) + } + aria-label={t('public~Column management')} + data-test="manage-columns" + > + + + + + + ) + } + pagination={} + /> + + + + + ); +}; diff --git a/frontend/packages/console-app/src/components/data-view/types.ts b/frontend/packages/console-app/src/components/data-view/types.ts new file mode 100644 index 00000000000..f55989eb9d1 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/types.ts @@ -0,0 +1,18 @@ +import { DataViewTh, DataViewTd } from '@patternfly/react-data-view'; +import { SortByDirection } from '@patternfly/react-table'; +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; + +export type ResourceFilters = { + name: string; + labels: string; +}; + +export type ResourceDataViewColumn< + TData extends K8sResourceCommon = K8sResourceCommon +> = DataViewTh & { + id: string; + title: string; + sortFunction?: string | ((filteredData: TData[], sortDirection: SortByDirection) => TData[]); +}; + +export type ResourceDataViewRow = DataViewTd[]; diff --git a/frontend/packages/console-app/src/components/data-view/useResourceDataViewData.tsx b/frontend/packages/console-app/src/components/data-view/useResourceDataViewData.tsx new file mode 100644 index 00000000000..58e6ec6813c --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useResourceDataViewData.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { useDataViewPagination, DataViewTh } from '@patternfly/react-data-view'; +import { SortByDirection, ThProps } from '@patternfly/react-table'; +import * as _ from 'lodash'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { + K8sResourceCommon, + TableColumn, + RowProps, +} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { useActiveColumns } from '@console/internal/components/factory/Table/active-columns-hook'; +import { sortResourceByValue } from '@console/internal/components/factory/Table/sort'; +import { ResourceDataViewColumn, ResourceDataViewRow } from './types'; +import { useResourceDataViewSort } from './useResourceDataViewSort'; + +export type GetDataViewRows = ( + data: RowProps[], + columns: ResourceDataViewColumn[], +) => ResourceDataViewRow[]; + +const isDataViewConfigurableColumn = ( + column: DataViewTh, +): column is Extract => { + return (column as any)?.cell !== undefined; +}; + +export const useResourceDataViewData = < + TData extends K8sResourceCommon = K8sResourceCommon, + TCustomRowData = any +>({ + columns, + filteredData, + getDataViewRows, + showNamespaceOverride, + columnManagementID, + customRowData, +}: { + columns: TableColumn[]; + filteredData: TData[]; + getDataViewRows: GetDataViewRows; + showNamespaceOverride?: boolean; + columnManagementID?: string; + customRowData?: TCustomRowData; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const pagination = useDataViewPagination({ + perPage: 50, + searchParams, + setSearchParams, + }); + + const [activeColumns] = useActiveColumns({ + columns, + showNamespaceOverride, + columnManagementID, + }); + + const dataViewColumns = React.useMemo[]>( + () => + activeColumns.map(({ id, title, sort, props }, index) => ({ + id, + title, + sortFunction: sort, + props: { + className: props.classes, + sort: { + columnIndex: index, + sortBy: { + defaultDirection: SortByDirection.asc, + direction: SortByDirection.asc, + index: 0, + }, + }, + isStickyColumn: props.isStickyColumn, + } as ThProps, + cell: {title}, + })), + [activeColumns], + ); + + const { sortBy, onSort } = useResourceDataViewSort({ + columns: dataViewColumns, + }); + + const sortedData = React.useMemo(() => { + const sortColumn = dataViewColumns[sortBy.index]; + + if (!isDataViewConfigurableColumn(sortColumn)) { + return filteredData; + } + + if (typeof sortColumn.props.sort === 'string') { + return filteredData.sort( + sortResourceByValue(sortBy.direction, (obj) => + _.get(obj, (sortColumn.props.sort as unknown) as string, ''), + ), + ); + } + + if (typeof sortColumn.sortFunction === 'string') { + return filteredData.sort( + sortResourceByValue(sortBy.direction, (obj) => + _.get(obj, sortColumn.sortFunction as string), + ), + ); + } + + if (typeof sortColumn.sortFunction === 'function') { + return sortColumn.sortFunction(filteredData, sortBy.direction); + } + + return filteredData; + }, [dataViewColumns, filteredData, sortBy.direction, sortBy.index]); + + const transformedData = sortedData + .map>((obj, index) => ({ + obj, + rowData: customRowData, + activeColumnIDs: new Set(), + index, + })) + .slice( + (pagination.page - 1) * pagination.perPage, + (pagination.page - 1) * pagination.perPage + pagination.perPage, + ); + + const dataViewRows = getDataViewRows(transformedData, dataViewColumns); + + // We have to tack sort information to the columns once all data is available + 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 }; +}; diff --git a/frontend/packages/console-app/src/components/data-view/useResourceDataViewFilters.ts b/frontend/packages/console-app/src/components/data-view/useResourceDataViewFilters.ts new file mode 100644 index 00000000000..59bdee88685 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useResourceDataViewFilters.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { useDataViewFilters } from '@patternfly/react-data-view'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { useExactSearch } from '@console/app/src/components/user-preferences/search/useExactSearch'; +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { + exactMatch, + fuzzyCaseInsensitive, +} from '@console/internal/components/factory/table-filters'; +import { getLabelsAsString } from '@console/shared/src/utils/label-filter'; +import { ResourceFilters } from './types'; + +export const useResourceDataViewFilters = < + TData extends K8sResourceCommon = K8sResourceCommon, + TFilters extends ResourceFilters = ResourceFilters +>({ + data, + initialFilters, + matchesAdditionalFilters, +}: { + data: TData[]; + initialFilters: TFilters; + matchesAdditionalFilters?: (resource: TData, filters: TFilters) => boolean; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [isExactSearch] = useExactSearch(); + + const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ + initialFilters, + searchParams, + setSearchParams, + }); + + const filteredData = React.useMemo( + () => + data.filter((resource) => { + // Filter by K8s resource name + const resourceName = resource.metadata.name; + const matchesName = + !filters.name || isExactSearch + ? exactMatch(filters.name, resourceName) + : fuzzyCaseInsensitive(filters.name, resourceName); + + // Filter by K8s resource labels + const resourceLabels = getLabelsAsString(resource); + const filterLabelsArray = filters.labels?.split(',') ?? []; + const matchesLabels = + !filters.labels || filterLabelsArray.every((label) => resourceLabels.includes(label)); + + return ( + matchesName && matchesLabels && (matchesAdditionalFilters?.(resource, filters) ?? true) + ); + }), + [data, filters, isExactSearch, matchesAdditionalFilters], + ); + + return { filters, onSetFilters, clearAllFilters, filteredData }; +}; diff --git a/frontend/packages/console-app/src/components/data-view/useResourceDataViewSort.ts b/frontend/packages/console-app/src/components/data-view/useResourceDataViewSort.ts new file mode 100644 index 00000000000..f9bc53a5bc9 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useResourceDataViewSort.ts @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { SortByDirection } from '@patternfly/react-table'; +import * as _ from 'lodash'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { ResourceDataViewColumn } from './types'; + +export const useResourceDataViewSort = ({ + columns, + sortColumnIndex, + sortDirection, +}: { + columns: ResourceDataViewColumn[]; + 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: number, direction: SortByDirection) => { + const sp = new URLSearchParams(window.location.search); + const url = new URL(window.location.href); + const sortColumn = columns[index]; + + if (sortColumn) { + sp.set('sortBy', sortColumn.title); + sp.set('orderBy', direction); + + 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 }); + } + }, [columns]); + + const onSort = React.useCallback( + (event: React.BaseSyntheticEvent, index: number, direction: SortByDirection) => { + event.preventDefault(); + applySort(index, direction); + }, + [applySort], + ); + + return { sortBy, onSort }; +}; 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/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"')} + +
+ + )}