diff --git a/frontend/src/component/common/Table/cells/FeatureTagCell/FeatureTagCell.tsx b/frontend/src/component/common/Table/cells/FeatureTagCell/FeatureTagCell.tsx new file mode 100644 index 0000000000..e4b9e8a81c --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureTagCell/FeatureTagCell.tsx @@ -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 = ({ row, value }) => { + const { searchQuery } = useSearchHighlightContext(); + + if (!row.original.tags || row.original.tags.length === 0) + return ; + + return ( + + + {row.original.tags?.map(tag => ( + + + {`${tag.type}:${tag.value}`} + + + ))} + + } + > + 0 && value.includes(searchQuery) + } + > + {row.original.tags?.length === 1 + ? '1 tag' + : `${row.original.tags?.length} tags`} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index fb42b64590..629d6811e5 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -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 = {}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx index acebce146e..f705a639d0 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx @@ -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 = ({ - {allColumns.map(column => [ - } - />, - - column.toggleHidden(column.isVisible) - } - disabled={staticColumns.includes(column.id)} - className={classes.menuItem} - > - - - - - <>{column.Header}} - elseShow={() => column.id} - /> - + {allColumns + .filter(({ hideInMenu }) => !hideInMenu) + .map(column => [ + } + />, + + column.toggleHidden(column.isVisible) } - /> - , - } - />, - ])} + disabled={staticColumns.includes(column.id)} + className={classes.menuItem} + > + + + + + ( + <>{column.Header} + )} + elseShow={() => column.id} + /> + + } + /> + , + } + />, + ])} diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 400d053260..87c9c12910 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -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; diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index 6a717bdbe2..15be3e0eed 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -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 { diff --git a/frontend/src/openapi/models/FeatureSchema.ts b/frontend/src/openapi/models/FeatureSchema.ts index afe005b86a..8b6a18e2a2 100644 --- a/frontend/src/openapi/models/FeatureSchema.ts +++ b/frontend/src/openapi/models/FeatureSchema.ts @@ -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} * @memberof FeatureSchema */ environments?: Array; /** - * + * * @type {Array} * @memberof FeatureSchema */ strategies?: Array; /** - * + * * @type {Array} * @memberof FeatureSchema */ variants?: Array; + /** + * + * @type {Array} + * @memberof FeatureSchema + */ + tags?: Array | 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).map(FeatureEnvironmentSchemaFromJSON)), - 'strategies': !exists(json, 'strategies') ? undefined : ((json['strategies'] as Array).map(StrategySchemaFromJSON)), - 'variants': !exists(json, 'variants') ? undefined : ((json['variants'] as Array).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).map( + FeatureEnvironmentSchemaFromJSON + ), + strategies: !exists(json, 'strategies') + ? undefined + : (json['strategies'] as Array).map(StrategySchemaFromJSON), + variants: !exists(json, 'variants') + ? undefined + : (json['variants'] as Array).map(VariantSchemaFromJSON), + tags: !exists(json, 'tags') + ? undefined + : json['tags'] === null + ? null + : (json['tags'] as Array).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).map(FeatureEnvironmentSchemaToJSON)), - 'strategies': value.strategies === undefined ? undefined : ((value.strategies as Array).map(StrategySchemaToJSON)), - 'variants': value.variants === undefined ? undefined : ((value.variants as Array).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).map( + FeatureEnvironmentSchemaToJSON + ), + strategies: + value.strategies === undefined + ? undefined + : (value.strategies as Array).map(StrategySchemaToJSON), + variants: + value.variants === undefined + ? undefined + : (value.variants as Array).map(VariantSchemaToJSON), + tags: + value.tags === undefined + ? undefined + : value.tags === null + ? null + : (value.tags as Array).map(TagSchemaToJSON), }; } - diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index f7de118a6d..d2c812bdc2 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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], diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index 48a4e854a0..0fb6f88326 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -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 { @@ -317,23 +327,63 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }; } + private addTag( + feature: Record, + row: Record, + ): void { + const tags = feature.tags || []; + const newTag = FeatureStrategiesStore.rowToTag(row); + feature.tags = [...tags, newTag]; + } + + private isNewTag( + feature: Record, + row: Record, + ): 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 { - 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 diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index f665812559..a096e482d8 100644 --- a/src/lib/db/feature-toggle-client-store.ts +++ b/src/lib/db/feature-toggle-client-store.ts @@ -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): 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, + row: Record, + ): void { + const tags = feature.tags || []; + const newTag = FeatureToggleClientStore.rowToTag(row); + feature.tags = [...tags, newTag]; + } + + private isNewTag( + feature: PartialDeep, + row: Record, + ): 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, row: Record, diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 693b77a071..cc4633f471 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index d3726454be..8ae695d643 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -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; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 4bfcc72cf5..11d6f18072 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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 }, }; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 60d58173a6..33572b2a56 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -68,6 +68,7 @@ export interface IFeatureToggleClient { impressionData?: boolean; lastSeenAt?: Date; createdAt?: Date; + tags?: ITag[]; } export interface IFeatureEnvironmentInfo { diff --git a/src/server-dev.ts b/src/server-dev.ts index d94bd6b73b..fabbd332e3 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -41,6 +41,7 @@ process.nextTick(async () => { syncSSOGroups: true, changeRequests: true, cloneEnvironment: true, + toggleTagFiltering: true, }, }, authentication: { diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 5878a4a290..4d2a55dfd7 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", }, diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index 04cb53ba93..3d22eeac24 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -193,6 +193,7 @@ export default class FakeFeatureStrategiesStore type: t.type || 'Release', stale: t.stale || false, variants: [], + tags: [], })); return Promise.resolve(clientRows); } diff --git a/src/test/fixtures/fake-feature-toggle-client-store.ts b/src/test/fixtures/fake-feature-toggle-client-store.ts index aa87fcf5f1..892ddf10e4 100644 --- a/src/test/fixtures/fake-feature-toggle-client-store.ts +++ b/src/test/fixtures/fake-feature-toggle-client-store.ts @@ -41,6 +41,7 @@ export default class FakeFeatureToggleClientStore type: t.type || 'Release', stale: t.stale || false, variants: [], + tags: [], })); return Promise.resolve(clientRows); }