mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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 { 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 = {}; | ||||||
|  | |||||||
| @ -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,7 +144,9 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({ | |||||||
|                     </IconButton> |                     </IconButton> | ||||||
|                 </Box> |                 </Box> | ||||||
|                 <MenuList> |                 <MenuList> | ||||||
|                     {allColumns.map(column => [ |                     {allColumns | ||||||
|  |                         .filter(({ hideInMenu }) => !hideInMenu) | ||||||
|  |                         .map(column => [ | ||||||
|                             <ConditionallyRender |                             <ConditionallyRender | ||||||
|                                 condition={dividerBefore.includes(column.id)} |                                 condition={dividerBefore.includes(column.id)} | ||||||
|                                 show={<Divider className={classes.divider} />} |                                 show={<Divider className={classes.divider} />} | ||||||
| @ -174,9 +177,12 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({ | |||||||
|                                             <ConditionallyRender |                                             <ConditionallyRender | ||||||
|                                                 condition={Boolean( |                                                 condition={Boolean( | ||||||
|                                                     typeof column.Header === |                                                     typeof column.Header === | ||||||
|                                                     'string' && column.Header |                                                         'string' && | ||||||
|  |                                                         column.Header | ||||||
|  |                                                 )} | ||||||
|  |                                                 show={() => ( | ||||||
|  |                                                     <>{column.Header}</> | ||||||
|                                                 )} |                                                 )} | ||||||
|                                             show={() => <>{column.Header}</>} |  | ||||||
|                                                 elseShow={() => column.id} |                                                 elseShow={() => column.id} | ||||||
|                                             /> |                                             /> | ||||||
|                                         </Typography> |                                         </Typography> | ||||||
|  | |||||||
| @ -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; | ||||||
|  | |||||||
| @ -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 { | ||||||
|  | |||||||
| @ -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, | ||||||
| @ -122,32 +128,69 @@ export interface FeatureSchema { | |||||||
|      * @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), | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -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], | ||||||
|  | |||||||
| @ -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,13 +327,41 @@ 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 = [ | ||||||
|             .where({ project: projectId }) |  | ||||||
|             .select( |  | ||||||
|             'features.name as feature_name', |             'features.name as feature_name', | ||||||
|             'features.type as type', |             'features.type as type', | ||||||
|             'features.created_at as created_at', |             'features.created_at as created_at', | ||||||
| @ -333,7 +371,19 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { | |||||||
|             'feature_environments.environment as environment', |             'feature_environments.environment as environment', | ||||||
|             'environments.type as environment_type', |             'environments.type as environment_type', | ||||||
|             'environments.sort_order as environment_sort_order', |             '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(selectColumns) | ||||||
|             .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 | ||||||
|  | |||||||
| @ -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>, | ||||||
|  | |||||||
| @ -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), | ||||||
|  | |||||||
| @ -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; | ||||||
|  | |||||||
| @ -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 }, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -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 { | ||||||
|  | |||||||
| @ -41,6 +41,7 @@ process.nextTick(async () => { | |||||||
|                         syncSSOGroups: true, |                         syncSSOGroups: true, | ||||||
|                         changeRequests: true, |                         changeRequests: true, | ||||||
|                         cloneEnvironment: true, |                         cloneEnvironment: true, | ||||||
|  |                         toggleTagFiltering: true, | ||||||
|                     }, |                     }, | ||||||
|                 }, |                 }, | ||||||
|                 authentication: { |                 authentication: { | ||||||
|  | |||||||
| @ -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", | ||||||
|           }, |           }, | ||||||
|  | |||||||
| @ -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); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -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); | ||||||
|     } |     } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user