1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-09 01:17:06 +02:00

Feature toggles: Filtering by tags (#2396)

https://linear.app/unleash/issue/UNL-140/experiment-with-filtering-feature-toggles-by-tags-on-the-ui

Going with a naïve approach for now, tags can be searchable the same way
we search for text. The tags column only shows up if at least one toggle
has tags set. There's a simple highlightable component that lets us know
a match was found and then shows all the tags on a tooltip:
<img width="1289" alt="image"
src="https://user-images.githubusercontent.com/14320932/201155093-b8605ff2-5bf7-45c5-b240-a33da254c278.png">
This commit is contained in:
Nuno Góis 2022-11-15 10:24:36 +00:00 committed by GitHub
parent b891d1ec4f
commit 1ddc46011c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 439 additions and 109 deletions

View File

@ -0,0 +1,60 @@
import { VFC } from 'react';
import { FeatureSchema } from 'openapi';
import { Link, styled, Typography } from '@mui/material';
import { TextCell } from '../TextCell/TextCell';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
const StyledTag = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
}));
const StyledLink = styled(Link, {
shouldForwardProp: prop => prop !== 'highlighted',
})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({
backgroundColor: highlighted ? theme.palette.highlight : 'transparent',
}));
interface IFeatureTagCellProps {
row: {
original: FeatureSchema;
};
value: string;
}
export const FeatureTagCell: VFC<IFeatureTagCellProps> = ({ row, value }) => {
const { searchQuery } = useSearchHighlightContext();
if (!row.original.tags || row.original.tags.length === 0)
return <TextCell />;
return (
<TextCell>
<HtmlTooltip
title={
<>
{row.original.tags?.map(tag => (
<StyledTag key={tag.type + tag.value}>
<Highlighter search={searchQuery}>
{`${tag.type}:${tag.value}`}
</Highlighter>
</StyledTag>
))}
</>
}
>
<StyledLink
underline="always"
highlighted={
searchQuery.length > 0 && value.includes(searchQuery)
}
>
{row.original.tags?.length === 1
? '1 tag'
: `${row.original.tags?.length} tags`}
</StyledLink>
</HtmlTooltip>
</TextCell>
);
};

View File

@ -20,6 +20,7 @@ import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { useSearch } from 'hooks/useSearch'; import { useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search'; import { Search } from 'component/common/Search/Search';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
name: 'Name of the feature', name: 'Name of the feature',
@ -57,6 +58,15 @@ const columns = [
sortType: 'alphanumeric', sortType: 'alphanumeric',
searchable: true, searchable: true,
}, },
{
id: 'tags',
Header: 'Tags',
accessor: (row: FeatureSchema) =>
row.tags?.map(({ type, value }) => `${type}:${value}`).join('\n') ||
'',
Cell: FeatureTagCell,
searchable: true,
},
{ {
Header: 'Created', Header: 'Created',
accessor: 'createdAt', accessor: 'createdAt',
@ -139,7 +149,7 @@ export const FeatureToggleListTable: VFC = () => {
setHiddenColumns, setHiddenColumns,
} = useTable( } = useTable(
{ {
columns, columns: columns as any[],
data, data,
initialState, initialState,
sortTypes, sortTypes,
@ -153,14 +163,17 @@ export const FeatureToggleListTable: VFC = () => {
useEffect(() => { useEffect(() => {
const hiddenColumns = ['description']; const hiddenColumns = ['description'];
if (!features.some(({ tags }) => tags?.length)) {
hiddenColumns.push('tags');
}
if (isMediumScreen) { if (isMediumScreen) {
hiddenColumns.push('lastSeenAt', 'stale'); hiddenColumns.push('lastSeenAt', 'stale');
} }
if (isSmallScreen) { if (isSmallScreen) {
hiddenColumns.push('type', 'createdAt'); hiddenColumns.push('type', 'createdAt', 'tags');
} }
setHiddenColumns(hiddenColumns); setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, isSmallScreen, isMediumScreen]); }, [setHiddenColumns, isSmallScreen, isMediumScreen, features]);
useEffect(() => { useEffect(() => {
const tableState: PageQueryType = {}; const tableState: PageQueryType = {};

View File

@ -25,6 +25,7 @@ interface IColumnsMenuProps {
id: string; id: string;
isVisible: boolean; isVisible: boolean;
toggleHidden: (state: boolean) => void; toggleHidden: (state: boolean) => void;
hideInMenu?: boolean;
}[]; }[];
staticColumns?: string[]; staticColumns?: string[];
dividerBefore?: string[]; dividerBefore?: string[];
@ -143,51 +144,56 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
</IconButton> </IconButton>
</Box> </Box>
<MenuList> <MenuList>
{allColumns.map(column => [ {allColumns
<ConditionallyRender .filter(({ hideInMenu }) => !hideInMenu)
condition={dividerBefore.includes(column.id)} .map(column => [
show={<Divider className={classes.divider} />} <ConditionallyRender
/>, condition={dividerBefore.includes(column.id)}
<MenuItem show={<Divider className={classes.divider} />}
onClick={() => />,
column.toggleHidden(column.isVisible) <MenuItem
} onClick={() =>
disabled={staticColumns.includes(column.id)} column.toggleHidden(column.isVisible)
className={classes.menuItem}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={column.isVisible}
disableRipple
inputProps={{
'aria-labelledby': column.id,
}}
size="medium"
className={classes.checkbox}
/>
</ListItemIcon>
<ListItemText
id={column.id}
primary={
<Typography variant="body2">
<ConditionallyRender
condition={Boolean(
typeof column.Header ===
'string' && column.Header
)}
show={() => <>{column.Header}</>}
elseShow={() => column.id}
/>
</Typography>
} }
/> disabled={staticColumns.includes(column.id)}
</MenuItem>, className={classes.menuItem}
<ConditionallyRender >
condition={dividerAfter.includes(column.id)} <ListItemIcon>
show={<Divider className={classes.divider} />} <Checkbox
/>, edge="start"
])} checked={column.isVisible}
disableRipple
inputProps={{
'aria-labelledby': column.id,
}}
size="medium"
className={classes.checkbox}
/>
</ListItemIcon>
<ListItemText
id={column.id}
primary={
<Typography variant="body2">
<ConditionallyRender
condition={Boolean(
typeof column.Header ===
'string' &&
column.Header
)}
show={() => (
<>{column.Header}</>
)}
elseShow={() => column.id}
/>
</Typography>
}
/>
</MenuItem>,
<ConditionallyRender
condition={dividerAfter.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
])}
</MenuList> </MenuList>
</Popover> </Popover>
</Box> </Box>

View File

@ -41,6 +41,8 @@ import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConf
import { CopyStrategyMessage } from '../../../changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/CopyStrategyMessage'; import { CopyStrategyMessage } from '../../../changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/CopyStrategyMessage';
import { UpdateEnabledMessage } from '../../../changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage'; import { UpdateEnabledMessage } from '../../../changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
interface IProjectFeatureTogglesProps { interface IProjectFeatureTogglesProps {
features: IProject['features']; features: IProject['features'];
@ -192,6 +194,17 @@ export const ProjectFeatureToggles = ({
sortType: 'alphanumeric', sortType: 'alphanumeric',
searchable: true, searchable: true,
}, },
{
id: 'tags',
Header: 'Tags',
accessor: (row: IFeatureToggleListItem) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
hideInMenu: true,
searchable: true,
},
{ {
Header: 'Created', Header: 'Created',
accessor: 'createdAt', accessor: 'createdAt',
@ -259,6 +272,7 @@ export const ProjectFeatureToggles = ({
createdAt, createdAt,
type, type,
stale, stale,
tags,
environments: featureEnvironments, environments: featureEnvironments,
}) => ({ }) => ({
name, name,
@ -266,6 +280,7 @@ export const ProjectFeatureToggles = ({
createdAt, createdAt,
type, type,
stale, stale,
tags,
environments: Object.fromEntries( environments: Object.fromEntries(
environments.map(env => [ environments.map(env => [
env, env,
@ -370,6 +385,16 @@ export const ProjectFeatureToggles = ({
useSortBy useSortBy
); );
useEffect(() => {
if (!features.some(({ tags }) => tags?.length)) {
setHiddenColumns(hiddenColumns => [...hiddenColumns, 'tags']);
} else {
setHiddenColumns(hiddenColumns =>
hiddenColumns.filter(column => column !== 'tags')
);
}
}, [setHiddenColumns, features]);
useEffect(() => { useEffect(() => {
if (loading) { if (loading) {
return; return;

View File

@ -1,4 +1,5 @@
import { IFeatureStrategy } from './strategy'; import { IFeatureStrategy } from './strategy';
import { ITag } from './tags';
export interface IFeatureToggleListItem { export interface IFeatureToggleListItem {
type: string; type: string;
@ -7,6 +8,7 @@ export interface IFeatureToggleListItem {
lastSeenAt?: string; lastSeenAt?: string;
createdAt: string; createdAt: string;
environments: IEnvironments[]; environments: IEnvironments[];
tags?: ITag[];
} }
export interface IEnvironments { export interface IEnvironments {

View File

@ -5,7 +5,7 @@
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
* *
* The version of the OpenAPI document: 4.11.0-beta.2 * The version of the OpenAPI document: 4.11.0-beta.2
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech * https://openapi-generator.tech
@ -25,6 +25,12 @@ import {
StrategySchemaFromJSONTyped, StrategySchemaFromJSONTyped,
StrategySchemaToJSON, StrategySchemaToJSON,
} from './StrategySchema'; } from './StrategySchema';
import {
TagSchema,
TagSchemaFromJSON,
TagSchemaFromJSONTyped,
TagSchemaToJSON,
} from './TagSchema';
import { import {
VariantSchema, VariantSchema,
VariantSchemaFromJSON, VariantSchemaFromJSON,
@ -33,121 +39,158 @@ import {
} from './VariantSchema'; } from './VariantSchema';
/** /**
* *
* @export * @export
* @interface FeatureSchema * @interface FeatureSchema
*/ */
export interface FeatureSchema { export interface FeatureSchema {
/** /**
* *
* @type {string} * @type {string}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
name: string; name: string;
/** /**
* *
* @type {string} * @type {string}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
type?: string; type?: string;
/** /**
* *
* @type {string} * @type {string}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
description?: string; description?: string;
/** /**
* *
* @type {boolean} * @type {boolean}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
archived?: boolean; archived?: boolean;
/** /**
* *
* @type {string} * @type {string}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
project?: string; project?: string;
/** /**
* *
* @type {boolean} * @type {boolean}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
enabled?: boolean; enabled?: boolean;
/** /**
* *
* @type {boolean} * @type {boolean}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
stale?: boolean; stale?: boolean;
/** /**
* *
* @type {boolean} * @type {boolean}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
impressionData?: boolean; impressionData?: boolean;
/** /**
* *
* @type {Date} * @type {Date}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
createdAt?: Date | null; createdAt?: Date | null;
/** /**
* *
* @type {Date} * @type {Date}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
archivedAt?: Date | null; archivedAt?: Date | null;
/** /**
* *
* @type {Date} * @type {Date}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
lastSeenAt?: Date | null; lastSeenAt?: Date | null;
/** /**
* *
* @type {Array<FeatureEnvironmentSchema>} * @type {Array<FeatureEnvironmentSchema>}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
environments?: Array<FeatureEnvironmentSchema>; environments?: Array<FeatureEnvironmentSchema>;
/** /**
* *
* @type {Array<StrategySchema>} * @type {Array<StrategySchema>}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
strategies?: Array<StrategySchema>; strategies?: Array<StrategySchema>;
/** /**
* *
* @type {Array<VariantSchema>} * @type {Array<VariantSchema>}
* @memberof FeatureSchema * @memberof FeatureSchema
*/ */
variants?: Array<VariantSchema>; variants?: Array<VariantSchema>;
/**
*
* @type {Array<TagSchema>}
* @memberof FeatureSchema
*/
tags?: Array<TagSchema> | null;
} }
export function FeatureSchemaFromJSON(json: any): FeatureSchema { export function FeatureSchemaFromJSON(json: any): FeatureSchema {
return FeatureSchemaFromJSONTyped(json, false); return FeatureSchemaFromJSONTyped(json, false);
} }
export function FeatureSchemaFromJSONTyped(json: any, ignoreDiscriminator: boolean): FeatureSchema { export function FeatureSchemaFromJSONTyped(
if ((json === undefined) || (json === null)) { json: any,
ignoreDiscriminator: boolean
): FeatureSchema {
if (json === undefined || json === null) {
return json; return json;
} }
return { return {
name: json['name'],
'name': json['name'], type: !exists(json, 'type') ? undefined : json['type'],
'type': !exists(json, 'type') ? undefined : json['type'], description: !exists(json, 'description')
'description': !exists(json, 'description') ? undefined : json['description'], ? undefined
'archived': !exists(json, 'archived') ? undefined : json['archived'], : json['description'],
'project': !exists(json, 'project') ? undefined : json['project'], archived: !exists(json, 'archived') ? undefined : json['archived'],
'enabled': !exists(json, 'enabled') ? undefined : json['enabled'], project: !exists(json, 'project') ? undefined : json['project'],
'stale': !exists(json, 'stale') ? undefined : json['stale'], enabled: !exists(json, 'enabled') ? undefined : json['enabled'],
'impressionData': !exists(json, 'impressionData') ? undefined : json['impressionData'], stale: !exists(json, 'stale') ? undefined : json['stale'],
'createdAt': !exists(json, 'createdAt') ? undefined : (json['createdAt'] === null ? null : new Date(json['createdAt'])), impressionData: !exists(json, 'impressionData')
'archivedAt': !exists(json, 'archivedAt') ? undefined : (json['archivedAt'] === null ? null : new Date(json['archivedAt'])), ? undefined
'lastSeenAt': !exists(json, 'lastSeenAt') ? undefined : (json['lastSeenAt'] === null ? null : new Date(json['lastSeenAt'])), : json['impressionData'],
'environments': !exists(json, 'environments') ? undefined : ((json['environments'] as Array<any>).map(FeatureEnvironmentSchemaFromJSON)), createdAt: !exists(json, 'createdAt')
'strategies': !exists(json, 'strategies') ? undefined : ((json['strategies'] as Array<any>).map(StrategySchemaFromJSON)), ? undefined
'variants': !exists(json, 'variants') ? undefined : ((json['variants'] as Array<any>).map(VariantSchemaFromJSON)), : json['createdAt'] === null
? null
: new Date(json['createdAt']),
archivedAt: !exists(json, 'archivedAt')
? undefined
: json['archivedAt'] === null
? null
: new Date(json['archivedAt']),
lastSeenAt: !exists(json, 'lastSeenAt')
? undefined
: json['lastSeenAt'] === null
? null
: new Date(json['lastSeenAt']),
environments: !exists(json, 'environments')
? undefined
: (json['environments'] as Array<any>).map(
FeatureEnvironmentSchemaFromJSON
),
strategies: !exists(json, 'strategies')
? undefined
: (json['strategies'] as Array<any>).map(StrategySchemaFromJSON),
variants: !exists(json, 'variants')
? undefined
: (json['variants'] as Array<any>).map(VariantSchemaFromJSON),
tags: !exists(json, 'tags')
? undefined
: json['tags'] === null
? null
: (json['tags'] as Array<any>).map(TagSchemaFromJSON),
}; };
} }
@ -159,21 +202,51 @@ export function FeatureSchemaToJSON(value?: FeatureSchema | null): any {
return null; return null;
} }
return { return {
name: value.name,
'name': value.name, type: value.type,
'type': value.type, description: value.description,
'description': value.description, archived: value.archived,
'archived': value.archived, project: value.project,
'project': value.project, enabled: value.enabled,
'enabled': value.enabled, stale: value.stale,
'stale': value.stale, impressionData: value.impressionData,
'impressionData': value.impressionData, createdAt:
'createdAt': value.createdAt === undefined ? undefined : (value.createdAt === null ? null : value.createdAt.toISOString().substr(0,10)), value.createdAt === undefined
'archivedAt': value.archivedAt === undefined ? undefined : (value.archivedAt === null ? null : value.archivedAt.toISOString().substr(0,10)), ? undefined
'lastSeenAt': value.lastSeenAt === undefined ? undefined : (value.lastSeenAt === null ? null : value.lastSeenAt.toISOString().substr(0,10)), : value.createdAt === null
'environments': value.environments === undefined ? undefined : ((value.environments as Array<any>).map(FeatureEnvironmentSchemaToJSON)), ? null
'strategies': value.strategies === undefined ? undefined : ((value.strategies as Array<any>).map(StrategySchemaToJSON)), : value.createdAt.toISOString().substr(0, 10),
'variants': value.variants === undefined ? undefined : ((value.variants as Array<any>).map(VariantSchemaToJSON)), archivedAt:
value.archivedAt === undefined
? undefined
: value.archivedAt === null
? null
: value.archivedAt.toISOString().substr(0, 10),
lastSeenAt:
value.lastSeenAt === undefined
? undefined
: value.lastSeenAt === null
? null
: value.lastSeenAt.toISOString().substr(0, 10),
environments:
value.environments === undefined
? undefined
: (value.environments as Array<any>).map(
FeatureEnvironmentSchemaToJSON
),
strategies:
value.strategies === undefined
? undefined
: (value.strategies as Array<any>).map(StrategySchemaToJSON),
variants:
value.variants === undefined
? undefined
: (value.variants as Array<any>).map(VariantSchemaToJSON),
tags:
value.tags === undefined
? undefined
: value.tags === null
? null
: (value.tags as Array<any>).map(TagSchemaToJSON),
}; };
} }

View File

@ -75,6 +75,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": false, "embedProxyFrontend": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"syncSSOGroups": false, "syncSSOGroups": false,
"toggleTagFiltering": false,
}, },
}, },
"flagResolver": FlagResolver { "flagResolver": FlagResolver {
@ -88,6 +89,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": false, "embedProxyFrontend": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"syncSSOGroups": false, "syncSSOGroups": false,
"toggleTagFiltering": false,
}, },
"externalResolver": { "externalResolver": {
"isEnabled": [Function], "isEnabled": [Function],

View File

@ -12,12 +12,14 @@ import {
IFeatureOverview, IFeatureOverview,
IFeatureStrategy, IFeatureStrategy,
IStrategyConfig, IStrategyConfig,
ITag,
} from '../types/model'; } from '../types/model';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { PartialSome } from '../types/partial'; import { PartialSome } from '../types/partial';
import FeatureToggleStore from './feature-toggle-store'; import FeatureToggleStore from './feature-toggle-store';
import { ensureStringValue } from '../util/ensureStringValue'; import { ensureStringValue } from '../util/ensureStringValue';
import { mapValues } from '../util/map-values'; import { mapValues } from '../util/map-values';
import { IFlagResolver } from '../types/experimental';
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -111,7 +113,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
private readonly timer: Function; private readonly timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { private flagResolver: IFlagResolver;
constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: LogProvider,
flagResolver: IFlagResolver,
) {
this.db = db; this.db = db;
this.logger = getLogger('feature-toggle-store.ts'); this.logger = getLogger('feature-toggle-store.ts');
this.timer = (action) => this.timer = (action) =>
@ -119,6 +128,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
store: 'feature-toggle-strategies', store: 'feature-toggle-strategies',
action, action,
}); });
this.flagResolver = flagResolver;
} }
async delete(key: string): Promise<void> { async delete(key: string): Promise<void> {
@ -317,23 +327,63 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}; };
} }
private addTag(
feature: Record<string, any>,
row: Record<string, any>,
): void {
const tags = feature.tags || [];
const newTag = FeatureStrategiesStore.rowToTag(row);
feature.tags = [...tags, newTag];
}
private isNewTag(
feature: Record<string, any>,
row: Record<string, any>,
): boolean {
return (
row.tag_type &&
row.tag_value &&
!feature.tags?.some(
(tag) =>
tag.type === row.tag_type && tag.value === row.tag_value,
)
);
}
private static rowToTag(r: any): ITag {
return {
value: r.tag_value,
type: r.tag_type,
};
}
async getFeatureOverview( async getFeatureOverview(
projectId: string, projectId: string,
archived: boolean = false, archived: boolean = false,
): Promise<IFeatureOverview[]> { ): Promise<IFeatureOverview[]> {
const rows = await this.db('features') let selectColumns = [
'features.name as feature_name',
'features.type as type',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'features.stale as stale',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
];
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
selectColumns = [
...selectColumns,
'ft.tag_value as tag_value',
'ft.tag_type as tag_type',
];
}
let query = this.db('features')
.where({ project: projectId }) .where({ project: projectId })
.select( .select(selectColumns)
'features.name as feature_name',
'features.type as type',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'features.stale as stale',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
)
.modify(FeatureToggleStore.filterByArchived, archived) .modify(FeatureToggleStore.filterByArchived, archived)
.leftJoin( .leftJoin(
'feature_environments', 'feature_environments',
@ -345,12 +395,26 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_environments.environment', 'feature_environments.environment',
'environments.name', 'environments.name',
); );
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
query = query.leftJoin(
'feature_tag as ft',
'ft.feature_name',
'features.name',
);
}
const rows = await query;
if (rows.length > 0) { if (rows.length > 0) {
const overview = rows.reduce((acc, r) => { const overview = rows.reduce((acc, r) => {
if (acc[r.feature_name] !== undefined) { if (acc[r.feature_name] !== undefined) {
acc[r.feature_name].environments.push( acc[r.feature_name].environments.push(
FeatureStrategiesStore.getEnvironment(r), FeatureStrategiesStore.getEnvironment(r),
); );
if (this.isNewTag(acc[r.feature_name], r)) {
this.addTag(acc[r.feature_name], r);
}
} else { } else {
acc[r.feature_name] = { acc[r.feature_name] = {
type: r.type, type: r.type,
@ -362,9 +426,13 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
FeatureStrategiesStore.getEnvironment(r), FeatureStrategiesStore.getEnvironment(r),
], ],
}; };
if (this.isNewTag(acc[r.feature_name], r)) {
this.addTag(acc[r.feature_name], r);
}
} }
return acc; return acc;
}, {}); }, {});
return Object.values(overview).map((o: IFeatureOverview) => ({ return Object.values(overview).map((o: IFeatureOverview) => ({
...o, ...o,
environments: o.environments environments: o.environments

View File

@ -6,6 +6,7 @@ import {
IFeatureToggleClient, IFeatureToggleClient,
IFeatureToggleQuery, IFeatureToggleQuery,
IStrategyConfig, IStrategyConfig,
ITag,
} from '../types/model'; } from '../types/model';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store'; import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
import { DEFAULT_ENV } from '../util/constants'; import { DEFAULT_ENV } from '../util/constants';
@ -14,6 +15,7 @@ import EventEmitter from 'events';
import FeatureToggleStore from './feature-toggle-store'; import FeatureToggleStore from './feature-toggle-store';
import { ensureStringValue } from '../util/ensureStringValue'; import { ensureStringValue } from '../util/ensureStringValue';
import { mapValues } from '../util/map-values'; import { mapValues } from '../util/map-values';
import { IFlagResolver } from '../types/experimental';
export interface FeaturesTable { export interface FeaturesTable {
name: string; name: string;
@ -37,11 +39,14 @@ export default class FeatureToggleClientStore
private timer: Function; private timer: Function;
private flagResolver: IFlagResolver;
constructor( constructor(
db: Knex, db: Knex,
eventBus: EventEmitter, eventBus: EventEmitter,
getLogger: LogProvider, getLogger: LogProvider,
inlineSegmentConstraints: boolean, inlineSegmentConstraints: boolean,
flagResolver: IFlagResolver,
) { ) {
this.db = db; this.db = db;
this.logger = getLogger('feature-toggle-client-store.ts'); this.logger = getLogger('feature-toggle-client-store.ts');
@ -51,6 +56,7 @@ export default class FeatureToggleClientStore
store: 'feature-toggle', store: 'feature-toggle',
action, action,
}); });
this.flagResolver = flagResolver;
} }
private async getAll( private async getAll(
@ -82,6 +88,14 @@ export default class FeatureToggleClientStore
'segments.constraints as segment_constraints', 'segments.constraints as segment_constraints',
]; ];
if (isAdmin && this.flagResolver.isEnabled('toggleTagFiltering')) {
selectColumns = [
...selectColumns,
'ft.tag_value as tag_value',
'ft.tag_type as tag_type',
];
}
let query = this.db('features') let query = this.db('features')
.select(selectColumns) .select(selectColumns)
.modify(FeatureToggleStore.filterByArchived, archived) .modify(FeatureToggleStore.filterByArchived, archived)
@ -108,6 +122,14 @@ export default class FeatureToggleClientStore
) )
.leftJoin('segments', `segments.id`, `fss.segment_id`); .leftJoin('segments', `segments.id`, `fss.segment_id`);
if (isAdmin && this.flagResolver.isEnabled('toggleTagFiltering')) {
query = query.leftJoin(
'feature_tag as ft',
'ft.feature_name',
'features.name',
);
}
if (featureQuery) { if (featureQuery) {
if (featureQuery.tag) { if (featureQuery.tag) {
const tagQuery = this.db const tagQuery = this.db
@ -140,6 +162,9 @@ export default class FeatureToggleClientStore
FeatureToggleClientStore.rowToStrategy(r), FeatureToggleClientStore.rowToStrategy(r),
); );
} }
if (this.isNewTag(feature, r)) {
this.addTag(feature, r);
}
if (featureQuery?.inlineSegmentConstraints && r.segment_id) { if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
this.addSegmentToStrategy(feature, r); this.addSegmentToStrategy(feature, r);
} else if ( } else if (
@ -185,6 +210,13 @@ export default class FeatureToggleClientStore
}; };
} }
private static rowToTag(row: Record<string, any>): ITag {
return {
value: row.tag_value,
type: row.tag_type,
};
}
private static removeIdsFromStrategies(features: IFeatureToggleClient[]) { private static removeIdsFromStrategies(features: IFeatureToggleClient[]) {
features.forEach((feature) => { features.forEach((feature) => {
feature.strategies.forEach((strategy) => { feature.strategies.forEach((strategy) => {
@ -203,6 +235,29 @@ export default class FeatureToggleClientStore
); );
} }
private addTag(
feature: Record<string, any>,
row: Record<string, any>,
): void {
const tags = feature.tags || [];
const newTag = FeatureToggleClientStore.rowToTag(row);
feature.tags = [...tags, newTag];
}
private isNewTag(
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
): boolean {
return (
row.tag_type &&
row.tag_value &&
!feature.tags?.some(
(tag) =>
tag.type === row.tag_type && tag.value === row.tag_value,
)
);
}
private addSegmentToStrategy( private addSegmentToStrategy(
feature: PartialDeep<IFeatureToggleClient>, feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>, row: Record<string, any>,

View File

@ -68,12 +68,14 @@ export const createStores = (
db, db,
eventBus, eventBus,
getLogger, getLogger,
config.flagResolver,
), ),
featureToggleClientStore: new FeatureToggleClientStore( featureToggleClientStore: new FeatureToggleClientStore(
db, db,
eventBus, eventBus,
getLogger, getLogger,
config.inlineSegmentConstraints, config.inlineSegmentConstraints,
config.flagResolver,
), ),
environmentStore: new EnvironmentStore(db, eventBus, getLogger), environmentStore: new EnvironmentStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger), featureTagStore: new FeatureTagStore(db, eventBus, getLogger),

View File

@ -5,6 +5,7 @@ import { overrideSchema } from './override-schema';
import { parametersSchema } from './parameters-schema'; import { parametersSchema } from './parameters-schema';
import { environmentSchema } from './environment-schema'; import { environmentSchema } from './environment-schema';
import { featureStrategySchema } from './feature-strategy-schema'; import { featureStrategySchema } from './feature-strategy-schema';
import { tagSchema } from './tag-schema';
export const featureSchema = { export const featureSchema = {
$id: '#/components/schemas/featureSchema', $id: '#/components/schemas/featureSchema',
@ -69,6 +70,13 @@ export const featureSchema = {
$ref: '#/components/schemas/variantSchema', $ref: '#/components/schemas/variantSchema',
}, },
}, },
tags: {
type: 'array',
items: {
$ref: '#/components/schemas/tagSchema',
},
nullable: true,
},
}, },
components: { components: {
schemas: { schemas: {
@ -78,6 +86,7 @@ export const featureSchema = {
parametersSchema, parametersSchema,
featureStrategySchema, featureStrategySchema,
variantSchema, variantSchema,
tagSchema,
}, },
}, },
} as const; } as const;

View File

@ -34,6 +34,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_CLONE_ENVIRONMENT, process.env.UNLEASH_EXPERIMENTAL_CLONE_ENVIRONMENT,
false, false,
), ),
toggleTagFiltering: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_TOGGLE_TAG_FILTERING,
false,
),
}, },
externalResolver: { isEnabled: (): boolean => false }, externalResolver: { isEnabled: (): boolean => false },
}; };

View File

@ -68,6 +68,7 @@ export interface IFeatureToggleClient {
impressionData?: boolean; impressionData?: boolean;
lastSeenAt?: Date; lastSeenAt?: Date;
createdAt?: Date; createdAt?: Date;
tags?: ITag[];
} }
export interface IFeatureEnvironmentInfo { export interface IFeatureEnvironmentInfo {

View File

@ -41,6 +41,7 @@ process.nextTick(async () => {
syncSSOGroups: true, syncSSOGroups: true,
changeRequests: true, changeRequests: true,
cloneEnvironment: true, cloneEnvironment: true,
toggleTagFiltering: true,
}, },
}, },
authentication: { authentication: {

View File

@ -1212,6 +1212,13 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "array", "type": "array",
}, },
"tags": {
"items": {
"$ref": "#/components/schemas/tagSchema",
},
"nullable": true,
"type": "array",
},
"type": { "type": {
"type": "string", "type": "string",
}, },

View File

@ -193,6 +193,7 @@ export default class FakeFeatureStrategiesStore
type: t.type || 'Release', type: t.type || 'Release',
stale: t.stale || false, stale: t.stale || false,
variants: [], variants: [],
tags: [],
})); }));
return Promise.resolve(clientRows); return Promise.resolve(clientRows);
} }

View File

@ -41,6 +41,7 @@ export default class FakeFeatureToggleClientStore
type: t.type || 'Release', type: t.type || 'Release',
stale: t.stale || false, stale: t.stale || false,
variants: [], variants: [],
tags: [],
})); }));
return Promise.resolve(clientRows); return Promise.resolve(clientRows);
} }