mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +02: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.findByPlaceholderText(/Search/);
|
||||||
await screen.getByRole('button', {
|
await screen.getByRole('button', {
|
||||||
name: /Filter/i,
|
name: 'Filter',
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
@ -38,17 +38,15 @@ const StyledIcon = styled(Icon)(({ theme }) => ({
|
|||||||
|
|
||||||
interface IAddFilterButtonProps {
|
interface IAddFilterButtonProps {
|
||||||
visibleOptions: string[];
|
visibleOptions: string[];
|
||||||
setVisibleOptions: (filters: string[]) => void;
|
|
||||||
hiddenOptions: string[];
|
hiddenOptions: string[];
|
||||||
setHiddenOptions: (filters: string[]) => void;
|
onSelectedOptionsChange: (filters: string[]) => void;
|
||||||
availableFilters: IFilterItem[];
|
availableFilters: IFilterItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddFilterButton = ({
|
export const AddFilterButton = ({
|
||||||
visibleOptions,
|
visibleOptions,
|
||||||
setVisibleOptions,
|
|
||||||
hiddenOptions,
|
hiddenOptions,
|
||||||
setHiddenOptions,
|
onSelectedOptionsChange,
|
||||||
availableFilters,
|
availableFilters,
|
||||||
}: IAddFilterButtonProps) => {
|
}: IAddFilterButtonProps) => {
|
||||||
const projectId = useOptionalPathParam('projectId');
|
const projectId = useOptionalPathParam('projectId');
|
||||||
@ -69,11 +67,7 @@ export const AddFilterButton = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (label: string) => {
|
const onSelect = (label: string) => {
|
||||||
const newVisibleOptions = visibleOptions.filter((f) => f !== label);
|
onSelectedOptionsChange([...hiddenOptions, label]);
|
||||||
const newHiddenOptions = [...hiddenOptions, label];
|
|
||||||
|
|
||||||
setHiddenOptions(newHiddenOptions);
|
|
||||||
setVisibleOptions(newVisibleOptions);
|
|
||||||
handleClose();
|
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 { Box, Icon, styled } from '@mui/material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { AddFilterButton } from '../AddFilterButton.tsx';
|
import { AddFilterButton } from '../AddFilterButton.tsx';
|
||||||
import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem';
|
import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem';
|
||||||
import {
|
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 & {
|
type MultiFilterProps = IFilterProps & {
|
||||||
rangeChangeHandler: RangeChangeHandler;
|
rangeChangeHandler: RangeChangeHandler;
|
||||||
};
|
};
|
||||||
@ -176,31 +191,12 @@ const MultiFilter: FC<MultiFilterProps> = ({
|
|||||||
rangeChangeHandler,
|
rangeChangeHandler,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
const deselectFilter = (label: string) => {
|
const deselectFilter = (label: string) => {
|
||||||
const newSelectedFilters = selectedFilters.filter((f) => f !== label);
|
const newSelectedFilters = selectedFilters.filter((f) => f !== label);
|
||||||
const newUnselectedFilters = [...unselectedFilters, label].sort();
|
|
||||||
|
|
||||||
setSelectedFilters(newSelectedFilters);
|
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(() => {
|
useEffect(() => {
|
||||||
@ -219,15 +215,16 @@ const MultiFilter: FC<MultiFilterProps> = ({
|
|||||||
newSelectedFilters,
|
newSelectedFilters,
|
||||||
);
|
);
|
||||||
setSelectedFilters(allSelectedFilters);
|
setSelectedFilters(allSelectedFilters);
|
||||||
|
|
||||||
const newUnselectedFilters = availableFilters
|
|
||||||
.filter((item) => !allSelectedFilters.includes(item.label))
|
|
||||||
.map((field) => field.label)
|
|
||||||
.sort();
|
|
||||||
setUnselectedFilters(newUnselectedFilters);
|
|
||||||
}, [JSON.stringify(state), JSON.stringify(availableFilters)]);
|
}, [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 (
|
return (
|
||||||
<StyledBox className={className}>
|
<StyledBox className={className}>
|
||||||
@ -251,19 +248,14 @@ const MultiFilter: FC<MultiFilterProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{unselectedFilters.length > 0 ? (
|
||||||
<ConditionallyRender
|
<AddFilterButton
|
||||||
condition={hasAvailableFilters}
|
availableFilters={availableFilters}
|
||||||
show={
|
visibleOptions={unselectedFilters}
|
||||||
<AddFilterButton
|
hiddenOptions={selectedFilters}
|
||||||
availableFilters={availableFilters}
|
onSelectedOptionsChange={setSelectedFilters}
|
||||||
visibleOptions={unselectedFilters}
|
/>
|
||||||
setVisibleOptions={setUnselectedFilters}
|
) : null}
|
||||||
hiddenOptions={selectedFilters}
|
|
||||||
setHiddenOptions={setSelectedFilters}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StyledBox>
|
</StyledBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -114,6 +114,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
createdBy: tableState.createdBy,
|
createdBy: tableState.createdBy,
|
||||||
archived: tableState.archived,
|
archived: tableState.archived,
|
||||||
lifecycle: tableState.lifecycle,
|
lifecycle: tableState.lifecycle,
|
||||||
|
lastSeenAt: tableState.lastSeenAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
} from 'component/filter/Filters/Filters';
|
} from 'component/filter/Filters/Filters';
|
||||||
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
|
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
|
||||||
import { formatTag } from 'utils/format-tag';
|
import { formatTag } from 'utils/format-tag';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
interface IProjectOverviewFilters {
|
interface IProjectOverviewFilters {
|
||||||
state: FilterItemParamHolder;
|
state: FilterItemParamHolder;
|
||||||
@ -21,6 +22,7 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { tags } = useAllTags();
|
const { tags } = useAllTags();
|
||||||
const { flagCreators } = useProjectFlagCreators(project);
|
const { flagCreators } = useProjectFlagCreators(project);
|
||||||
|
const filterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive');
|
||||||
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
|
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -81,6 +83,17 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
|
|||||||
filterKey: 'createdAt',
|
filterKey: 'createdAt',
|
||||||
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
|
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',
|
label: 'Flag type',
|
||||||
icon: 'flag',
|
icon: 'flag',
|
||||||
@ -127,7 +140,11 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
setAvailableFilters(availableFilters);
|
setAvailableFilters(availableFilters);
|
||||||
}, [JSON.stringify(tags), JSON.stringify(flagCreators)]);
|
}, [
|
||||||
|
JSON.stringify(tags),
|
||||||
|
JSON.stringify(flagCreators),
|
||||||
|
filterFlagsToArchiveEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Filters
|
<Filters
|
||||||
|
@ -38,6 +38,7 @@ export const useProjectFeatureSearch = (
|
|||||||
tag: FilterItemParam,
|
tag: FilterItemParam,
|
||||||
state: FilterItemParam,
|
state: FilterItemParam,
|
||||||
createdAt: FilterItemParam,
|
createdAt: FilterItemParam,
|
||||||
|
lastSeenAt: FilterItemParam,
|
||||||
type: FilterItemParam,
|
type: FilterItemParam,
|
||||||
createdBy: FilterItemParam,
|
createdBy: FilterItemParam,
|
||||||
archived: FilterItemParam,
|
archived: FilterItemParam,
|
||||||
|
@ -95,6 +95,7 @@ export type UiFlags = {
|
|||||||
reportUnknownFlags?: boolean;
|
reportUnknownFlags?: boolean;
|
||||||
lifecycleGraphs?: boolean;
|
lifecycleGraphs?: boolean;
|
||||||
addConfiguration?: boolean;
|
addConfiguration?: boolean;
|
||||||
|
filterFlagsToArchive?: boolean;
|
||||||
projectListViewToggle?: 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.
|
* 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;
|
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,
|
favoritesFirst,
|
||||||
archived,
|
archived,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
lastSeenAt,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const {
|
const {
|
||||||
@ -149,6 +150,7 @@ export default class FeatureSearchController extends Controller {
|
|||||||
createdBy,
|
createdBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
lastSeenAt,
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
limit: normalizedLimit,
|
limit: normalizedLimit,
|
||||||
|
@ -73,6 +73,14 @@ export class FeatureSearchService {
|
|||||||
if (parsed) queryParams.push(parsed);
|
if (parsed) queryParams.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.lastSeenAt) {
|
||||||
|
const parsed = parseSearchOperatorValue(
|
||||||
|
'lastSeenAt',
|
||||||
|
params.lastSeenAt,
|
||||||
|
);
|
||||||
|
if (parsed) queryParams.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
['tag', 'segment', 'project'].forEach((field) => {
|
['tag', 'segment', 'project'].forEach((field) => {
|
||||||
if (params[field]) {
|
if (params[field]) {
|
||||||
const parsed = parseSearchOperatorValue(field, 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 = (
|
const applyQueryParams = (
|
||||||
query: Knex.QueryBuilder,
|
query: Knex.QueryBuilder,
|
||||||
queryParams: IQueryParam[],
|
queryParams: IQueryParam[],
|
||||||
@ -782,12 +802,17 @@ const applyQueryParams = (
|
|||||||
const segmentConditions = queryParams.filter(
|
const segmentConditions = queryParams.filter(
|
||||||
(param) => param.field === 'segment',
|
(param) => param.field === 'segment',
|
||||||
);
|
);
|
||||||
|
const lastSeenAtConditions = queryParams.filter(
|
||||||
|
(param) => param.field === 'lastSeenAt',
|
||||||
|
);
|
||||||
const genericConditions = queryParams.filter(
|
const genericConditions = queryParams.filter(
|
||||||
(param) => !['tag', 'stale'].includes(param.field),
|
(param) =>
|
||||||
|
!['tag', 'stale', 'segment', 'lastSeenAt'].includes(param.field),
|
||||||
);
|
);
|
||||||
applyGenericQueryParams(query, genericConditions);
|
applyGenericQueryParams(query, genericConditions);
|
||||||
|
|
||||||
applyStaleConditions(query, staleConditions);
|
applyStaleConditions(query, staleConditions);
|
||||||
|
applyLastSeenAtConditions(query, lastSeenAtConditions);
|
||||||
|
|
||||||
applyMultiQueryParams(
|
applyMultiQueryParams(
|
||||||
query,
|
query,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { subDays } from 'date-fns';
|
||||||
import dbInit, {
|
import dbInit, {
|
||||||
type ITestDb,
|
type ITestDb,
|
||||||
} from '../../../test/e2e/helpers/database-init.js';
|
} 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 () => {
|
test('should return environment usage metrics and lifecycle', async () => {
|
||||||
await app.createFeature({
|
await app.createFeature({
|
||||||
name: 'my_feature_b',
|
name: 'my_feature_b',
|
||||||
|
@ -31,6 +31,7 @@ export interface IFeatureSearchParams {
|
|||||||
type?: string;
|
type?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
lifecycle?: string;
|
lifecycle?: string;
|
||||||
|
lastSeenAt?: string;
|
||||||
status?: string[][];
|
status?: string[][];
|
||||||
offset: number;
|
offset: number;
|
||||||
favoritesFirst?: boolean;
|
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.',
|
'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',
|
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;
|
] as const;
|
||||||
|
|
||||||
export type FeatureSearchQueryParameters = Partial<
|
export type FeatureSearchQueryParameters = Partial<
|
||||||
|
@ -66,6 +66,7 @@ export type IFlagKey =
|
|||||||
| 'lifecycleGraphs'
|
| 'lifecycleGraphs'
|
||||||
| 'githubAuth'
|
| 'githubAuth'
|
||||||
| 'addConfiguration'
|
| 'addConfiguration'
|
||||||
|
| 'filterFlagsToArchive'
|
||||||
| 'projectListViewToggle';
|
| 'projectListViewToggle';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
@ -306,6 +307,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_ADD_CONFIGURATION,
|
process.env.UNLEASH_EXPERIMENTAL_ADD_CONFIGURATION,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
filterFlagsToArchive: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_FILTER_FLAGS_TO_ARCHIVE,
|
||||||
|
false,
|
||||||
|
),
|
||||||
projectListViewToggle: parseEnvVarBoolean(
|
projectListViewToggle: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_PROJECT_LIST_VIEW_TOGGLE,
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_LIST_VIEW_TOGGLE,
|
||||||
false,
|
false,
|
||||||
|
Loading…
Reference in New Issue
Block a user