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

View File

@ -25,6 +25,7 @@ interface IColumnsMenuProps {
id: string;
isVisible: boolean;
toggleHidden: (state: boolean) => void;
hideInMenu?: boolean;
}[];
staticColumns?: string[];
dividerBefore?: string[];
@ -143,51 +144,56 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
</IconButton>
</Box>
<MenuList>
{allColumns.map(column => [
<ConditionallyRender
condition={dividerBefore.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
<MenuItem
onClick={() =>
column.toggleHidden(column.isVisible)
}
disabled={staticColumns.includes(column.id)}
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>
{allColumns
.filter(({ hideInMenu }) => !hideInMenu)
.map(column => [
<ConditionallyRender
condition={dividerBefore.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
<MenuItem
onClick={() =>
column.toggleHidden(column.isVisible)
}
/>
</MenuItem>,
<ConditionallyRender
condition={dividerAfter.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
])}
disabled={staticColumns.includes(column.id)}
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>
}
/>
</MenuItem>,
<ConditionallyRender
condition={dividerAfter.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
])}
</MenuList>
</Popover>
</Box>

View File

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

View File

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

View File

@ -5,7 +5,7 @@
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 4.11.0-beta.2
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -25,6 +25,12 @@ import {
StrategySchemaFromJSONTyped,
StrategySchemaToJSON,
} from './StrategySchema';
import {
TagSchema,
TagSchemaFromJSON,
TagSchemaFromJSONTyped,
TagSchemaToJSON,
} from './TagSchema';
import {
VariantSchema,
VariantSchemaFromJSON,
@ -33,121 +39,158 @@ import {
} from './VariantSchema';
/**
*
*
* @export
* @interface FeatureSchema
*/
export interface FeatureSchema {
/**
*
*
* @type {string}
* @memberof FeatureSchema
*/
name: string;
/**
*
*
* @type {string}
* @memberof FeatureSchema
*/
type?: string;
/**
*
*
* @type {string}
* @memberof FeatureSchema
*/
description?: string;
/**
*
*
* @type {boolean}
* @memberof FeatureSchema
*/
archived?: boolean;
/**
*
*
* @type {string}
* @memberof FeatureSchema
*/
project?: string;
/**
*
*
* @type {boolean}
* @memberof FeatureSchema
*/
enabled?: boolean;
/**
*
*
* @type {boolean}
* @memberof FeatureSchema
*/
stale?: boolean;
/**
*
*
* @type {boolean}
* @memberof FeatureSchema
*/
impressionData?: boolean;
/**
*
*
* @type {Date}
* @memberof FeatureSchema
*/
createdAt?: Date | null;
/**
*
*
* @type {Date}
* @memberof FeatureSchema
*/
archivedAt?: Date | null;
/**
*
*
* @type {Date}
* @memberof FeatureSchema
*/
lastSeenAt?: Date | null;
/**
*
*
* @type {Array<FeatureEnvironmentSchema>}
* @memberof FeatureSchema
*/
environments?: Array<FeatureEnvironmentSchema>;
/**
*
*
* @type {Array<StrategySchema>}
* @memberof FeatureSchema
*/
strategies?: Array<StrategySchema>;
/**
*
*
* @type {Array<VariantSchema>}
* @memberof FeatureSchema
*/
variants?: Array<VariantSchema>;
/**
*
* @type {Array<TagSchema>}
* @memberof FeatureSchema
*/
tags?: Array<TagSchema> | null;
}
export function FeatureSchemaFromJSON(json: any): FeatureSchema {
return FeatureSchemaFromJSONTyped(json, false);
}
export function FeatureSchemaFromJSONTyped(json: any, ignoreDiscriminator: boolean): FeatureSchema {
if ((json === undefined) || (json === null)) {
export function FeatureSchemaFromJSONTyped(
json: any,
ignoreDiscriminator: boolean
): FeatureSchema {
if (json === undefined || json === null) {
return json;
}
return {
'name': json['name'],
'type': !exists(json, 'type') ? undefined : json['type'],
'description': !exists(json, 'description') ? undefined : json['description'],
'archived': !exists(json, 'archived') ? undefined : json['archived'],
'project': !exists(json, 'project') ? undefined : json['project'],
'enabled': !exists(json, 'enabled') ? undefined : json['enabled'],
'stale': !exists(json, 'stale') ? undefined : json['stale'],
'impressionData': !exists(json, 'impressionData') ? undefined : json['impressionData'],
'createdAt': !exists(json, 'createdAt') ? undefined : (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)),
name: json['name'],
type: !exists(json, 'type') ? undefined : json['type'],
description: !exists(json, 'description')
? undefined
: json['description'],
archived: !exists(json, 'archived') ? undefined : json['archived'],
project: !exists(json, 'project') ? undefined : json['project'],
enabled: !exists(json, 'enabled') ? undefined : json['enabled'],
stale: !exists(json, 'stale') ? undefined : json['stale'],
impressionData: !exists(json, 'impressionData')
? undefined
: json['impressionData'],
createdAt: !exists(json, 'createdAt')
? undefined
: 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 {
'name': value.name,
'type': value.type,
'description': value.description,
'archived': value.archived,
'project': value.project,
'enabled': value.enabled,
'stale': value.stale,
'impressionData': value.impressionData,
'createdAt': value.createdAt === undefined ? undefined : (value.createdAt === null ? null : value.createdAt.toISOString().substr(0,10)),
'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)),
name: value.name,
type: value.type,
description: value.description,
archived: value.archived,
project: value.project,
enabled: value.enabled,
stale: value.stale,
impressionData: value.impressionData,
createdAt:
value.createdAt === undefined
? undefined
: value.createdAt === null
? null
: value.createdAt.toISOString().substr(0, 10),
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,
"responseTimeWithAppName": false,
"syncSSOGroups": false,
"toggleTagFiltering": false,
},
},
"flagResolver": FlagResolver {
@ -88,6 +89,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": false,
"responseTimeWithAppName": false,
"syncSSOGroups": false,
"toggleTagFiltering": false,
},
"externalResolver": {
"isEnabled": [Function],

View File

@ -12,12 +12,14 @@ import {
IFeatureOverview,
IFeatureStrategy,
IStrategyConfig,
ITag,
} from '../types/model';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { PartialSome } from '../types/partial';
import FeatureToggleStore from './feature-toggle-store';
import { ensureStringValue } from '../util/ensureStringValue';
import { mapValues } from '../util/map-values';
import { IFlagResolver } from '../types/experimental';
const COLUMNS = [
'id',
@ -111,7 +113,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
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.logger = getLogger('feature-toggle-store.ts');
this.timer = (action) =>
@ -119,6 +128,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
store: 'feature-toggle-strategies',
action,
});
this.flagResolver = flagResolver;
}
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(
projectId: string,
archived: boolean = false,
): 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 })
.select(
'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',
)
.select(selectColumns)
.modify(FeatureToggleStore.filterByArchived, archived)
.leftJoin(
'feature_environments',
@ -345,12 +395,26 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_environments.environment',
'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) {
const overview = rows.reduce((acc, r) => {
if (acc[r.feature_name] !== undefined) {
acc[r.feature_name].environments.push(
FeatureStrategiesStore.getEnvironment(r),
);
if (this.isNewTag(acc[r.feature_name], r)) {
this.addTag(acc[r.feature_name], r);
}
} else {
acc[r.feature_name] = {
type: r.type,
@ -362,9 +426,13 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
FeatureStrategiesStore.getEnvironment(r),
],
};
if (this.isNewTag(acc[r.feature_name], r)) {
this.addTag(acc[r.feature_name], r);
}
}
return acc;
}, {});
return Object.values(overview).map((o: IFeatureOverview) => ({
...o,
environments: o.environments

View File

@ -6,6 +6,7 @@ import {
IFeatureToggleClient,
IFeatureToggleQuery,
IStrategyConfig,
ITag,
} from '../types/model';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
import { DEFAULT_ENV } from '../util/constants';
@ -14,6 +15,7 @@ import EventEmitter from 'events';
import FeatureToggleStore from './feature-toggle-store';
import { ensureStringValue } from '../util/ensureStringValue';
import { mapValues } from '../util/map-values';
import { IFlagResolver } from '../types/experimental';
export interface FeaturesTable {
name: string;
@ -37,11 +39,14 @@ export default class FeatureToggleClientStore
private timer: Function;
private flagResolver: IFlagResolver;
constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: LogProvider,
inlineSegmentConstraints: boolean,
flagResolver: IFlagResolver,
) {
this.db = db;
this.logger = getLogger('feature-toggle-client-store.ts');
@ -51,6 +56,7 @@ export default class FeatureToggleClientStore
store: 'feature-toggle',
action,
});
this.flagResolver = flagResolver;
}
private async getAll(
@ -82,6 +88,14 @@ export default class FeatureToggleClientStore
'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')
.select(selectColumns)
.modify(FeatureToggleStore.filterByArchived, archived)
@ -108,6 +122,14 @@ export default class FeatureToggleClientStore
)
.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.tag) {
const tagQuery = this.db
@ -140,6 +162,9 @@ export default class FeatureToggleClientStore
FeatureToggleClientStore.rowToStrategy(r),
);
}
if (this.isNewTag(feature, r)) {
this.addTag(feature, r);
}
if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
this.addSegmentToStrategy(feature, r);
} 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[]) {
features.forEach((feature) => {
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(
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,

View File

@ -68,12 +68,14 @@ export const createStores = (
db,
eventBus,
getLogger,
config.flagResolver,
),
featureToggleClientStore: new FeatureToggleClientStore(
db,
eventBus,
getLogger,
config.inlineSegmentConstraints,
config.flagResolver,
),
environmentStore: new EnvironmentStore(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 { environmentSchema } from './environment-schema';
import { featureStrategySchema } from './feature-strategy-schema';
import { tagSchema } from './tag-schema';
export const featureSchema = {
$id: '#/components/schemas/featureSchema',
@ -69,6 +70,13 @@ export const featureSchema = {
$ref: '#/components/schemas/variantSchema',
},
},
tags: {
type: 'array',
items: {
$ref: '#/components/schemas/tagSchema',
},
nullable: true,
},
},
components: {
schemas: {
@ -78,6 +86,7 @@ export const featureSchema = {
parametersSchema,
featureStrategySchema,
variantSchema,
tagSchema,
},
},
} as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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