diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index cfd115c75..e2bac020c 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -352,5 +352,13 @@ "You don't have permission to perform this action": "You don't have permission to perform this action", "annotations": "annotations", "annotation": "annotation", - "No owner": "No owner" + "No owner": "No owner", + "Pagination": "Pagination", + "Go to first page": "Go to first page", + "Go to previous page": "Go to previous page", + "Go to next page": "Go to next page", + "Go to last page": "Go to last page", + "Items per page": "Items per page", + "per page": "per page", + "of": "of" } diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index f77e16ac2..f1aeccdfc 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -352,5 +352,13 @@ "You don't have permission to perform this action": "You don't have permission to perform this action", "annotations": "annotations", "annotation": "annotation", - "No owner": "No owner" + "No owner": "No owner", + "Pagination": "Pagination", + "Go to first page": "Go to first page", + "Go to previous page": "Go to previous page", + "Go to next page": "Go to next page", + "Go to last page": "Go to last page", + "Items per page": "Items per page", + "per page": "per page", + "of": "of" } diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index 76b66c62e..cbe7350f0 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -352,5 +352,13 @@ "You don't have permission to perform this action": "You don't have permission to perform this action", "annotations": "annotations", "annotation": "annotation", - "No owner": "No owner" + "No owner": "No owner", + "Pagination": "Pagination", + "Go to first page": "Go to first page", + "Go to previous page": "Go to previous page", + "Go to next page": "Go to next page", + "Go to last page": "Go to last page", + "Items per page": "Items per page", + "per page": "per page", + "of": "of" } diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index d016eedc6..f01dea6bc 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -352,5 +352,13 @@ "You don't have permission to perform this action": "You don't have permission to perform this action", "annotations": "annotations", "annotation": "annotation", - "No owner": "No owner" + "No owner": "No owner", + "Pagination": "Pagination", + "Go to first page": "Go to first page", + "Go to previous page": "Go to previous page", + "Go to next page": "Go to next page", + "Go to last page": "Go to last page", + "Items per page": "Items per page", + "per page": "per page", + "of": "of" } diff --git a/src/gitops/components/imageupdater/ImageUpdaterList.tsx b/src/gitops/components/imageupdater/ImageUpdaterList.tsx index 24c76b29a..33ae2b797 100644 --- a/src/gitops/components/imageupdater/ImageUpdaterList.tsx +++ b/src/gitops/components/imageupdater/ImageUpdaterList.tsx @@ -38,7 +38,12 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; +import { filterByConsoleNameAndLabels, parseLabelFilterParam } from '../shared/listPageTextFilters'; import MetadataLabels from '../shared/MetadataLabels'; import { useImageUpdaterActionsProvider } from './hooks/useImageUpdaterActionsProvider'; @@ -87,6 +92,8 @@ const ImageUpdaterList: React.FC = ({ // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; + const nameQuery = searchParams.get('name') || ''; + const labelsParam = searchParams.get('labels') || ''; const { t } = useTranslation('plugin__gitops-plugin'); @@ -98,11 +105,16 @@ const ImageUpdaterList: React.FC = ({ const filters = getFilters(t); const [data, filteredData, onFilterChange] = useListPageFilter(sortedItems, filters); + const filteredByNameAndLabels = React.useMemo( + () => filterByConsoleNameAndLabels(filteredData, nameQuery, parseLabelFilterParam(labelsParam)), + [filteredData, nameQuery, labelsParam], + ); + const filteredBySearch = React.useMemo(() => { - if (!searchQuery) return filteredData; + if (!searchQuery) return filteredByNameAndLabels; const lowerQuery = searchQuery.toLowerCase(); - return filteredData.filter((item) => { + return filteredByNameAndLabels.filter((item) => { const name = item.metadata?.name || ''; const labels = item.metadata?.labels || {}; return ( @@ -116,9 +128,14 @@ const ImageUpdaterList: React.FC = ({ }) ); }); - }, [filteredData, searchQuery]); + }, [filteredByNameAndLabels, searchQuery]); - const rows = useImageUpdaterRowsDV(filteredBySearch as ImageUpdaterKind[], effectiveNamespace); + const { pagination, pagedItems, itemCount } = useGitOpsListPagePagination({ + items: filteredBySearch as ImageUpdaterKind[], + namespace: effectiveNamespace, + searchParams, + }); + const rows = useImageUpdaterRowsDV(pagedItems, effectiveNamespace); const hasItems = React.useMemo(() => { return sortedItems.length > 0; @@ -207,6 +224,8 @@ const ImageUpdaterList: React.FC = ({ emptyState={empty} isError={!!loadError} errorState={error || undefined} + itemCount={itemCount} + pagination={pagination} /> diff --git a/src/gitops/components/project/ProjectList.tsx b/src/gitops/components/project/ProjectList.tsx index 579935359..fa3818c99 100644 --- a/src/gitops/components/project/ProjectList.tsx +++ b/src/gitops/components/project/ProjectList.tsx @@ -36,7 +36,12 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; +import { filterByConsoleNameAndLabels, parseLabelFilterParam } from '../shared/listPageTextFilters'; import { MetadataLabels } from '../shared/MetadataLabels/MetadataLabels'; import { useProjectActionsProvider } from './hooks/useProjectActionsProvider'; @@ -101,6 +106,8 @@ const ProjectList: React.FC = ({ // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; + const nameQuery = searchParams.get('name') || ''; + const labelsParam = searchParams.get('labels') || ''; const { t } = useTranslation('plugin__gitops-plugin'); @@ -112,11 +119,16 @@ const ProjectList: React.FC = ({ const filters = getFilters(t, applications, appsLoaded); const [data, filteredData, onFilterChange] = useListPageFilter(sortedProjects, filters); + const filteredByNameAndLabels = React.useMemo( + () => filterByConsoleNameAndLabels(filteredData, nameQuery, parseLabelFilterParam(labelsParam)), + [filteredData, nameQuery, labelsParam], + ); + // Filter by search query if present (after other filters) const filteredBySearch = React.useMemo(() => { - if (!searchQuery) return filteredData; + if (!searchQuery) return filteredByNameAndLabels; - return filteredData.filter((project) => { + return filteredByNameAndLabels.filter((project) => { const name = project.metadata?.name || ''; const description = project.spec?.description || ''; const labels = project.metadata?.labels || {}; @@ -136,9 +148,14 @@ const ProjectList: React.FC = ({ }) ); }); - }, [filteredData, searchQuery]); + }, [filteredByNameAndLabels, searchQuery]); - const rows = useProjectsRowsDV(filteredBySearch, namespace, applications, appsLoaded); + const { pagination, pagedItems, itemCount } = useGitOpsListPagePagination({ + items: filteredBySearch, + namespace, + searchParams, + }); + const rows = useProjectsRowsDV(pagedItems, namespace, applications, appsLoaded); const showNamespaceColumn = !namespace || namespace === ''; // Check if there are projects initially (before search) @@ -236,6 +253,8 @@ const ProjectList: React.FC = ({ emptyState={empty} isError={!!loadError} errorState={error || undefined} + itemCount={itemCount} + pagination={pagination} /> diff --git a/src/gitops/components/rollout/RolloutList.tsx b/src/gitops/components/rollout/RolloutList.tsx index 5797ca393..82f7aa5f3 100644 --- a/src/gitops/components/rollout/RolloutList.tsx +++ b/src/gitops/components/rollout/RolloutList.tsx @@ -36,7 +36,16 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; +import { + filterByConsoleNameAndLabels, + filterResourcesByLabelQuery, + parseLabelFilterParam, +} from '../shared/listPageTextFilters'; import { MetadataLabels } from '../shared/MetadataLabels/MetadataLabels'; import { useRolloutActionsProvider } from './hooks/useRolloutActionsProvider'; @@ -112,6 +121,8 @@ const RolloutList: React.FC = ({ // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; + const nameQuery = searchParams.get('name') || ''; + const labelsParam = searchParams.get('labels') || ''; const { t } = useGitOpsTranslation(); @@ -123,22 +134,22 @@ const RolloutList: React.FC = ({ const filters = getFilters(t); const [data, filteredData, onFilterChange] = useListPageFilter(sortedRollouts, filters); - // TODO: use alternate filter since it is deprecated. See DataTableView potentially - // Filter by search query if present (after other filters) - const filteredBySearch = React.useMemo(() => { - if (!searchQuery) return filteredData; + const filteredByNameAndLabels = React.useMemo( + () => filterByConsoleNameAndLabels(filteredData, nameQuery, parseLabelFilterParam(labelsParam)), + [filteredData, nameQuery, labelsParam], + ); - return filteredData.filter((app) => { - const labels = app.metadata?.labels || {}; - // Check if any label matches the search query - return Object.entries(labels).some(([key, value]) => { - const labelSelector = `${key}=${value}`; - return labelSelector.includes(searchQuery) || key.includes(searchQuery); - }); - }); - }, [filteredData, searchQuery]); + const filteredBySearch = React.useMemo( + () => filterResourcesByLabelQuery(filteredByNameAndLabels, searchQuery), + [filteredByNameAndLabels, searchQuery], + ); - const rows = useRolloutsRowsDV(filteredBySearch, namespace, t); + const { pagination, pagedItems, itemCount } = useGitOpsListPagePagination({ + items: filteredBySearch, + namespace, + searchParams, + }); + const rows = useRolloutsRowsDV(pagedItems, namespace, t); const empty = ( @@ -206,7 +217,7 @@ const RolloutList: React.FC = ({ onFilterChange={onFilterChange} /> - {rows.length > 0 && !loadError && ( + {filteredBySearch.length > 0 && !loadError && ( {topologyLink(topologyUrl, t)} @@ -220,6 +231,8 @@ const RolloutList: React.FC = ({ emptyState={empty} isError={!!loadError} errorState={error || undefined} + itemCount={itemCount} + pagination={pagination} /> diff --git a/src/gitops/components/shared/ApplicationList.tsx b/src/gitops/components/shared/ApplicationList.tsx index 7c5d7ff98..9d864b68a 100644 --- a/src/gitops/components/shared/ApplicationList.tsx +++ b/src/gitops/components/shared/ApplicationList.tsx @@ -11,7 +11,6 @@ import { ListPageFilter, ListPageHeader, ResourceLink, - RowFilter, useK8sWatchResource, useListPageFilter, } from '@openshift-console/dynamic-plugin-sdk'; @@ -51,8 +50,24 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from './AllNamespaces'; +import { + APPLICATION_HEALTH_FILTER_PARAM, + APPLICATION_SYNC_FILTER_PARAM, + filterApplicationsByStatus, + getApplicationRowFilters, + parseRowFilterParam, +} from './applicationListFilters'; import ApplicationSetApplicationsView from './ApplicationSetApplicationsView'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from './DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from './DataView'; +import { + filterByConsoleNameAndLabels, + filterResourcesByLabelQuery, + parseLabelFilterParam, +} from './listPageTextFilters'; import MetadataLabels from './MetadataLabels'; interface ApplicationProps { @@ -141,26 +156,45 @@ const ApplicationList: React.FC = ({ [sortedApplications, project, appset], ); - // TODO: use alternate filter since it is deprecated. See DataTableView potentially - // PatternFly filters work on owned apps only (the dataset that will be displayed) - const filters = getFilters(t); - // const filters = React.useMemo(() => getFilters(t), [t]); - const [data, filteredData, onFilterChange] = useListPageFilter(ownedApps, filters); + // Apply the URL chips to the full owned list so the table and pager match + // what the user selected, even if the Console filter hook is out of date. + const filters = React.useMemo(() => getApplicationRowFilters(t), [t]); + const [data, , onFilterChange] = useListPageFilter(ownedApps, filters); + const healthFilterParam = searchParams.get(APPLICATION_HEALTH_FILTER_PARAM); + const syncFilterParam = searchParams.get(APPLICATION_SYNC_FILTER_PARAM); + const nameQuery = searchParams.get('name') || ''; + const labelsParam = searchParams.get('labels') || ''; + const filteredByStatus = React.useMemo( + () => + filterApplicationsByStatus( + data as ApplicationKind[], + parseRowFilterParam(healthFilterParam), + parseRowFilterParam(syncFilterParam), + ), + [data, healthFilterParam, syncFilterParam], + ); - // Filter by search query if present (after other filters) - const filteredBySearch = React.useMemo(() => { - if (!searchQuery) return filteredData; + const filteredByNameAndLabels = React.useMemo( + () => + filterByConsoleNameAndLabels(filteredByStatus, nameQuery, parseLabelFilterParam(labelsParam)), + [filteredByStatus, nameQuery, labelsParam], + ); - return filteredData.filter((app) => { - const labels = app.metadata?.labels || {}; - // Check if any label matches the search query - return Object.entries(labels).some(([key, value]) => { - const labelSelector = `${key}=${value}`; - return labelSelector.includes(searchQuery) || key.includes(searchQuery); - }); - }); - }, [filteredData, searchQuery]); - const rows = useApplicationRowsDV(filteredBySearch, namespace); + const filteredBySearch = React.useMemo( + () => filterResourcesByLabelQuery(filteredByNameAndLabels, searchQuery), + [filteredByNameAndLabels, searchQuery], + ); + + const { + pagination, + pagedItems: pagedApplications, + itemCount, + } = useGitOpsListPagePagination({ + items: filteredBySearch, + namespace, + searchParams, + }); + const rows = useApplicationRowsDV(pagedApplications, namespace); // Check if there are applications owned by this ApplicationSet initially (before filters/search) const hasOwnedApplications = ownedApps.length > 0; @@ -275,6 +309,8 @@ const ApplicationList: React.FC = ({ emptyState={empty} errorState={error || undefined} isError={!!loadError} + itemCount={itemCount} + pagination={pagination} /> )} {!appset && ( @@ -285,6 +321,8 @@ const ApplicationList: React.FC = ({ emptyState={empty} errorState={error || undefined} isError={!!loadError} + itemCount={itemCount} + pagination={pagination} /> )} @@ -559,57 +597,4 @@ const useColumnsDV = ( return columns; }; -const FilterUnknownStatus: string = 'Sync.' + SyncStatus.UNKNOWN; - -const getFilters = (t: (key: string) => string): RowFilter[] => [ - { - filterGroupName: t('Sync Status'), - type: 'app-sync', - reducer: (application) => - application.status?.sync?.status == SyncStatus.UNKNOWN || - application.status?.sync?.status == undefined - ? FilterUnknownStatus - : application.status?.sync?.status, - filter: (input, application) => { - if (input.selected?.length && application?.status?.sync?.status) { - return ( - input.selected.includes(application.status?.sync?.status) || - (input.selected.includes(FilterUnknownStatus) && - application.status?.sync?.status == SyncStatus.UNKNOWN) - ); - } else if (application.status?.sync?.status == undefined) { - return true; - } else if (!application?.status?.sync?.status) { - return false; - } - return true; - }, - items: [ - { id: SyncStatus.SYNCED, title: SyncStatus.SYNCED }, - { id: SyncStatus.OUT_OF_SYNC, title: SyncStatus.OUT_OF_SYNC }, - { id: FilterUnknownStatus, title: SyncStatus.UNKNOWN }, - ], - }, - { - filterGroupName: t('Health Status'), - type: 'app-health', - reducer: (application) => application.status?.health?.status, - filter: (input, application) => { - if (input.selected?.length && application?.status?.health?.status) { - return input.selected.includes(application.status?.health?.status); - } else { - return true; - } - }, - items: [ - { id: HealthStatus.UNKNOWN, title: HealthStatus.UNKNOWN }, - { id: HealthStatus.PROGRESSING, title: HealthStatus.PROGRESSING }, - { id: HealthStatus.SUSPENDED, title: HealthStatus.SUSPENDED }, - { id: HealthStatus.HEALTHY, title: HealthStatus.HEALTHY }, - { id: HealthStatus.DEGRADED, title: HealthStatus.DEGRADED }, - { id: HealthStatus.MISSING, title: HealthStatus.MISSING }, - ], - }, -]; - export default ApplicationList; diff --git a/src/gitops/components/shared/ApplicationSetApplicationsView.tsx b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx index 2ff53f875..367aa5f1a 100644 --- a/src/gitops/components/shared/ApplicationSetApplicationsView.tsx +++ b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx @@ -9,7 +9,7 @@ import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/Dat import { ApplicationSetGraphView } from '../appset/graph/ApplicationSetGraphView'; -import { GitOpsDataViewTable } from './DataView'; +import { type GitOpsDataViewPagination, GitOpsDataViewTable } from './DataView'; import GitOpsViewSwitcher from './GitOpsViewSwitcher'; import { APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, GitOpsViewType } from './GitOpsViewType'; @@ -31,6 +31,8 @@ type ApplicationSetApplicationsViewProps = { errorState?: React.ReactNode; isError?: boolean; isEmpty: boolean; + itemCount?: number; + pagination?: GitOpsDataViewPagination; }; const ApplicationSetApplicationsView: React.FC = ({ @@ -49,6 +51,8 @@ const ApplicationSetApplicationsView: React.FC { const [savedViewType, setSavedViewType, viewSettingsLoaded] = useUserSettings( APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, @@ -124,6 +128,8 @@ const ApplicationSetApplicationsView: React.FC { @@ -165,6 +174,8 @@ const ApplicationSetList: React.FC = ({ // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; + const nameQuery = searchParams.get('name') || ''; + const labelsParam = searchParams.get('labels') || ''; const columnsDV = useColumnsDV(namespace, getSortParams); const sortedApplicationSets = React.useMemo(() => { @@ -180,21 +191,22 @@ const ApplicationSetList: React.FC = ({ const filters = getFilters(t); const [data, filteredData, onFilterChange] = useListPageFilter(sortedApplicationSets, filters); - // Filter by search query if present (after other filters) - const filteredBySearch = React.useMemo(() => { - if (!searchQuery) return filteredData; + const filteredByNameAndLabels = React.useMemo( + () => filterByConsoleNameAndLabels(filteredData, nameQuery, parseLabelFilterParam(labelsParam)), + [filteredData, nameQuery, labelsParam], + ); - return filteredData.filter((appSet) => { - const labels = appSet.metadata?.labels || {}; - // Check if any label matches the search query - return Object.entries(labels).some(([key, value]) => { - const labelSelector = `${key}=${value}`; - return labelSelector.includes(searchQuery) || key.includes(searchQuery); - }); - }); - }, [filteredData, searchQuery]); + const filteredBySearch = React.useMemo( + () => filterResourcesByLabelQuery(filteredByNameAndLabels, searchQuery), + [filteredByNameAndLabels, searchQuery], + ); - const rows = useApplicationSetRowsDV(filteredBySearch, namespace, applications, appsLoaded); + const { pagination, pagedItems, itemCount } = useGitOpsListPagePagination({ + items: filteredBySearch, + namespace, + searchParams, + }); + const rows = useApplicationSetRowsDV(pagedItems, namespace, applications, appsLoaded); // Check if there are ApplicationSets initially (before search) const hasApplicationSets = React.useMemo(() => { @@ -290,6 +302,8 @@ const ApplicationSetList: React.FC = ({ isLoading={!loaded} isError={!!loadError} errorState={error} + itemCount={itemCount} + pagination={pagination} activeState={ // eslint-disable-next-line no-nested-ternary !loaded ? DataViewState.loading : isEmptyState ? DataViewState.empty : undefined diff --git a/src/gitops/components/shared/DataView/GitOpsDataViewTable.tsx b/src/gitops/components/shared/DataView/GitOpsDataViewTable.tsx index 5d221d6a9..1d8ea6169 100644 --- a/src/gitops/components/shared/DataView/GitOpsDataViewTable.tsx +++ b/src/gitops/components/shared/DataView/GitOpsDataViewTable.tsx @@ -1,14 +1,35 @@ import * as React from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; +import { useGitOpsTranslation } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { Pagination, PaginationVariant } from '@patternfly/react-core'; import DataView, { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; import DataViewTable, { DataViewTh, DataViewTr, } from '@patternfly/react-data-view/dist/esm/DataViewTable'; -import { useDataViewSort } from '@patternfly/react-data-view/dist/esm/Hooks'; +import DataViewToolbar from '@patternfly/react-data-view/dist/esm/DataViewToolbar'; +import { useDataViewPagination, useDataViewSort } from '@patternfly/react-data-view/dist/esm/Hooks'; import { ThProps } from '@patternfly/react-table'; +import { + getGitOpsPaginationResetKey, + GITOPS_DEFAULT_PER_PAGE, + GITOPS_PER_PAGE_OPTIONS, + paginateItems, +} from './gitOpsDataViewPagination'; + +let gitOpsPaginationInstanceCounter = 0; + +const useGitOpsPaginationWidgetIdBase = (): string => { + const idRef = React.useRef(); + if (!idRef.current) { + gitOpsPaginationInstanceCounter += 1; + idRef.current = `gitops-pagination-${gitOpsPaginationInstanceCounter}`; + } + return idRef.current; +}; + type BodyStateKey = 'empty' | 'error'; export type GitOpsDataViewBodyStates = Partial>; @@ -46,6 +67,28 @@ export type GitOpsDataViewTableProps = { * the appropriate state based on isEmpty and isError. */ activeState?: DataViewState | null; + /** + * Total number of filtered items. Required when pagination is enabled so the + * pager can show "1-50 of N" against the full result set, not the current page. + */ + itemCount?: number; + /** + * Optional PF pagination state. When omitted, the table renders every row. + */ + pagination?: GitOpsDataViewPagination; +}; + +export type GitOpsDataViewPagination = { + page: number; + perPage: number; + onSetPage: ( + event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined, + newPage: number, + ) => void; + onPerPageSelect: ( + event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined, + newPerPage: number, + ) => void; }; const mergeBodyStates = ( @@ -73,7 +116,10 @@ export const GitOpsDataViewTable: React.FC = ({ errorState, bodyStates, activeState, + itemCount, + pagination, }) => { + const paginationWidgetIdBase = useGitOpsPaginationWidgetIdBase(); const resolvedBodyStates = React.useMemo( () => mergeBodyStates(bodyStates, { @@ -99,13 +145,69 @@ export const GitOpsDataViewTable: React.FC = ({ return null; }, [activeState, isEmpty, isError, isLoading]); + const paginationItemCount = itemCount ?? 0; + const showPagination = !!pagination && paginationItemCount > 0 && !isError && !isLoading; + return ( + {showPagination && pagination && ( + + } + /> + )} + {showPagination && pagination && ( + + } + /> + )} ); }; +const GitOpsPagination: React.FC<{ + itemCount: number; + pagination: GitOpsDataViewPagination; + variant: PaginationVariant; + widgetId: string; +}> = ({ itemCount, pagination, variant, widgetId }) => { + const { t } = useGitOpsTranslation(); + + return ( + + ); +}; + export interface GitOpsDataViewSortConfig { key: string; } @@ -169,4 +271,94 @@ export const useGitOpsDataViewSort = ( }; }; +/** + * Client-side pagination for GitOps DataView tables. Matches Console: 10/20/50/100, + * default 50, page and perPage stored in the URL. Resets to page 1 when resetKey changes + * (filters, search, namespace) and clamps when the result set shrinks. + */ +export const useGitOpsDataViewPagination = ({ + itemCount, + resetKey, +}: { + itemCount: number; + resetKey?: string; +}): GitOpsDataViewPagination => { + const [searchParams, setSearchParams] = useSearchParams(); + + const setMergedSearchParams = React.useCallback( + (params: URLSearchParams) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + params.forEach((value, key) => { + next.set(key, value); + }); + return next; + }); + }, + [setSearchParams], + ); + + const pagination = useDataViewPagination({ + perPage: GITOPS_DEFAULT_PER_PAGE, + searchParams, + setSearchParams: setMergedSearchParams, + }); + const { page, perPage, onSetPage } = pagination; + const previousResetKey = React.useRef(resetKey); + + React.useEffect(() => { + if (resetKey === undefined || previousResetKey.current === resetKey) { + return; + } + previousResetKey.current = resetKey; + if (page > 1) { + onSetPage(undefined, 1); + } + }, [onSetPage, page, resetKey]); + + React.useEffect(() => { + const maxPage = Math.max(1, Math.ceil(itemCount / perPage) || 1); + if (page > maxPage) { + onSetPage(undefined, maxPage); + } + }, [itemCount, onSetPage, page, perPage]); + + return pagination; +}; + +/** + * Wires URL pagination for a filtered GitOps list: reset on filter/search/namespace + * changes, then return the current page of items for the table. + */ +export const useGitOpsListPagePagination = ({ + items, + namespace, + searchParams, +}: { + items: T[] | undefined; + namespace?: string | null; + searchParams: URLSearchParams; +}): { + pagination: GitOpsDataViewPagination; + pagedItems: T[]; + itemCount: number; +} => { + const searchParamsKey = searchParams.toString(); + const paginationResetKey = React.useMemo( + () => getGitOpsPaginationResetKey(namespace, new URLSearchParams(searchParamsKey)), + [namespace, searchParamsKey], + ); + const itemCount = items?.length ?? 0; + const pagination = useGitOpsDataViewPagination({ + itemCount, + resetKey: paginationResetKey, + }); + const pagedItems = React.useMemo( + () => paginateItems(items, pagination.page, pagination.perPage), + [items, pagination.page, pagination.perPage], + ); + + return { pagination, pagedItems, itemCount }; +}; + type GitOpsSetSearchParams = ReturnType[1]; diff --git a/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts new file mode 100644 index 000000000..5df364c09 --- /dev/null +++ b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts @@ -0,0 +1,86 @@ +import { getGitOpsPaginationResetKey, paginateItems } from './gitOpsDataViewPagination'; + +describe('paginateItems', () => { + const items = ['a', 'b', 'c', 'd', 'e']; + + it('returns the first page', () => { + expect(paginateItems(items, 1, 2)).toEqual(['a', 'b']); + }); + + it('returns a middle page', () => { + expect(paginateItems(items, 2, 2)).toEqual(['c', 'd']); + }); + + it('returns a partial last page', () => { + expect(paginateItems(items, 3, 2)).toEqual(['e']); + }); + + it('treats missing items as an empty list', () => { + expect(paginateItems(undefined, 1, 50)).toEqual([]); + }); + + it('shows page 1 when the requested page is 0', () => { + expect(paginateItems(items, 0, 2)).toEqual(['a', 'b']); + }); + + it('shows at least one item when items per page is 0', () => { + expect(paginateItems(items, 1, 0)).toEqual(['a']); + }); + + it('returns an empty page past the end of the list', () => { + expect(paginateItems(items, 10, 2)).toEqual([]); + }); +}); + +describe('getGitOpsPaginationResetKey', () => { + it('ignores pagination and sort URL params', () => { + const params = new URLSearchParams( + 'page=3&perPage=20&sortBy=name&direction=asc&q=guestbook&rowFilter-app-sync=Synced', + ); + + expect(getGitOpsPaginationResetKey('argocd', params)).toBe( + 'argocd|q=guestbook&rowFilter-app-sync=Synced', + ); + }); + + it('changes when namespace or filters change', () => { + const params = new URLSearchParams('q=guestbook'); + + expect(getGitOpsPaginationResetKey('argocd', params)).not.toBe( + getGitOpsPaginationResetKey('openshift-gitops', params), + ); + expect(getGitOpsPaginationResetKey('argocd', params)).not.toBe( + getGitOpsPaginationResetKey('argocd', new URLSearchParams('q=other')), + ); + }); + + it('changes when health status chips change but not when only the page changes', () => { + const health = new URLSearchParams('page=2&rowFilter-app-health=Healthy'); + const nextPage = new URLSearchParams('page=3&rowFilter-app-health=Healthy'); + const otherHealth = new URLSearchParams('page=2&rowFilter-app-health=Degraded'); + + expect(getGitOpsPaginationResetKey('argocd', health)).toBe( + getGitOpsPaginationResetKey('argocd', nextPage), + ); + expect(getGitOpsPaginationResetKey('argocd', health)).not.toBe( + getGitOpsPaginationResetKey('argocd', otherHealth), + ); + }); + + it('changes when name or label chips change but not when only the page changes', () => { + const named = new URLSearchParams('page=2&name=appsss'); + const nextPage = new URLSearchParams('page=3&name=appsss'); + const otherName = new URLSearchParams('page=2&name=guestbook'); + const labeled = new URLSearchParams('page=2&labels=app=guestbook'); + + expect(getGitOpsPaginationResetKey('argocd', named)).toBe( + getGitOpsPaginationResetKey('argocd', nextPage), + ); + expect(getGitOpsPaginationResetKey('argocd', named)).not.toBe( + getGitOpsPaginationResetKey('argocd', otherName), + ); + expect(getGitOpsPaginationResetKey('argocd', named)).not.toBe( + getGitOpsPaginationResetKey('argocd', labeled), + ); + }); +}); diff --git a/src/gitops/components/shared/DataView/gitOpsDataViewPagination.ts b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.ts new file mode 100644 index 000000000..7a6577dfe --- /dev/null +++ b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.ts @@ -0,0 +1,30 @@ +export const GITOPS_DEFAULT_PER_PAGE = 50; + +export const GITOPS_PER_PAGE_OPTIONS = [ + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + { title: '100', value: 100 }, +]; + +const PAGINATION_RESET_IGNORE_PARAMS = new Set(['page', 'perPage', 'sortBy', 'direction']); + +export const paginateItems = (items: T[] | undefined, page: number, perPage: number): T[] => { + const list = items ?? []; + const safePage = Math.max(page, 1); + const safePerPage = Math.max(perPage, 1); + const start = (safePage - 1) * safePerPage; + return list.slice(start, start + safePerPage); +}; + +export const getGitOpsPaginationResetKey = ( + namespace: string | null | undefined, + searchParams: URLSearchParams, +): string => { + const filtered = [...searchParams.entries()] + .filter(([key]) => !PAGINATION_RESET_IGNORE_PARAMS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + return `${namespace ?? ''}|${filtered}`; +}; diff --git a/src/gitops/components/shared/DataView/index.ts b/src/gitops/components/shared/DataView/index.ts index bb7c776b2..e973ddd7e 100644 --- a/src/gitops/components/shared/DataView/index.ts +++ b/src/gitops/components/shared/DataView/index.ts @@ -1,8 +1,17 @@ +export { + getGitOpsPaginationResetKey, + GITOPS_DEFAULT_PER_PAGE, + GITOPS_PER_PAGE_OPTIONS, + paginateItems, +} from './gitOpsDataViewPagination'; export { type GitOpsDataViewBodyStates, + type GitOpsDataViewPagination, type GitOpsDataViewSortConfig, type GitOpsDataViewTableProps, type UseGitOpsDataViewSortResult, GitOpsDataViewTable, + useGitOpsDataViewPagination, useGitOpsDataViewSort, + useGitOpsListPagePagination, } from './GitOpsDataViewTable'; diff --git a/src/gitops/components/shared/applicationListFilters.test.ts b/src/gitops/components/shared/applicationListFilters.test.ts new file mode 100644 index 000000000..41284b4a4 --- /dev/null +++ b/src/gitops/components/shared/applicationListFilters.test.ts @@ -0,0 +1,150 @@ +import { HealthStatus, SyncStatus } from '../../utils/constants'; + +import { + APPLICATION_SYNC_UNKNOWN_FILTER_ID, + filterApplicationsByStatus, + getApplicationHealthStatus, + getApplicationSyncFilterId, + matchesApplicationHealthFilter, + matchesApplicationSyncFilter, + parseRowFilterParam, +} from './applicationListFilters'; +import { paginateItems } from './DataView/gitOpsDataViewPagination'; + +const app = (name: string, health?: string, sync?: string, labels?: Record) => + ({ + metadata: { name, labels }, + spec: { project: 'default' }, + status: { + health: health ? { status: health } : undefined, + sync: sync ? { status: sync } : undefined, + }, + } as any); + +describe('parseRowFilterParam', () => { + it('splits Console rowFilter query values', () => { + expect(parseRowFilterParam('Healthy,Degraded')).toEqual(['Healthy', 'Degraded']); + }); + + it('returns an empty list when unset', () => { + expect(parseRowFilterParam(null)).toEqual([]); + expect(parseRowFilterParam('')).toEqual([]); + }); +}); + +describe('application health filter', () => { + it('treats missing health as Unknown', () => { + expect(getApplicationHealthStatus(app('none'))).toBe(HealthStatus.UNKNOWN); + expect(getApplicationHealthStatus(app('ok', HealthStatus.HEALTHY))).toBe(HealthStatus.HEALTHY); + }); + + it('passes all apps when nothing is selected', () => { + expect(matchesApplicationHealthFilter([], app('missing', HealthStatus.MISSING))).toBe(true); + expect(matchesApplicationHealthFilter(undefined, app('none'))).toBe(true); + }); + + it('keeps Healthy and Degraded and drops Missing', () => { + const selected = [HealthStatus.HEALTHY, HealthStatus.DEGRADED]; + expect(matchesApplicationHealthFilter(selected, app('ok', HealthStatus.HEALTHY))).toBe(true); + expect(matchesApplicationHealthFilter(selected, app('bad', HealthStatus.DEGRADED))).toBe(true); + expect(matchesApplicationHealthFilter(selected, app('gone', HealthStatus.MISSING))).toBe(false); + expect(matchesApplicationHealthFilter(selected, app('wait', HealthStatus.PROGRESSING))).toBe( + false, + ); + }); + + it('does not keep apps with no health status when Healthy is selected', () => { + expect(matchesApplicationHealthFilter([HealthStatus.HEALTHY], app('none'))).toBe(false); + }); + + it('filters a list to the selected health statuses', () => { + const apps = [ + app('a', HealthStatus.HEALTHY), + app('b', HealthStatus.MISSING), + app('c', HealthStatus.PROGRESSING), + app('d', HealthStatus.HEALTHY), + ]; + expect( + filterApplicationsByStatus(apps, [HealthStatus.HEALTHY, HealthStatus.DEGRADED], []), + ).toHaveLength(2); + }); +}); + +describe('application sync filter', () => { + it('maps missing and Unknown sync to the Unknown filter id', () => { + expect(getApplicationSyncFilterId(app('none'))).toBe(APPLICATION_SYNC_UNKNOWN_FILTER_ID); + expect( + getApplicationSyncFilterId(app('unknown', HealthStatus.HEALTHY, SyncStatus.UNKNOWN)), + ).toBe(APPLICATION_SYNC_UNKNOWN_FILTER_ID); + expect(getApplicationSyncFilterId(app('ok', HealthStatus.HEALTHY, SyncStatus.SYNCED))).toBe( + SyncStatus.SYNCED, + ); + }); + + it('does not keep unknown/missing sync when Synced is selected', () => { + expect( + matchesApplicationSyncFilter([SyncStatus.SYNCED], app('none', HealthStatus.HEALTHY)), + ).toBe(false); + expect( + matchesApplicationSyncFilter( + [SyncStatus.SYNCED], + app('unknown', HealthStatus.HEALTHY, SyncStatus.UNKNOWN), + ), + ).toBe(false); + }); + + it('filters by sync independently of health', () => { + const apps = [ + app('synced', HealthStatus.HEALTHY, SyncStatus.SYNCED), + app('out', HealthStatus.HEALTHY, SyncStatus.OUT_OF_SYNC), + ]; + expect(filterApplicationsByStatus(apps, [], [SyncStatus.SYNCED])).toHaveLength(1); + expect(filterApplicationsByStatus(apps, [], [SyncStatus.SYNCED])[0].metadata.name).toBe( + 'synced', + ); + }); +}); + +describe('application list sort, filter, and pagination', () => { + const apps = [ + app('zeta', HealthStatus.HEALTHY, SyncStatus.SYNCED, { app: 'guestbook' }), + app('alpha', HealthStatus.MISSING, SyncStatus.OUT_OF_SYNC, { team: 'platform' }), + app('beta', HealthStatus.HEALTHY, SyncStatus.SYNCED, { env: 'prod' }), + app('gamma', HealthStatus.DEGRADED, SyncStatus.OUT_OF_SYNC), + app('delta', HealthStatus.HEALTHY, SyncStatus.SYNCED, { app: 'demo' }), + ]; + + const byName = (items: { metadata: { name: string } }[]) => + [...items].sort((left, right) => left.metadata.name.localeCompare(right.metadata.name)); + + it('sorts then filters by health, then paginates', () => { + const sorted = byName(apps); + expect(sorted.map((item) => item.metadata.name)).toEqual([ + 'alpha', + 'beta', + 'delta', + 'gamma', + 'zeta', + ]); + + const healthy = filterApplicationsByStatus(sorted, [HealthStatus.HEALTHY], []); + expect(healthy.map((item) => item.metadata.name)).toEqual(['beta', 'delta', 'zeta']); + + expect(paginateItems(healthy, 1, 2).map((item) => item.metadata.name)).toEqual([ + 'beta', + 'delta', + ]); + expect(paginateItems(healthy, 2, 2).map((item) => item.metadata.name)).toEqual(['zeta']); + }); + + it('applies health and sync together before paging', () => { + const healthySynced = filterApplicationsByStatus( + byName(apps), + [HealthStatus.HEALTHY], + [SyncStatus.SYNCED], + ); + expect(healthySynced.map((item) => item.metadata.name)).toEqual(['beta', 'delta', 'zeta']); + expect(paginateItems(healthySynced, 1, 2)).toHaveLength(2); + expect(paginateItems(healthySynced, 1, 2)[0].metadata.name).toBe('beta'); + }); +}); diff --git a/src/gitops/components/shared/applicationListFilters.ts b/src/gitops/components/shared/applicationListFilters.ts new file mode 100644 index 000000000..bf9ed6d0a --- /dev/null +++ b/src/gitops/components/shared/applicationListFilters.ts @@ -0,0 +1,89 @@ +import { RowFilter } from '@openshift-console/dynamic-plugin-sdk'; + +import { ApplicationKind } from '../../models/ApplicationModel'; +import { HealthStatus, SyncStatus } from '../../utils/constants'; + +export const APPLICATION_SYNC_FILTER_TYPE = 'app-sync'; +export const APPLICATION_HEALTH_FILTER_TYPE = 'app-health'; +export const APPLICATION_SYNC_FILTER_PARAM = `rowFilter-${APPLICATION_SYNC_FILTER_TYPE}`; +export const APPLICATION_HEALTH_FILTER_PARAM = `rowFilter-${APPLICATION_HEALTH_FILTER_TYPE}`; +export const APPLICATION_SYNC_UNKNOWN_FILTER_ID = `Sync.${SyncStatus.UNKNOWN}`; + +export const getApplicationHealthStatus = ( + application: Pick | undefined, +): HealthStatus => (application?.status?.health?.status as HealthStatus) || HealthStatus.UNKNOWN; + +export const getApplicationSyncFilterId = ( + application: Pick | undefined, +): string => { + const status = application?.status?.sync?.status; + if (!status || status === SyncStatus.UNKNOWN) { + return APPLICATION_SYNC_UNKNOWN_FILTER_ID; + } + return status; +}; + +export const parseRowFilterParam = (value: string | null | undefined): string[] => + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +export const matchesApplicationHealthFilter = ( + selected: string[] | undefined, + application: Pick | undefined, +): boolean => { + if (!selected?.length) { + return true; + } + return selected.includes(getApplicationHealthStatus(application)); +}; + +export const matchesApplicationSyncFilter = ( + selected: string[] | undefined, + application: Pick | undefined, +): boolean => { + if (!selected?.length) { + return true; + } + return selected.includes(getApplicationSyncFilterId(application)); +}; + +export const filterApplicationsByStatus = ( + applications: ApplicationKind[] | undefined, + healthSelected: string[], + syncSelected: string[], +): ApplicationKind[] => + (applications ?? []).filter( + (application) => + matchesApplicationHealthFilter(healthSelected, application) && + matchesApplicationSyncFilter(syncSelected, application), + ); + +export const getApplicationRowFilters = (t: (key: string) => string): RowFilter[] => [ + { + filterGroupName: t('Sync Status'), + type: APPLICATION_SYNC_FILTER_TYPE, + reducer: (application) => getApplicationSyncFilterId(application), + filter: (input, application) => matchesApplicationSyncFilter(input.selected, application), + items: [ + { id: SyncStatus.SYNCED, title: SyncStatus.SYNCED }, + { id: SyncStatus.OUT_OF_SYNC, title: SyncStatus.OUT_OF_SYNC }, + { id: APPLICATION_SYNC_UNKNOWN_FILTER_ID, title: SyncStatus.UNKNOWN }, + ], + }, + { + filterGroupName: t('Health Status'), + type: APPLICATION_HEALTH_FILTER_TYPE, + reducer: (application) => getApplicationHealthStatus(application), + filter: (input, application) => matchesApplicationHealthFilter(input.selected, application), + items: [ + { id: HealthStatus.UNKNOWN, title: HealthStatus.UNKNOWN }, + { id: HealthStatus.PROGRESSING, title: HealthStatus.PROGRESSING }, + { id: HealthStatus.SUSPENDED, title: HealthStatus.SUSPENDED }, + { id: HealthStatus.HEALTHY, title: HealthStatus.HEALTHY }, + { id: HealthStatus.DEGRADED, title: HealthStatus.DEGRADED }, + { id: HealthStatus.MISSING, title: HealthStatus.MISSING }, + ], + }, +]; diff --git a/src/gitops/components/shared/listPageTextFilters.test.ts b/src/gitops/components/shared/listPageTextFilters.test.ts new file mode 100644 index 000000000..2698f8107 --- /dev/null +++ b/src/gitops/components/shared/listPageTextFilters.test.ts @@ -0,0 +1,128 @@ +import { paginateItems } from './DataView/gitOpsDataViewPagination'; +import { + filterByConsoleNameAndLabels, + filterResourcesByLabelQuery, + fuzzySearch, + matchesConsoleLabelFilter, + matchesConsoleNameFilter, + matchesLabelSearchQuery, + parseLabelFilterParam, +} from './listPageTextFilters'; + +const resource = (name: string, labels?: Record) => ({ + metadata: { name, labels }, +}); + +describe('fuzzySearch (Console Name filter)', () => { + it('matches when query letters appear in order, including the Application screenshot case', () => { + expect(fuzzySearch('appsss', 'app-matrix-2-staging-us-east')).toBe(true); + expect(fuzzySearch('appsss', 'app-matrix-2-staging-us-west')).toBe(true); + }); + + it('matches the ApplicationSet screenshot case', () => { + expect(fuzzySearch('testss', 'test-sorting-appset')).toBe(true); + expect(fuzzySearch('testss', 'test-sorting-single-generator')).toBe(true); + }); + + it('does not match when letters are missing or out of order', () => { + expect(fuzzySearch('appsss', 'guestbook')).toBe(false); + expect(fuzzySearch('zzz', 'app-matrix-2-staging-us-east')).toBe(false); + expect(fuzzySearch('ssa', 'app')).toBe(false); + }); + + it('matches an exact or contiguous substring', () => { + expect(fuzzySearch('guestbook', 'guestbook')).toBe(true); + expect(fuzzySearch('book', 'guestbook')).toBe(true); + }); +}); + +describe('matchesConsoleNameFilter', () => { + it('keeps every resource when the name query is empty', () => { + expect(matchesConsoleNameFilter('', 'guestbook')).toBe(true); + expect(matchesConsoleNameFilter(null, 'guestbook')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(matchesConsoleNameFilter('APP', 'app-matrix-2-staging-us-east')).toBe(true); + expect(matchesConsoleNameFilter('TestSS', 'test-sorting-appset')).toBe(true); + }); +}); + +describe('matchesConsoleLabelFilter', () => { + const labels = { app: 'guestbook', env: 'prod' }; + + it('keeps every resource when no label chips are selected', () => { + expect(matchesConsoleLabelFilter([], labels)).toBe(true); + expect(matchesConsoleLabelFilter(undefined, labels)).toBe(true); + }); + + it('requires every chip to match a key=value label', () => { + expect(matchesConsoleLabelFilter(['app=guestbook'], labels)).toBe(true); + expect(matchesConsoleLabelFilter(['app=guestbook', 'env=prod'], labels)).toBe(true); + expect(matchesConsoleLabelFilter(['app=guestbook', 'env=stage'], labels)).toBe(false); + expect(matchesConsoleLabelFilter(['team=platform'], labels)).toBe(false); + }); + + it('parses Console labels query values', () => { + expect(parseLabelFilterParam('app=guestbook,env=prod')).toEqual(['app=guestbook', 'env=prod']); + expect(parseLabelFilterParam(null)).toEqual([]); + }); +}); + +describe('matchesLabelSearchQuery (GitOps q param)', () => { + const labels = { app: 'guestbook', env: 'prod' }; + + it('matches label keys and key=value, and ignores resource name', () => { + expect(matchesLabelSearchQuery('', labels)).toBe(true); + expect(matchesLabelSearchQuery('guestbook', labels)).toBe(true); + expect(matchesLabelSearchQuery('GUESTBOOK', labels)).toBe(true); + expect(matchesLabelSearchQuery('app=', labels)).toBe(true); + expect(matchesLabelSearchQuery('env', labels)).toBe(true); + expect(matchesLabelSearchQuery('missing', labels)).toBe(false); + expect(matchesLabelSearchQuery('guestbook', undefined)).toBe(false); + }); +}); + +describe('name and label filters with pagination', () => { + const items = [ + resource('app-matrix-2-staging-us-east', { app: 'matrix' }), + resource('app-matrix-2-staging-us-west', { app: 'matrix' }), + resource('guestbook', { app: 'guestbook' }), + resource('test-sorting-appset'), + ]; + + it('filters by Console name then paginates the filtered set', () => { + const named = filterByConsoleNameAndLabels(items, 'appsss', []); + expect(named.map((item) => item.metadata.name)).toEqual([ + 'app-matrix-2-staging-us-east', + 'app-matrix-2-staging-us-west', + ]); + expect(paginateItems(named, 1, 1).map((item) => item.metadata.name)).toEqual([ + 'app-matrix-2-staging-us-east', + ]); + expect(paginateItems(named, 2, 1).map((item) => item.metadata.name)).toEqual([ + 'app-matrix-2-staging-us-west', + ]); + }); + + it('filters by Console labels then paginates', () => { + const labeled = filterByConsoleNameAndLabels(items, '', ['app=guestbook']); + expect(labeled.map((item) => item.metadata.name)).toEqual(['guestbook']); + expect(paginateItems(labeled, 1, 50)).toHaveLength(1); + }); + + it('applies name and labels together', () => { + const both = filterByConsoleNameAndLabels(items, 'appsss', ['app=matrix']); + expect(both.map((item) => item.metadata.name)).toEqual([ + 'app-matrix-2-staging-us-east', + 'app-matrix-2-staging-us-west', + ]); + expect(filterByConsoleNameAndLabels(items, 'appsss', ['app=guestbook'])).toEqual([]); + }); + + it('applies the q label search without matching names', () => { + const byQ = filterResourcesByLabelQuery(items, 'guestbook'); + expect(byQ.map((item) => item.metadata.name)).toEqual(['guestbook']); + expect(filterResourcesByLabelQuery(items, 'app-matrix')).toEqual([]); + }); +}); diff --git a/src/gitops/components/shared/listPageTextFilters.ts b/src/gitops/components/shared/listPageTextFilters.ts new file mode 100644 index 000000000..50ee5760c --- /dev/null +++ b/src/gitops/components/shared/listPageTextFilters.ts @@ -0,0 +1,103 @@ +type LabeledResource = { + metadata?: { + name?: string; + labels?: Record; + }; +}; + +/** + * Console ListPageFilter Name filter uses fuzzysearch on metadata.name + * (case-insensitive). Letters of the query must appear in order, not as a + * contiguous substring. + */ +export const fuzzySearch = (needle: string, haystack: string): boolean => { + const needleLength = needle.length; + const haystackLength = haystack.length; + if (needleLength > haystackLength) { + return false; + } + if (needleLength === haystackLength) { + return needle === haystack; + } + let haystackIndex = 0; + for (let needleIndex = 0; needleIndex < needleLength; needleIndex++) { + const needleCode = needle.charCodeAt(needleIndex); + let found = false; + while (haystackIndex < haystackLength) { + if (haystack.charCodeAt(haystackIndex++) === needleCode) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +}; + +export const matchesConsoleNameFilter = ( + query: string | null | undefined, + name: string | undefined, +): boolean => { + if (!query) { + return true; + } + return fuzzySearch(query.toLowerCase(), (name || '').toLowerCase()); +}; + +export const parseLabelFilterParam = (value: string | null | undefined): string[] => + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +export const matchesConsoleLabelFilter = ( + selected: string[] | undefined, + labels: Record | undefined, +): boolean => { + if (!selected?.length) { + return true; + } + const labelStrings = Object.entries(labels || {}).map(([key, val]) => `${key}=${val ?? ''}`); + return selected.every((chip) => labelStrings.some((label) => label.includes(chip))); +}; + +/** Extra GitOps `q` pass used by list pages: match label key or key=value. */ +export const matchesLabelSearchQuery = ( + query: string, + labels: Record | undefined, +): boolean => { + if (!query) { + return true; + } + const normalizedQuery = query.toLowerCase(); + return Object.entries(labels || {}).some(([key, value]) => { + const labelSelector = `${key}=${value}`; + return ( + labelSelector.toLowerCase().includes(normalizedQuery) || + key.toLowerCase().includes(normalizedQuery) + ); + }); +}; + +export const filterByConsoleNameAndLabels = ( + items: T[] | undefined, + nameQuery: string | null | undefined, + labelChips: string[] | undefined, +): T[] => + (items ?? []).filter( + (item) => + matchesConsoleNameFilter(nameQuery, item.metadata?.name) && + matchesConsoleLabelFilter(labelChips, item.metadata?.labels), + ); + +export const filterResourcesByLabelQuery = ( + items: T[] | undefined, + query: string, +): T[] => { + if (!query) { + return items ?? []; + } + return (items ?? []).filter((item) => matchesLabelSearchQuery(query, item.metadata?.labels)); +};