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:
parent
b891d1ec4f
commit
1ddc46011c
@ -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>
|
||||
);
|
||||
};
|
@ -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 = {};
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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],
|
||||
|
@ -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
|
||||
|
@ -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>,
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
|
@ -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 },
|
||||
};
|
||||
|
@ -68,6 +68,7 @@ export interface IFeatureToggleClient {
|
||||
impressionData?: boolean;
|
||||
lastSeenAt?: Date;
|
||||
createdAt?: Date;
|
||||
tags?: ITag[];
|
||||
}
|
||||
|
||||
export interface IFeatureEnvironmentInfo {
|
||||
|
@ -41,6 +41,7 @@ process.nextTick(async () => {
|
||||
syncSSOGroups: true,
|
||||
changeRequests: true,
|
||||
cloneEnvironment: true,
|
||||
toggleTagFiltering: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
@ -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",
|
||||
},
|
||||
|
@ -193,6 +193,7 @@ export default class FakeFeatureStrategiesStore
|
||||
type: t.type || 'Release',
|
||||
stale: t.stale || false,
|
||||
variants: [],
|
||||
tags: [],
|
||||
}));
|
||||
return Promise.resolve(clientRows);
|
||||
}
|
||||
|
@ -41,6 +41,7 @@ export default class FakeFeatureToggleClientStore
|
||||
type: t.type || 'Release',
|
||||
stale: t.stale || false,
|
||||
variants: [],
|
||||
tags: [],
|
||||
}));
|
||||
return Promise.resolve(clientRows);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user