diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx index 21ea1afb21..10a724c5b4 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx @@ -96,7 +96,7 @@ test('Filter table by project', async () => { await screen.findByPlaceholderText(/Search/); await screen.getByRole('button', { - name: /Filter/i, + name: 'Filter', }); await Promise.all( diff --git a/frontend/src/component/filter/AddFilterButton.tsx b/frontend/src/component/filter/AddFilterButton.tsx index e038899220..e1f23212dc 100644 --- a/frontend/src/component/filter/AddFilterButton.tsx +++ b/frontend/src/component/filter/AddFilterButton.tsx @@ -38,17 +38,15 @@ const StyledIcon = styled(Icon)(({ theme }) => ({ interface IAddFilterButtonProps { visibleOptions: string[]; - setVisibleOptions: (filters: string[]) => void; hiddenOptions: string[]; - setHiddenOptions: (filters: string[]) => void; + onSelectedOptionsChange: (filters: string[]) => void; availableFilters: IFilterItem[]; } export const AddFilterButton = ({ visibleOptions, - setVisibleOptions, hiddenOptions, - setHiddenOptions, + onSelectedOptionsChange, availableFilters, }: IAddFilterButtonProps) => { const projectId = useOptionalPathParam('projectId'); @@ -69,11 +67,7 @@ export const AddFilterButton = ({ }; const onSelect = (label: string) => { - const newVisibleOptions = visibleOptions.filter((f) => f !== label); - const newHiddenOptions = [...hiddenOptions, label]; - - setHiddenOptions(newHiddenOptions); - setVisibleOptions(newVisibleOptions); + onSelectedOptionsChange([...hiddenOptions, label]); handleClose(); }; diff --git a/frontend/src/component/filter/Filters/Filters.tsx b/frontend/src/component/filter/Filters/Filters.tsx index 6241f6c041..e1e92879ae 100644 --- a/frontend/src/component/filter/Filters/Filters.tsx +++ b/frontend/src/component/filter/Filters/Filters.tsx @@ -1,6 +1,5 @@ -import { type FC, useEffect, useState } from 'react'; +import { type FC, useEffect, useMemo, useState } from 'react'; import { Box, Icon, styled } from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { AddFilterButton } from '../AddFilterButton.tsx'; import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem'; import { @@ -165,6 +164,22 @@ const SingleFilter: FC = ({ ); }; +const mergeArraysKeepingOrder = ( + firstArray: string[], + secondArray: string[], +): string[] => { + const resultArray: string[] = [...firstArray]; + const elementsSet = new Set(firstArray); + + secondArray.forEach((element) => { + if (!elementsSet.has(element)) { + resultArray.push(element); + } + }); + + return resultArray; +}; + type MultiFilterProps = IFilterProps & { rangeChangeHandler: RangeChangeHandler; }; @@ -176,31 +191,12 @@ const MultiFilter: FC = ({ rangeChangeHandler, className, }) => { - const [unselectedFilters, setUnselectedFilters] = useState([]); const [selectedFilters, setSelectedFilters] = useState([]); const deselectFilter = (label: string) => { const newSelectedFilters = selectedFilters.filter((f) => f !== label); - const newUnselectedFilters = [...unselectedFilters, label].sort(); setSelectedFilters(newSelectedFilters); - setUnselectedFilters(newUnselectedFilters); - }; - - const mergeArraysKeepingOrder = ( - firstArray: string[], - secondArray: string[], - ): string[] => { - const resultArray: string[] = [...firstArray]; - const elementsSet = new Set(firstArray); - - secondArray.forEach((element) => { - if (!elementsSet.has(element)) { - resultArray.push(element); - } - }); - - return resultArray; }; useEffect(() => { @@ -219,15 +215,16 @@ const MultiFilter: FC = ({ newSelectedFilters, ); setSelectedFilters(allSelectedFilters); - - const newUnselectedFilters = availableFilters - .filter((item) => !allSelectedFilters.includes(item.label)) - .map((field) => field.label) - .sort(); - setUnselectedFilters(newUnselectedFilters); }, [JSON.stringify(state), JSON.stringify(availableFilters)]); - const hasAvailableFilters = unselectedFilters.length > 0; + const unselectedFilters = useMemo( + () => + availableFilters + .filter((item) => !selectedFilters.includes(item.label)) + .map((field) => field.label) + .sort(), + [availableFilters, selectedFilters], + ); return ( @@ -251,19 +248,14 @@ const MultiFilter: FC = ({ /> ); })} - - - } - /> + {unselectedFilters.length > 0 ? ( + + ) : null} ); }; diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index 03a6127891..7a9dc0245c 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -114,6 +114,7 @@ export const ProjectFeatureToggles = ({ createdBy: tableState.createdBy, archived: tableState.archived, lifecycle: tableState.lifecycle, + lastSeenAt: tableState.lastSeenAt, }; const { favorite, unfavorite } = useFavoriteFeaturesApi(); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index 017ca188cc..1df469dae0 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -7,6 +7,7 @@ import { } from 'component/filter/Filters/Filters'; import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators'; import { formatTag } from 'utils/format-tag'; +import { useUiFlag } from 'hooks/useUiFlag'; interface IProjectOverviewFilters { state: FilterItemParamHolder; @@ -21,6 +22,7 @@ export const ProjectOverviewFilters: VFC = ({ }) => { const { tags } = useAllTags(); const { flagCreators } = useProjectFlagCreators(project); + const filterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive'); const [availableFilters, setAvailableFilters] = useState([]); useEffect(() => { @@ -81,6 +83,17 @@ export const ProjectOverviewFilters: VFC = ({ filterKey: 'createdAt', dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'], }, + ...(filterFlagsToArchiveEnabled + ? [ + { + label: 'Last seen', + icon: 'monitor_heart', + options: [], + filterKey: 'lastSeenAt', + dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'], + } as IFilterItem, + ] + : []), { label: 'Flag type', icon: 'flag', @@ -127,7 +140,11 @@ export const ProjectOverviewFilters: VFC = ({ ]; setAvailableFilters(availableFilters); - }, [JSON.stringify(tags), JSON.stringify(flagCreators)]); + }, [ + JSON.stringify(tags), + JSON.stringify(flagCreators), + filterFlagsToArchiveEnabled, + ]); return ( { if (params[field]) { const parsed = parseSearchOperatorValue(field, params[field]); diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index eb42f70e23..9c05f1183c 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -771,6 +771,26 @@ const applyStaleConditions = ( } } }; +const applyLastSeenAtConditions = ( + query: Knex.QueryBuilder, + lastSeenAtConditions: IQueryParam[], +): void => { + lastSeenAtConditions.forEach((param) => { + const lastSeenAtExpression = query.client.raw( + 'coalesce(last_seen_at_metrics.last_seen_at, features.last_seen_at)', + ); + + switch (param.operator) { + case 'IS_BEFORE': + query.where(lastSeenAtExpression, '<', param.values[0]); + break; + case 'IS_ON_OR_AFTER': + query.where(lastSeenAtExpression, '>=', param.values[0]); + break; + } + }); +}; + const applyQueryParams = ( query: Knex.QueryBuilder, queryParams: IQueryParam[], @@ -782,12 +802,17 @@ const applyQueryParams = ( const segmentConditions = queryParams.filter( (param) => param.field === 'segment', ); + const lastSeenAtConditions = queryParams.filter( + (param) => param.field === 'lastSeenAt', + ); const genericConditions = queryParams.filter( - (param) => !['tag', 'stale'].includes(param.field), + (param) => + !['tag', 'stale', 'segment', 'lastSeenAt'].includes(param.field), ); applyGenericQueryParams(query, genericConditions); applyStaleConditions(query, staleConditions); + applyLastSeenAtConditions(query, lastSeenAtConditions); applyMultiQueryParams( query, diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 4ad2150650..9cb32bd1ed 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -1,3 +1,4 @@ +import { subDays } from 'date-fns'; import dbInit, { type ITestDb, } from '../../../test/e2e/helpers/database-init.js'; @@ -1085,6 +1086,149 @@ test('should filter features by combined operators', async () => { }); }); +test('should filter features by lastSeenAt', async () => { + await app.createFeature({ + name: 'recently_seen_feature', + }); + await app.createFeature({ + name: 'old_seen_feature', + }); + + const currentDate = new Date(); + + await insertLastSeenAt( + 'recently_seen_feature', + db.rawDatabase, + DEFAULT_ENV, + currentDate.toISOString(), + ); + await insertLastSeenAt( + 'old_seen_feature', + db.rawDatabase, + DEFAULT_ENV, + subDays(currentDate, 10).toISOString(), + ); + + const sevenDaysAgo = subDays(currentDate, 7); + + const { body: recentFeatures } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${sevenDaysAgo.toISOString().split('T')[0]}`, + ) + .expect(200); + + expect(recentFeatures.features).toHaveLength(1); + expect(recentFeatures.features[0].name).toBe('recently_seen_feature'); + + const { body: oldFeatures } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_BEFORE:${sevenDaysAgo.toISOString().split('T')[0]}`, + ) + .expect(200); + + expect(oldFeatures.features).toHaveLength(1); + expect(oldFeatures.features[0].name).toBe('old_seen_feature'); + + const { body: allFeatures } = await app.request + .get('/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:2000-01-01') + .expect(200); + expect(allFeatures.features).toHaveLength(2); +}); + +test('should filter by last seen even if in different environment', async () => { + await app.createFeature({ + name: 'feature_in_production', + }); + await app.createFeature({ + name: 'feature_in_development', + }); + + const currentDate = new Date(); + + await insertLastSeenAt( + 'feature_in_production', + db.rawDatabase, + 'production', + subDays(currentDate, 2).toISOString(), + ); + + await insertLastSeenAt( + 'feature_in_development', + db.rawDatabase, + DEFAULT_ENV, + subDays(currentDate, 5).toISOString(), + ); + + const threeDaysAgo = subDays(currentDate, 3); + + const { body: recentFeatures } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${threeDaysAgo.toISOString().split('T')[0]}`, + ) + .expect(200); + + expect(recentFeatures.features).toHaveLength(1); + expect(recentFeatures.features[0].name).toBe('feature_in_production'); + + const sixDaysAgo = subDays(currentDate, 6); + + const { body: olderFeatures } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${sixDaysAgo.toISOString().split('T')[0]}`, + ) + .expect(200); + + expect(olderFeatures.features).toHaveLength(2); + expect(olderFeatures.features.map((f) => f.name)).toContain( + 'feature_in_production', + ); + expect(olderFeatures.features.map((f) => f.name)).toContain( + 'feature_in_development', + ); +}); + +test('should not return features with no last seen when filtering by lastSeenAt', async () => { + await app.createFeature({ + name: 'feature_with_last_seen', + }); + await app.createFeature({ + name: 'feature_without_last_seen', + }); + + const currentDate = new Date(); + + await insertLastSeenAt( + 'feature_with_last_seen', + db.rawDatabase, + DEFAULT_ENV, + subDays(currentDate, 1).toISOString(), + ); + + const twoDaysAgo = subDays(currentDate, 2); + + const { body: featuresWithLastSeen } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${twoDaysAgo.toISOString().split('T')[0]}`, + ) + .expect(200); + + expect(featuresWithLastSeen.features).toHaveLength(1); + expect(featuresWithLastSeen.features[0].name).toBe( + 'feature_with_last_seen', + ); + + const currentDateFormatted = currentDate.toISOString().split('T')[0]; + + const { body: featuresBeforeToday } = await app.request + .get( + `/api/admin/search/features?lastSeenAt=IS_BEFORE:${currentDateFormatted}`, + ) + .expect(200); + + expect(featuresBeforeToday.features).toHaveLength(1); + expect(featuresBeforeToday.features[0].name).toBe('feature_with_last_seen'); +}); + test('should return environment usage metrics and lifecycle', async () => { await app.createFeature({ name: 'my_feature_b', diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index 1887187c0c..10b6588595 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -31,6 +31,7 @@ export interface IFeatureSearchParams { type?: string; tag?: string; lifecycle?: string; + lastSeenAt?: string; status?: string[][]; offset: number; favoritesFirst?: boolean; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 5fff558f02..c4cfc7512b 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -179,6 +179,17 @@ export const featureSearchQueryParameters = [ 'The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.', in: 'query', }, + { + name: 'lastSeenAt', + schema: { + type: 'string', + example: 'IS_ON_OR_AFTER:2023-01-28', + pattern: '^(IS_BEFORE|IS_ON_OR_AFTER):\\d{4}-\\d{2}-\\d{2}$', + }, + description: + 'The date the feature was last seen from metrics. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.', + in: 'query', + }, ] as const; export type FeatureSearchQueryParameters = Partial< diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index c033e042ba..fe54c2d887 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -66,6 +66,7 @@ export type IFlagKey = | 'lifecycleGraphs' | 'githubAuth' | 'addConfiguration' + | 'filterFlagsToArchive' | 'projectListViewToggle'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -306,6 +307,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_ADD_CONFIGURATION, false, ), + filterFlagsToArchive: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FILTER_FLAGS_TO_ARCHIVE, + false, + ), projectListViewToggle: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_PROJECT_LIST_VIEW_TOGGLE, false,