diff --git a/src/components/common/DataViewFilterToolbar.tsx b/src/components/common/DataViewFilterToolbar.tsx index f65b8ac4..f90e27ac 100644 --- a/src/components/common/DataViewFilterToolbar.tsx +++ b/src/components/common/DataViewFilterToolbar.tsx @@ -32,6 +32,7 @@ export interface CheckboxFilterConfig { placeholder?: string; options: FilterOption[]; defaultValues?: string[]; + singleSelect?: boolean; } export interface FilterValues { @@ -73,6 +74,11 @@ const CheckboxFilterInput: FC<{ const onMenuSelect = (_event: unknown, itemId: string | number) => { const id = String(itemId); + if (config.singleSelect) { + onSelect([id]); + setIsOpen(false); + return; + } const newSelected = selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]; @@ -110,14 +116,16 @@ const CheckboxFilterInput: FC<{ onClick={handleToggleClick} isExpanded={isOpen} isFullWidth - icon={} + icon={config.singleSelect ? undefined : } badge={ - selected.length > 0 ? ( + !config.singleSelect && selected.length > 0 ? ( {selected.length} ) : undefined } > - {config.placeholder || config.title} + {config.singleSelect && selected.length > 0 + ? config.options.find((o) => o.value === selected[0])?.label + : config.placeholder || config.title} } triggerRef={toggleRef} @@ -134,15 +142,17 @@ const CheckboxFilterInput: FC<{ {option.label} - - {option.totalCount !== undefined - ? `${option.count}/${option.totalCount}` - : option.count} - + {!config.singleSelect && ( + + {option.totalCount !== undefined + ? `${option.count}/${option.totalCount}` + : option.count} + + )} ))} diff --git a/src/components/hooks/__tests__/useDateRangeFilter.spec.ts b/src/components/hooks/__tests__/useDateRangeFilter.spec.ts new file mode 100644 index 00000000..bdd33d76 --- /dev/null +++ b/src/components/hooks/__tests__/useDateRangeFilter.spec.ts @@ -0,0 +1,66 @@ +import { + useFlag, + useUserPreference, +} from '@openshift-console/dynamic-plugin-sdk'; +import { testHook } from '../../../test-data/utils/hooks-utils'; +import { useDateRangeFilter } from '../useDateRangeFilter'; + +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + useFlag: jest.fn(), + useUserPreference: jest.fn(), +})); + +const useFlagMock = useFlag as jest.Mock; +const useUserPreferenceMock = useUserPreference as jest.Mock; + +const ONE_DAY_MS = 86400000; +const ONE_WEEK_MS = 604800000; + +describe('useDateRangeFilter', () => { + let setTimespanMock: jest.Mock; + + beforeEach(() => { + setTimespanMock = jest.fn(); + useFlagMock.mockReturnValue(true); + useUserPreferenceMock.mockReturnValue([ONE_DAY_MS, setTimespanMock]); + }); + + it('should return timespan from user preference', () => { + const { result } = testHook(() => useDateRangeFilter()); + expect(result.current.timespan).toBe(ONE_DAY_MS); + }); + + it('should compute startDate as Date.now() - timespan', () => { + const now = Date.now(); + const { result } = testHook(() => useDateRangeFilter()); + const diff = now - result.current.timespan; + expect(result.current.startDate).toBeGreaterThanOrEqual(diff - 100); + expect(result.current.startDate).toBeLessThanOrEqual(diff + 100); + }); + + it('should generate a valid CEL expression', () => { + const { result } = testHook(() => useDateRangeFilter()); + expect(result.current.dateFilterCEL).toMatch( + /^data\.status\.startTime > timestamp\(".*"\)$/, + ); + }); + + it('should default to 1 day when useUserPreference returns undefined', () => { + useUserPreferenceMock.mockReturnValue([undefined, setTimespanMock]); + const { result } = testHook(() => useDateRangeFilter()); + expect(result.current.timespan).toBe(ONE_DAY_MS); + expect(result.current.dateFilterCEL).not.toBe(''); + }); + + it('should expose setTimespan from the preference hook', () => { + const { result } = testHook(() => useDateRangeFilter()); + result.current.setTimespan(ONE_WEEK_MS); + expect(setTimespanMock).toHaveBeenCalledWith(ONE_WEEK_MS); + }); + + it('should reflect the isTektonResultEnabled flag', () => { + useFlagMock.mockReturnValue(false); + const { result } = testHook(() => useDateRangeFilter()); + expect(result.current.isTektonResultEnabled).toBe(false); + }); +}); diff --git a/src/components/hooks/useDateRangeFilter.ts b/src/components/hooks/useDateRangeFilter.ts new file mode 100644 index 00000000..957bd1ae --- /dev/null +++ b/src/components/hooks/useDateRangeFilter.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { + useFlag, + useUserPreference, +} from '@openshift-console/dynamic-plugin-sdk'; +import { FLAG_PIPELINE_TEKTON_RESULT_INSTALLED } from '../../consts'; +import { parsePrometheusDuration } from '../pipelines-overview/dateTime'; + +export type DateRangeFilterResult = { + timespan: number; + setTimespan: (ms: number) => void; + startDate: number | undefined; + dateFilterCEL: string; + isTektonResultEnabled: boolean; + preferenceLoaded: boolean; +}; + +const SETTINGS_KEY = 'plugin__pipelines-console-plugin.dateRangeFilter'; + +export const useDateRangeFilter = (): DateRangeFilterResult => { + const isTektonResultEnabled = useFlag(FLAG_PIPELINE_TEKTON_RESULT_INSTALLED); + const [timespan, setTimespan, preferenceLoaded] = useUserPreference( + SETTINGS_KEY, + parsePrometheusDuration('1d'), + true, + ); + + const ts = timespan ?? parsePrometheusDuration('1d'); + + const startDate = useMemo(() => { + if (!ts) return undefined; + return Date.now() - ts; + }, [ts]); + + const dateFilterCEL = useMemo(() => { + if (!startDate) return ''; + return `data.status.startTime > timestamp("${new Date( + startDate, + ).toISOString()}")`; + }, [startDate]); + + return { + timespan: ts, + setTimespan, + startDate, + dateFilterCEL, + isTektonResultEnabled, + preferenceLoaded, + }; +}; diff --git a/src/components/hooks/useTaskRuns.ts b/src/components/hooks/useTaskRuns.ts index 1638de94..fb315be2 100644 --- a/src/components/hooks/useTaskRuns.ts +++ b/src/components/hooks/useTaskRuns.ts @@ -21,7 +21,7 @@ import { } from '../../models'; import { ApprovalTaskKind, PipelineRunKind, TaskRunKind } from '../../types'; import { useDeepCompareMemoize } from '../utils/common-utils'; -import { EQ } from '../utils/tekton-results'; +import { AND, EQ } from '../utils/tekton-results'; import { useMultiClusterProxyService } from './useMultiClusterProxyService'; import { useMultiClusterTaskRuns } from './useMultiClusterTaskRuns'; import { useTRRuns } from './useTektonResults'; @@ -191,12 +191,14 @@ export const usePipelineRuns = ( selector?: Selector; limit?: number; name?: string; + filter?: string; }, ): [PipelineRunKind[], boolean, boolean, Error | undefined, boolean, boolean] => useRuns(PIPELINE_RUN_GVK, namespace, { selector: options?.selector, limit: options?.limit /* similar to one present in UseTaskRunsOptions */, name: options?.name /* similar to one present in UseTaskRunsOptions */, + filter: options?.filter, }); export const useRuns = ( @@ -207,6 +209,7 @@ export const useRuns = ( limit?: number; name?: string; skipFetch?: boolean; + filter?: string; // CEL expression sent to Tekton Results to retrieve PRs within the date range }, pipelineRunFinished?: boolean, pipelineRunManagedBy?: string, @@ -315,10 +318,10 @@ export const useRuns = ( const trOptions: typeof optionsMemo = useMemo(() => { if (optionsMemo?.name) { - const { name, ...rest } = optionsMemo; + const { name, filter, ...rest } = optionsMemo; return { ...rest, - filter: EQ('data.metadata.name', name), + filter: AND(EQ('data.metadata.name', name), filter), }; } return optionsMemo; diff --git a/src/components/hooks/useTektonResult.ts b/src/components/hooks/useTektonResult.ts index ef0cc664..0f44131a 100644 --- a/src/components/hooks/useTektonResult.ts +++ b/src/components/hooks/useTektonResult.ts @@ -1,5 +1,5 @@ import { Selector, useFlag } from '@openshift-console/dynamic-plugin-sdk'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { RepositoryFields, RepositoryLabels } from '../../consts'; import { FLAGS, PipelineRunKind } from '../../types'; import { getTaskRunLog } from '../utils/tekton-results'; @@ -9,7 +9,7 @@ export type GetNextPage = () => void | undefined; export const useGetPipelineRuns = ( ns: string, - options?: { name: string; kind: string }, + options?: { name: string; kind: string; filter?: string }, ): [ PipelineRunKind[], boolean, @@ -31,12 +31,18 @@ export const useGetPipelineRuns = ( }; } - return usePipelineRuns( - ns, - selector && { - selector, - }, - ); + const pipelineRunOptions = useMemo(() => { + const opts: Record = {}; + if (selector) { + opts.selector = selector; + } + if (options?.filter) { + opts.filter = options.filter; + } + return opts; + }, [selector, options?.filter]); + + return usePipelineRuns(ns, pipelineRunOptions); }; export const useTRTaskRunLog = ( diff --git a/src/components/pipelineRuns-list/PipelineRunsList.tsx b/src/components/pipelineRuns-list/PipelineRunsList.tsx index 52c545de..863793d1 100644 --- a/src/components/pipelineRuns-list/PipelineRunsList.tsx +++ b/src/components/pipelineRuns-list/PipelineRunsList.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router'; import { ListPageBody } from '@openshift-console/dynamic-plugin-sdk'; import usePipelineRunsColumns from './usePipelineRunsColumns'; @@ -11,6 +11,15 @@ import { ConsoleDataView } from '@openshift-console/dynamic-plugin-sdk-internal' import { useTranslation } from 'react-i18next'; import { useDataViewFilter } from '../hooks/useDataViewFilter'; import { DataViewFilterToolbar } from '../common/DataViewFilterToolbar'; +import { useDateRangeFilter } from '../hooks/useDateRangeFilter'; +import { + formatPrometheusDuration, + parsePrometheusDuration, +} from '../pipelines-overview/dateTime'; +import { + TimeRangeOptions, + TimeRangeOptionsK8s, +} from '../pipelines-overview/utils'; import './PipelineRunsList.scss'; @@ -48,25 +57,93 @@ const PipelineRunsList: FC = ({ } }, []); + const { + dateFilterCEL, + startDate, + timespan, + setTimespan, + isTektonResultEnabled, + preferenceLoaded, + } = useDateRangeFilter(); + const [pipelineRuns, k8sLoaded, trLoaded, pipelineRunsLoadError] = - useGetPipelineRuns(namespace, { name: PLRsForName, kind: PLRsForKind }); + useGetPipelineRuns(namespace, { + name: PLRsForName, + kind: PLRsForKind, + filter: dateFilterCEL, + }); + + const dateFilteredRuns = useMemo(() => { + if (!timespan || !startDate || !pipelineRuns) return pipelineRuns; + return pipelineRuns.filter((plr) => { + const st = plr.status?.startTime; + if (!st) return true; + return new Date(st).getTime() > startDate; + }); + }, [pipelineRuns, timespan, startDate]); const { - filterValues, - onFilterChange, - onClearAll, + filterValues: baseFilterValues, + onFilterChange: baseOnFilterChange, + onClearAll: baseOnClearAll, filteredData, updatedCheckboxFilters, } = useDataViewFilter({ - data: pipelineRuns || [], + data: dateFilteredRuns || [], options: { resourceType: 'PipelineRun', defaultDataSourceValues: ['cluster-data'], }, }); + const currentKey = formatPrometheusDuration(timespan); + const timeRangeOptions = isTektonResultEnabled + ? TimeRangeOptions() + : TimeRangeOptionsK8s(); + + const filterValues = { ...baseFilterValues, timeRange: [currentKey] }; + + const checkboxFilters = useMemo( + () => [ + ...updatedCheckboxFilters, + { + id: 'timeRange', + title: t('Time Range'), + singleSelect: true, + defaultValues: [currentKey], + options: Object.entries(timeRangeOptions).map(([key, label]) => ({ + value: key, + label, + count: 0, + })), + }, + ], + [updatedCheckboxFilters, t, currentKey, timeRangeOptions], + ); + + const onFilterChange = useCallback( + (key: string, value: string | string[]) => { + if (key === 'timeRange') { + setTimespan( + parsePrometheusDuration((value as string[])[0] || '1d'), + ); + return; + } + baseOnFilterChange(key, value); + }, + [baseOnFilterChange, setTimespan], + ); + + const onClearAll = useCallback(() => { + baseOnClearAll(); + setTimespan(parsePrometheusDuration('1d')); + }, [baseOnClearAll, setTimespan]); + const loaded = useMemo(() => { - const selectedSources = filterValues?.dataSource as string[] | undefined; + if (!preferenceLoaded) return false; + const selectedSources = baseFilterValues?.dataSource as + | string[] + | undefined; const bothOrNone = !selectedSources?.length || (selectedSources.includes('cluster-data') && @@ -74,16 +151,16 @@ const PipelineRunsList: FC = ({ if (bothOrNone) return k8sLoaded && trLoaded; if (selectedSources.includes('cluster-data')) return k8sLoaded; return trLoaded; - }, [k8sLoaded, trLoaded, filterValues?.dataSource]); + }, [preferenceLoaded, k8sLoaded, trLoaded, baseFilterValues?.dataSource]); return ( - {!hideTextFilter && ( + {!hideTextFilter && preferenceLoaded && ( )} diff --git a/src/components/pipelineRuns-list/__tests__/PipelineRunsList.spec.tsx b/src/components/pipelineRuns-list/__tests__/PipelineRunsList.spec.tsx new file mode 100644 index 00000000..8c1f087e --- /dev/null +++ b/src/components/pipelineRuns-list/__tests__/PipelineRunsList.spec.tsx @@ -0,0 +1,162 @@ +import { render, screen } from '@testing-library/react'; +import { useFlag } from '@openshift-console/dynamic-plugin-sdk'; +import { ConsoleDataView } from '@openshift-console/dynamic-plugin-sdk-internal'; +import PipelineRunsList from '../PipelineRunsList'; +import { useGetPipelineRuns } from '../../hooks/useTektonResult'; +import { useDateRangeFilter } from '../../hooks/useDateRangeFilter'; +import type { PipelineRunKind } from '../../../types'; +import type { DateRangeFilterResult } from '../../hooks/useDateRangeFilter'; + +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + useFlag: jest.fn(), + ListPageBody: ({ children }) =>
{children}
, + getGroupVersionKindForModel: jest.fn((model) => ({ + group: model?.apiGroup, + version: model?.apiVersion, + kind: model?.kind, + })), + useK8sWatchResource: jest.fn(() => [[], true, undefined]), +})); +jest.mock('@openshift-console/dynamic-plugin-sdk-internal', () => ({ + ConsoleDataView: jest.fn(() => null), +})); +jest.mock('react-router', () => ({ + useParams: () => ({ ns: 'test-ns' }), + useSearchParams: () => [new URLSearchParams(), jest.fn()], +})); +jest.mock('../../hooks/useTektonResult'); +jest.mock('../../hooks/useDateRangeFilter'); +jest.mock('../../hooks/useDataViewFilter', () => ({ + useDataViewFilter: ({ data }) => ({ + filterValues: {}, + onFilterChange: jest.fn(), + onClearAll: jest.fn(), + filteredData: data, + updatedCheckboxFilters: [], + }), +})); +jest.mock('../../common/DataViewFilterToolbar', () => ({ + DataViewFilterToolbar: ({ checkboxFilters }) => ( +
+ {checkboxFilters?.map((f) => + f.singleSelect ? ( + + {f.options.find((o) => f.defaultValues?.includes(o.value))?.label} + + ) : null, + )} +
+ ), +})); +jest.mock('../usePipelineRunsColumns', () => ({ + __esModule: true, + default: () => [], +})); +jest.mock('../../hooks/hooks', () => ({ + useGetActiveUser: () => 'test-user', +})); + +const useFlagMock = useFlag as jest.Mock; +const useGetPipelineRunsMock = useGetPipelineRuns as jest.Mock; +const useDateRangeFilterMock = useDateRangeFilter as jest.Mock; +const consoleDataViewMock = ConsoleDataView as jest.Mock; + +const ONE_DAY_MS = 86400000; + +const makeDateRange = ( + overrides?: Partial, +): DateRangeFilterResult => ({ + timespan: ONE_DAY_MS, + setTimespan: jest.fn(), + startDate: Date.now() - ONE_DAY_MS, + dateFilterCEL: + 'data.status.startTime > timestamp("2026-06-14T00:00:00.000Z")', + isTektonResultEnabled: true, + preferenceLoaded: true, + ...overrides, +}); + +const makePipelineRun = (name: string, startTime?: string): PipelineRunKind => + ({ + apiVersion: 'tekton.dev/v1', + kind: 'PipelineRun', + metadata: { name, namespace: 'test-ns', uid: name }, + status: startTime ? { startTime } : {}, + } as unknown as PipelineRunKind); + +describe('PipelineRunsList', () => { + beforeEach(() => { + useFlagMock.mockReturnValue(true); + consoleDataViewMock.mockReturnValue(null); + useDateRangeFilterMock.mockReturnValue(makeDateRange()); + useGetPipelineRunsMock.mockReturnValue([[], true, true, undefined]); + }); + + it('should pass dateFilterCEL to useGetPipelineRuns', () => { + const cel = + 'data.status.startTime > timestamp("2026-06-14T00:00:00.000Z")'; + useDateRangeFilterMock.mockReturnValue( + makeDateRange({ dateFilterCEL: cel }), + ); + render(); + expect(useGetPipelineRunsMock).toHaveBeenCalledWith( + 'test-ns', + expect.objectContaining({ filter: cel }), + ); + }); + + it('should filter out old runs client-side', () => { + const now = Date.now(); + const recent = makePipelineRun( + 'recent', + new Date(now - 1000).toISOString(), + ); + const old = makePipelineRun( + 'old', + new Date(now - 2 * ONE_DAY_MS).toISOString(), + ); + useGetPipelineRunsMock.mockReturnValue([ + [recent, old], + true, + true, + undefined, + ]); + useDateRangeFilterMock.mockReturnValue( + makeDateRange({ startDate: now - ONE_DAY_MS }), + ); + render(); + const dataArg = consoleDataViewMock.mock.calls[0][0].data; + expect(dataArg).not.toContainEqual( + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'old' }), + }), + ); + }); + + it('should keep runs without startTime (pending)', () => { + const now = Date.now(); + const pending = makePipelineRun('pending'); + useGetPipelineRunsMock.mockReturnValue([[pending], true, true, undefined]); + useDateRangeFilterMock.mockReturnValue( + makeDateRange({ startDate: now - ONE_DAY_MS }), + ); + render(); + const dataArg = consoleDataViewMock.mock.calls[0][0].data; + expect(dataArg).toContainEqual( + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'pending' }), + }), + ); + }); + + it('should include time range as a singleSelect checkbox filter', () => { + render(); + expect(screen.getByTestId('filter-timeRange')).toBeTruthy(); + expect(screen.getByText('Last day')).toBeTruthy(); + }); + + it('should hide filters when hideTextFilter is true', () => { + render(); + expect(screen.queryByTestId('filter-toolbar')).toBeNull(); + }); +});