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..1f12c9e56e2 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/ResourceDataView.tsx @@ -0,0 +1,208 @@ +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 { EmptyBox } from '@console/shared/src/components/empty-state/EmptyBox'; +import { StatusBox } from '@console/shared/src/components/status/StatusBox'; +import { DataViewLabelFilter } from './DataViewLabelFilter'; +import { ResourceFilters, GetDataViewRows } from './types'; +import { useResourceDataViewData } from './useResourceDataViewData'; +import { useResourceDataViewFilters } from './useResourceDataViewFilters'; + +export type ResourceDataViewProps = { + label?: string; + data: TData[]; + loaded: boolean; + loadError?: any; + 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; + mock?: boolean; +}; + +/** + * Console DataView component based on PatternFly DataView. + */ +export const ResourceDataView = < + TData extends K8sResourceCommon = K8sResourceCommon, + TCustomRowData = any, + TFilters extends ResourceFilters = ResourceFilters +>({ + label, + data, + loaded, + loadError, + columns, + columnLayout, + columnManagementID, + initialFilters, + additionalFilterNodes, + matchesAdditionalFilters, + getDataViewRows, + customRowData, + showNamespaceOverride, + hideNameLabelFilters, + hideLabelFilter, + hideColumnManagement, + mock, +}: 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( + () => ( + + + + + {label ? t('public~No {{label}} found', { label }) : t('public~None found')} + + + + + ), + [t, dataViewColumns.length, label], + ); + + const activeState = React.useMemo(() => { + if (!loaded) { + return DataViewState.loading; + } + if (filteredData.length === 0) { + return DataViewState.empty; + } + return undefined; + }, [filteredData.length, loaded]); + + const dataViewFilterNodes = React.useMemo(() => { + const basicFilters: React.ReactNode[] = []; + + if (!hideNameLabelFilters) { + basicFilters.push(); + } + + if (!hideNameLabelFilters && !hideLabelFilter) { + basicFilters.push( + , + ); + } + + 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 mock ? ( + + ) : ( + } + > + + 0 && ( + onSetFilters(values)}> + {dataViewFilterNodes} + + ) + } + 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..90349e61561 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/types.ts @@ -0,0 +1,29 @@ +import { DataViewTh, DataViewTd } from '@patternfly/react-data-view'; +import { SortByDirection } from '@patternfly/react-table'; +import { + K8sResourceCommon, + RowProps, +} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; + +export type ResourceFilters = { + name: string; + label: string; +}; + +export type ResourceDataViewColumn< + TData extends K8sResourceCommon = K8sResourceCommon +> = DataViewTh & { + id: string; + title: string; + sortFunction?: string | ((filteredData: TData[], sortDirection: SortByDirection) => TData[]); +}; + +export type ResourceDataViewRow = DataViewTd[]; + +/** + * Maps Console `RowProps` data to DataView compatible format. + */ +export type GetDataViewRows = ( + data: RowProps[], + columns: ResourceDataViewColumn[], +) => ResourceDataViewRow[]; 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..1b5eee4a28f --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useResourceDataViewData.tsx @@ -0,0 +1,175 @@ +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 { useTranslation } from 'react-i18next'; +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, GetDataViewRows } from './types'; +import { useResourceDataViewSort, getSortByDirection } from './useResourceDataViewSort'; + +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 { t } = useTranslation(); + 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: { classes, isStickyColumn, stickyMinWidth, modifier } }, + index, + ) => { + const headerProps: ThProps = { + className: classes, + isStickyColumn, + stickyMinWidth, + modifier, + }; + + if (sort) { + headerProps.sort = { + columnIndex: index, + sortBy: { + index: 0, + direction: SortByDirection.asc, + defaultDirection: SortByDirection.asc, + }, + }; + } + + return { + id, + title, + sortFunction: sort, + props: headerProps, + cell: title ? ( + {title} + ) : ( + {t('public~Actions')} + ), + }; + }, + ), + [activeColumns, t], + ); + + const { sortBy, onSort } = useResourceDataViewSort({ + columns: dataViewColumns, + }); + + const sortedData = React.useMemo(() => { + const sortColumn = dataViewColumns[sortBy.index]; + const sortDirection = getSortByDirection(sortBy.direction); + + if (!isDataViewConfigurableColumn(sortColumn)) { + return filteredData; + } + + if (typeof sortColumn.props.sort === 'string') { + return filteredData.sort( + sortResourceByValue(sortDirection, (obj) => + _.get(obj, (sortColumn.props.sort as unknown) as string, ''), + ), + ); + } + + if (typeof sortColumn.sortFunction === 'string') { + return filteredData.sort( + sortResourceByValue(sortDirection, (obj) => _.get(obj, sortColumn.sortFunction as string)), + ); + } + + if (typeof sortColumn.sortFunction === 'function') { + return sortColumn.sortFunction(filteredData, sortDirection); + } + + 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); + + // This code fixes a sorting issue but should be revisited to add more clarity + const dataViewColumnsWithSortApplied = React.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, + }, + }, + } + : column; + }), + [dataViewColumns, sortBy.index, sortBy.direction, onSort], + ); + + return { dataViewRows, dataViewColumns: dataViewColumnsWithSortApplied, 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..f7728ebc461 --- /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.label?.split(',') ?? []; + const matchesLabels = + !filters.label || 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..db855d45d5c --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/useResourceDataViewSort.ts @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { SortByDirection, ISortBy } from '@patternfly/react-table'; +import * as _ from 'lodash'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { ResourceDataViewColumn } from './types'; + +export const getSortByDirection = (value: string): SortByDirection => + value === SortByDirection.desc.valueOf() ? SortByDirection.desc : SortByDirection.asc; + +export const useResourceDataViewSort = ({ + columns, + sortColumnIndex, + sortDirection, +}: { + columns: ResourceDataViewColumn[]; + sortColumnIndex?: number; + sortDirection?: SortByDirection; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + + // Initialize sort state from URL params or defaults + const getInitialSortState = React.useCallback<() => ISortBy>(() => { + const sortByParam = searchParams.get('sortBy'); + const orderByParam = searchParams.get('orderBy'); + + if (sortByParam && columns.length > 0) { + const columnIndex = _.findIndex(columns, { title: sortByParam }); + + if (columnIndex >= 0) { + return { + index: columnIndex, + direction: getSortByDirection(orderByParam), + }; + } + } + + return { + index: sortColumnIndex ?? 0, + direction: sortDirection ?? SortByDirection.asc, + }; + }, [searchParams, columns, sortColumnIndex, sortDirection]); + + const [sortBy, setSortBy] = React.useState(getInitialSortState); + + const applySort = React.useCallback( + (index: number, direction: SortByDirection) => { + const sortColumn = columns[index]; + + if (sortColumn) { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.set('sortBy', sortColumn.title); + newParams.set('orderBy', direction); + return newParams; + }); + + setSortBy({ index, direction }); + } + }, + [columns, setSearchParams], + ); + + // Update sort state when columns change or URL params change + React.useEffect(() => { + const newSortState = getInitialSortState(); + + setSortBy((prevSortState) => { + // Only update if the state actually changed + return _.isEqual(prevSortState, newSortState) ? prevSortState : newSortState; + }); + }, [getInitialSortState]); + + const onSort = React.useCallback( + (event: React.BaseSyntheticEvent, index: number, direction: SortByDirection) => { + event.preventDefault(); + applySort(index, direction); + }, + [applySort], + ); + + return { sortBy, onSort }; +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md index cc392d63646..95f4bbcfeab 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md @@ -2607,7 +2607,7 @@ A hook that provides a list of user-selected active TableColumns. showNamespaceOverride: false, columnManagementID, }); - return userSettingsAreLoaded ? : null + return userSettingsLoaded ? : null ``` diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/api/dynamic-core-api.ts b/frontend/packages/console-dynamic-plugin-sdk/src/api/dynamic-core-api.ts index 9bb043b6053..1d68992556c 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/api/dynamic-core-api.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/api/dynamic-core-api.ts @@ -163,7 +163,7 @@ export const TableData: React.FC = require('@console/internal/co * showNamespaceOverride: false, * columnManagementID, * }); - * return userSettingsAreLoaded ? : null + * return userSettingsLoaded ? : null * ``` */ export const useActiveColumns: UseActiveColumns = require('@console/internal/components/factory/Table/active-columns-hook') 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"')} + +
+ + )}