mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat: filter flags by "last seen at" (#10449)
This lets users filter features by when they were last reported in metrics.
This commit is contained in:
		
							parent
							
								
									bd5a8539c0
								
							
						
					
					
						commit
						e1b6979627
					
				@ -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(
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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<SingleFilterProps> = ({
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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<MultiFilterProps> = ({
 | 
			
		||||
    rangeChangeHandler,
 | 
			
		||||
    className,
 | 
			
		||||
}) => {
 | 
			
		||||
    const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
 | 
			
		||||
    const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
 | 
			
		||||
 | 
			
		||||
    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<MultiFilterProps> = ({
 | 
			
		||||
            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 (
 | 
			
		||||
        <StyledBox className={className}>
 | 
			
		||||
@ -251,19 +248,14 @@ const MultiFilter: FC<MultiFilterProps> = ({
 | 
			
		||||
                    />
 | 
			
		||||
                );
 | 
			
		||||
            })}
 | 
			
		||||
 | 
			
		||||
            <ConditionallyRender
 | 
			
		||||
                condition={hasAvailableFilters}
 | 
			
		||||
                show={
 | 
			
		||||
                    <AddFilterButton
 | 
			
		||||
                        availableFilters={availableFilters}
 | 
			
		||||
                        visibleOptions={unselectedFilters}
 | 
			
		||||
                        setVisibleOptions={setUnselectedFilters}
 | 
			
		||||
                        hiddenOptions={selectedFilters}
 | 
			
		||||
                        setHiddenOptions={setSelectedFilters}
 | 
			
		||||
                    />
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            {unselectedFilters.length > 0 ? (
 | 
			
		||||
                <AddFilterButton
 | 
			
		||||
                    availableFilters={availableFilters}
 | 
			
		||||
                    visibleOptions={unselectedFilters}
 | 
			
		||||
                    hiddenOptions={selectedFilters}
 | 
			
		||||
                    onSelectedOptionsChange={setSelectedFilters}
 | 
			
		||||
                />
 | 
			
		||||
            ) : null}
 | 
			
		||||
        </StyledBox>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -114,6 +114,7 @@ export const ProjectFeatureToggles = ({
 | 
			
		||||
        createdBy: tableState.createdBy,
 | 
			
		||||
        archived: tableState.archived,
 | 
			
		||||
        lifecycle: tableState.lifecycle,
 | 
			
		||||
        lastSeenAt: tableState.lastSeenAt,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const { favorite, unfavorite } = useFavoriteFeaturesApi();
 | 
			
		||||
 | 
			
		||||
@ -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<IProjectOverviewFilters> = ({
 | 
			
		||||
}) => {
 | 
			
		||||
    const { tags } = useAllTags();
 | 
			
		||||
    const { flagCreators } = useProjectFlagCreators(project);
 | 
			
		||||
    const filterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive');
 | 
			
		||||
    const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
@ -81,6 +83,17 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
 | 
			
		||||
                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<IProjectOverviewFilters> = ({
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        setAvailableFilters(availableFilters);
 | 
			
		||||
    }, [JSON.stringify(tags), JSON.stringify(flagCreators)]);
 | 
			
		||||
    }, [
 | 
			
		||||
        JSON.stringify(tags),
 | 
			
		||||
        JSON.stringify(flagCreators),
 | 
			
		||||
        filterFlagsToArchiveEnabled,
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Filters
 | 
			
		||||
 | 
			
		||||
@ -38,6 +38,7 @@ export const useProjectFeatureSearch = (
 | 
			
		||||
        tag: FilterItemParam,
 | 
			
		||||
        state: FilterItemParam,
 | 
			
		||||
        createdAt: FilterItemParam,
 | 
			
		||||
        lastSeenAt: FilterItemParam,
 | 
			
		||||
        type: FilterItemParam,
 | 
			
		||||
        createdBy: FilterItemParam,
 | 
			
		||||
        archived: FilterItemParam,
 | 
			
		||||
 | 
			
		||||
@ -95,6 +95,7 @@ export type UiFlags = {
 | 
			
		||||
    reportUnknownFlags?: boolean;
 | 
			
		||||
    lifecycleGraphs?: boolean;
 | 
			
		||||
    addConfiguration?: boolean;
 | 
			
		||||
    filterFlagsToArchive?: boolean;
 | 
			
		||||
    projectListViewToggle?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -70,4 +70,8 @@ export type SearchFeaturesParams = {
 | 
			
		||||
     * The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.
 | 
			
		||||
     */
 | 
			
		||||
    createdAt?: string;
 | 
			
		||||
    /**
 | 
			
		||||
     * The date the feature was last seen (either from metrics or manual report). The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.
 | 
			
		||||
     */
 | 
			
		||||
    lastSeenAt?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -108,6 +108,7 @@ export default class FeatureSearchController extends Controller {
 | 
			
		||||
            favoritesFirst,
 | 
			
		||||
            archived,
 | 
			
		||||
            sortBy,
 | 
			
		||||
            lastSeenAt,
 | 
			
		||||
        } = req.query;
 | 
			
		||||
        const userId = req.user.id;
 | 
			
		||||
        const {
 | 
			
		||||
@ -149,6 +150,7 @@ export default class FeatureSearchController extends Controller {
 | 
			
		||||
            createdBy,
 | 
			
		||||
            sortBy,
 | 
			
		||||
            lifecycle,
 | 
			
		||||
            lastSeenAt,
 | 
			
		||||
            status: normalizedStatus,
 | 
			
		||||
            offset: normalizedOffset,
 | 
			
		||||
            limit: normalizedLimit,
 | 
			
		||||
 | 
			
		||||
@ -73,6 +73,14 @@ export class FeatureSearchService {
 | 
			
		||||
            if (parsed) queryParams.push(parsed);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (params.lastSeenAt) {
 | 
			
		||||
            const parsed = parseSearchOperatorValue(
 | 
			
		||||
                'lastSeenAt',
 | 
			
		||||
                params.lastSeenAt,
 | 
			
		||||
            );
 | 
			
		||||
            if (parsed) queryParams.push(parsed);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ['tag', 'segment', 'project'].forEach((field) => {
 | 
			
		||||
            if (params[field]) {
 | 
			
		||||
                const parsed = parseSearchOperatorValue(field, params[field]);
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
 | 
			
		||||
@ -31,6 +31,7 @@ export interface IFeatureSearchParams {
 | 
			
		||||
    type?: string;
 | 
			
		||||
    tag?: string;
 | 
			
		||||
    lifecycle?: string;
 | 
			
		||||
    lastSeenAt?: string;
 | 
			
		||||
    status?: string[][];
 | 
			
		||||
    offset: number;
 | 
			
		||||
    favoritesFirst?: boolean;
 | 
			
		||||
 | 
			
		||||
@ -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<
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user