2021-01-03 14:25:43 +05:30
< script >
import Vue from 'vue' ;
import { memoize , isString , cloneDeep , isNumber , uniqueId } from 'lodash' ;
import {
GlButton ,
2021-01-29 00:20:46 +05:30
GlBadge ,
2021-01-03 14:25:43 +05:30
GlTooltip ,
GlTooltipDirective ,
GlFormTextarea ,
GlFormCheckbox ,
GlSprintf ,
GlIcon ,
} from '@gitlab/ui' ;
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue' ;
import { s _ _ } from '~/locale' ;
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin' ;
import ToggleButton from '~/vue_shared/components/toggle_button.vue' ;
import EnvironmentsDropdown from './environments_dropdown.vue' ;
import Strategy from './strategy.vue' ;
import {
ROLLOUT _STRATEGY _ALL _USERS ,
ROLLOUT _STRATEGY _PERCENT _ROLLOUT ,
ROLLOUT _STRATEGY _USER _ID ,
ALL _ENVIRONMENTS _NAME ,
INTERNAL _ID _PREFIX ,
NEW _VERSION _FLAG ,
LEGACY _FLAG ,
} from '../constants' ;
import { createNewEnvironmentScope } from '../store/helpers' ;
export default {
components : {
GlButton ,
GlBadge ,
GlFormTextarea ,
GlFormCheckbox ,
GlTooltip ,
GlSprintf ,
GlIcon ,
ToggleButton ,
EnvironmentsDropdown ,
Strategy ,
RelatedIssuesRoot ,
} ,
directives : {
GlTooltip : GlTooltipDirective ,
} ,
mixins : [ featureFlagsMixin ( ) ] ,
props : {
active : {
type : Boolean ,
required : false ,
default : true ,
} ,
name : {
type : String ,
required : false ,
default : '' ,
} ,
description : {
type : String ,
required : false ,
default : '' ,
} ,
scopes : {
type : Array ,
required : false ,
default : ( ) => [ ] ,
} ,
cancelPath : {
type : String ,
required : true ,
} ,
submitText : {
type : String ,
required : true ,
} ,
strategies : {
type : Array ,
required : false ,
default : ( ) => [ ] ,
} ,
version : {
type : String ,
required : false ,
default : LEGACY _FLAG ,
} ,
} ,
inject : {
featureFlagIssuesEndpoint : {
default : '' ,
} ,
} ,
translations : {
allEnvironmentsText : s _ _ ( 'FeatureFlags|* (All Environments)' ) ,
helpText : s _ _ (
'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.' ,
) ,
newHelpText : s _ _ (
'FeatureFlags|Enable features for specific users and environments by configuring feature flag strategies.' ,
) ,
noStrategiesText : s _ _ ( 'FeatureFlags|Feature Flag has no strategies' ) ,
} ,
ROLLOUT _STRATEGY _ALL _USERS ,
ROLLOUT _STRATEGY _PERCENT _ROLLOUT ,
ROLLOUT _STRATEGY _USER _ID ,
// Matches numbers 0 through 100
rolloutPercentageRegex : /^[0-9]$|^[1-9][0-9]$|^100$/ ,
data ( ) {
return {
formName : this . name ,
formDescription : this . description ,
// operate on a clone to avoid mutating props
formScopes : this . scopes . map ( s => ( { ... s } ) ) ,
formStrategies : cloneDeep ( this . strategies ) ,
newScope : '' ,
} ;
} ,
computed : {
filteredScopes ( ) {
return this . formScopes . filter ( scope => ! scope . shouldBeDestroyed ) ;
} ,
filteredStrategies ( ) {
return this . formStrategies . filter ( s => ! s . shouldBeDestroyed ) ;
} ,
canUpdateFlag ( ) {
return ! this . permissionsFlag || ( this . formScopes || [ ] ) . every ( scope => scope . canUpdate ) ;
} ,
permissionsFlag ( ) {
return this . glFeatures . featureFlagPermissions ;
} ,
supportsStrategies ( ) {
return this . glFeatures . featureFlagsNewVersion && this . version === NEW _VERSION _FLAG ;
} ,
showRelatedIssues ( ) {
return this . featureFlagIssuesEndpoint . length > 0 ;
} ,
readOnly ( ) {
return (
this . glFeatures . featureFlagsNewVersion &&
this . glFeatures . featureFlagsLegacyReadOnly &&
! this . glFeatures . featureFlagsLegacyReadOnlyOverride &&
this . version === LEGACY _FLAG
) ;
} ,
} ,
methods : {
keyFor ( strategy ) {
if ( strategy . id ) {
return strategy . id ;
}
return uniqueId ( 'strategy_' ) ;
} ,
addStrategy ( ) {
this . formStrategies . push ( { name : ROLLOUT _STRATEGY _ALL _USERS , parameters : { } , scopes : [ ] } ) ;
} ,
deleteStrategy ( s ) {
if ( isNumber ( s . id ) ) {
Vue . set ( s , 'shouldBeDestroyed' , true ) ;
} else {
this . formStrategies = this . formStrategies . filter ( strategy => strategy !== s ) ;
}
} ,
isAllEnvironment ( name ) {
return name === ALL _ENVIRONMENTS _NAME ;
} ,
/ * *
* When the user clicks the remove button we delete the scope
*
* If the scope has an ID , we need to add the ` shouldBeDestroyed ` flag .
* If the scope does * not * have an ID , we can just remove it .
*
* This flag will be used when submitting the data to the backend
* to determine which records to delete ( via a "_destroy" property ) .
*
* @ param { Object } scope
* /
removeScope ( scope ) {
if ( isString ( scope . id ) && scope . id . startsWith ( INTERNAL _ID _PREFIX ) ) {
this . formScopes = this . formScopes . filter ( s => s !== scope ) ;
} else {
Vue . set ( scope , 'shouldBeDestroyed' , true ) ;
}
} ,
/ * *
* Creates a new scope and adds it to the list of scopes
*
* @ param overrides An object whose properties will
* be used override the default scope options
* /
createNewScope ( overrides ) {
this . formScopes . push ( createNewEnvironmentScope ( overrides , this . permissionsFlag ) ) ;
this . newScope = '' ;
} ,
/ * *
* When the user clicks the submit button
* it triggers an event with the form data
* /
handleSubmit ( ) {
const flag = {
name : this . formName ,
description : this . formDescription ,
active : this . active ,
version : this . version ,
} ;
if ( this . version === LEGACY _FLAG ) {
flag . scopes = this . formScopes ;
} else {
flag . strategies = this . formStrategies ;
}
this . $emit ( 'handleSubmit' , flag ) ;
} ,
canUpdateScope ( scope ) {
return ! this . permissionsFlag || scope . canUpdate ;
} ,
isRolloutPercentageInvalid : memoize ( function isRolloutPercentageInvalid ( percentage ) {
return ! this . $options . rolloutPercentageRegex . test ( percentage ) ;
} ) ,
/ * *
* Generates a unique ID for the strategy based on the v - for index
*
* @ param index The index of the strategy
* /
rolloutStrategyId ( index ) {
return ` rollout-strategy- ${ index } ` ;
} ,
/ * *
* Generates a unique ID for the percentage based on the v - for index
*
* @ param index The index of the percentage
* /
rolloutPercentageId ( index ) {
return ` rollout-percentage- ${ index } ` ;
} ,
rolloutUserId ( index ) {
return ` rollout-user-id- ${ index } ` ;
} ,
shouldDisplayIncludeUserIds ( scope ) {
return ! [ ROLLOUT _STRATEGY _ALL _USERS , ROLLOUT _STRATEGY _USER _ID ] . includes (
scope . rolloutStrategy ,
) ;
} ,
shouldDisplayUserIds ( scope ) {
return scope . rolloutStrategy === ROLLOUT _STRATEGY _USER _ID || scope . shouldIncludeUserIds ;
} ,
onStrategyChange ( index ) {
const scope = this . filteredScopes [ index ] ;
scope . shouldIncludeUserIds =
scope . rolloutUserIds . length > 0 &&
scope . rolloutStrategy === ROLLOUT _STRATEGY _PERCENT _ROLLOUT ;
} ,
onFormStrategyChange ( strategy , index ) {
Object . assign ( this . filteredStrategies [ index ] , strategy ) ;
} ,
} ,
} ;
< / script >
< template >
< form class = "feature-flags-form" >
< fieldset >
< div class = "row" >
< div class = "form-group col-md-4" >
< label for = "feature-flag-name" class = "label-bold" > { { s _ _ ( 'FeatureFlags|Name' ) } } * < / label >
< input
id = "feature-flag-name"
v - model = "formName"
: disabled = "!canUpdateFlag"
class = "form-control"
/ >
< / div >
< / div >
< div class = "row" >
< div class = "form-group col-md-4" >
< label for = "feature-flag-description" class = "label-bold" >
{ { s _ _ ( 'FeatureFlags|Description' ) } }
< / label >
< textarea
id = "feature-flag-description"
v - model = "formDescription"
: disabled = "!canUpdateFlag"
class = "form-control"
rows = "4"
> < / textarea >
< / div >
< / div >
< related-issues-root
v - if = "showRelatedIssues"
: endpoint = "featureFlagIssuesEndpoint"
: can - admin = "true"
: show - categorized - issues = "false"
/ >
< template v-if = "supportsStrategies" >
< div class = "row" >
< div class = "col-md-12" >
< h4 > { { s _ _ ( 'FeatureFlags|Strategies' ) } } < / h4 >
< div class = "flex align-items-baseline justify-content-between" >
< p class = "mr-3" > { { $options . translations . newHelpText } } < / p >
< gl-button variant = "success" category = "secondary" @click ="addStrategy" >
{ { s _ _ ( 'FeatureFlags|Add strategy' ) } }
< / gl-button >
< / div >
< / div >
< / div >
< div v-if = "filteredStrategies.length > 0" data-testid="feature-flag-strategies" >
< strategy
v - for = "(strategy, index) in filteredStrategies"
: key = "keyFor(strategy)"
: strategy = "strategy"
: index = "index"
@ change = "onFormStrategyChange($event, index)"
@ delete = "deleteStrategy(strategy)"
/ >
< / div >
< div v -else class = "flex justify-content-center border-top py-4 w-100" >
< span > { { $options . translations . noStrategiesText } } < / span >
< / div >
< / template >
< div v -else class = "row" >
< div class = "form-group col-md-12" >
< h4 > { { s _ _ ( 'FeatureFlags|Target environments' ) } } < / h4 >
< gl-sprintf :message = "$options.translations.helpText" >
< template # code = "{ content }" >
< code > { { content } } < / code >
< / template >
< template # bold = "{ content }" >
< b > { { content } } < / b >
< / template >
< / gl-sprintf >
< div class = "js-scopes-table gl-mt-3" >
< div class = "gl-responsive-table-row table-row-header" role = "row" >
< div class = "table-section section-30" role = "columnheader" >
{ { s _ _ ( 'FeatureFlags|Environment Spec' ) } }
< / div >
< div class = "table-section section-20 text-center" role = "columnheader" >
{ { s _ _ ( 'FeatureFlags|Status' ) } }
< / div >
< div class = "table-section section-40" role = "columnheader" >
{ { s _ _ ( 'FeatureFlags|Rollout Strategy' ) } }
< / div >
< / div >
< div
v - for = "(scope, index) in filteredScopes"
: key = "scope.id"
ref = "scopeRow"
class = "gl-responsive-table-row"
role = "row"
>
< div class = "table-section section-30" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Environment Spec' ) } }
< / div >
< div
class = "table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
>
< p v-if = "isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3" >
{ { $options . translations . allEnvironmentsText } }
< / p >
< environments-dropdown
v - else
class = "col-12"
: value = "scope.environmentScope"
: disabled = "!canUpdateScope(scope) || scope.environmentScope !== ''"
@ selectEnvironment = "env => (scope.environmentScope = env)"
@ createClicked = "env => (scope.environmentScope = env)"
@ clearInput = "env => (scope.environmentScope = '')"
/ >
< gl-badge v-if = "permissionsFlag && scope.protected" variant="success" >
{ { s _ _ ( 'FeatureFlags|Protected' ) } }
< / gl-badge >
< / div >
< / div >
< div class = "table-section section-20 text-center" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Status' ) } }
< / div >
< div class = "table-mobile-content js-feature-flag-status" >
< toggle-button
: value = "scope.active"
: disabled - input = "!active || !canUpdateScope(scope)"
@ change = "status => (scope.active = status)"
/ >
< / div >
< / div >
< div class = "table-section section-40" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Rollout Strategy' ) } }
< / div >
< div class = "table-mobile-content js-rollout-strategy form-inline" >
< label class = "sr-only" :for = "rolloutStrategyId(index)" >
{ { s _ _ ( 'FeatureFlags|Rollout Strategy' ) } }
< / label >
< div class = "select-wrapper col-12 col-md-8 p-0" >
< select
: id = "rolloutStrategyId(index)"
v - model = "scope.rolloutStrategy"
: disabled = "!scope.active"
class = "form-control select-control w-100 js-rollout-strategy"
@ change = "onStrategyChange(index)"
>
< option :value = "$options.ROLLOUT_STRATEGY_ALL_USERS" >
{ { s _ _ ( 'FeatureFlags|All users' ) } }
< / option >
< option :value = "$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT" >
{ { s _ _ ( 'FeatureFlags|Percent rollout (logged in users)' ) } }
< / option >
< option :value = "$options.ROLLOUT_STRATEGY_USER_ID" >
{ { s _ _ ( 'FeatureFlags|User IDs' ) } }
< / option >
< / select >
< gl-icon
name = "chevron-down"
class = "gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
: size = "16"
/ >
< / div >
< div
v - if = "scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"
class = "d-flex-center mt-2 mt-md-0 ml-md-2"
>
< label class = "sr-only" :for = "rolloutPercentageId(index)" >
{ { s _ _ ( 'FeatureFlags|Rollout Percentage' ) } }
< / label >
< div class = "gl-w-9" >
< input
: id = "rolloutPercentageId(index)"
v - model = "scope.rolloutPercentage"
: disabled = "!scope.active"
: class = " {
'is-invalid' : isRolloutPercentageInvalid ( scope . rolloutPercentage ) ,
} "
type = "number"
min = "0"
max = "100"
: pattern = "$options.rolloutPercentageRegex.source"
class = "rollout-percentage js-rollout-percentage form-control text-right w-100"
/ >
< / div >
< gl-tooltip
v - if = "isRolloutPercentageInvalid(scope.rolloutPercentage)"
: target = "rolloutPercentageId(index)"
>
{ {
2021-01-29 00:20:46 +05:30
s _ _ (
'FeatureFlags|Percent rollout must be an integer number between 0 and 100' ,
)
2021-01-03 14:25:43 +05:30
} }
< / gl-tooltip >
< span class = "ml-1" > % < / span >
< / div >
< div class = "d-flex flex-column align-items-start mt-2 w-100" >
< gl-form-checkbox
v - if = "shouldDisplayIncludeUserIds(scope)"
v - model = "scope.shouldIncludeUserIds"
> { { s _ _ ( 'FeatureFlags|Include additional user IDs' ) } } < / g l - f o r m - c h e c k b o x
>
< template v-if = "shouldDisplayUserIds(scope)" >
< label :for = "rolloutUserId(index)" class = "mb-2" >
{ { s _ _ ( 'FeatureFlags|User IDs' ) } }
< / label >
< gl-form-textarea
: id = "rolloutUserId(index)"
v - model = "scope.rolloutUserIds"
class = "w-100"
/ >
< / template >
< / div >
< / div >
< / div >
< div class = "table-section section-10 text-right" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Remove' ) } }
< / div >
< div class = "table-mobile-content js-feature-flag-delete" >
< gl-button
v - if = "!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
v - gl - tooltip
: title = "s__('FeatureFlags|Remove')"
class = "js-delete-scope btn-transparent pr-3 pl-3"
icon = "clear"
@ click = "removeScope(scope)"
/ >
< / div >
< / div >
< / div >
< div class = "js-add-new-scope gl-responsive-table-row" role = "row" >
< div class = "table-section section-30" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Environment Spec' ) } }
< / div >
< div class = "table-mobile-content js-feature-flag-status" >
< environments-dropdown
class = "js-new-scope-name col-12"
: value = "newScope"
@ selectEnvironment = "env => createNewScope({ environmentScope: env })"
@ createClicked = "env => createNewScope({ environmentScope: env })"
/ >
< / div >
< / div >
< div class = "table-section section-20 text-center" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Status' ) } }
< / div >
< div class = "table-mobile-content js-feature-flag-status" >
< toggle-button
: disabled - input = "!active"
: value = "false"
@ change = "createNewScope({ active: true })"
/ >
< / div >
< / div >
< div class = "table-section section-40" role = "gridcell" >
< div class = "table-mobile-header" role = "rowheader" >
{ { s _ _ ( 'FeatureFlags|Rollout Strategy' ) } }
< / div >
< div class = "table-mobile-content js-rollout-strategy form-inline" >
< label class = "sr-only" for = "new-rollout-strategy-placeholder" > { {
s _ _ ( 'FeatureFlags|Rollout Strategy' )
} } < / label >
< div class = "select-wrapper col-12 col-md-8 p-0" >
< select
id = "new-rollout-strategy-placeholder"
disabled
class = "form-control select-control w-100"
>
< option > { { s _ _ ( 'FeatureFlags|All users' ) } } < / option >
< / select >
< gl-icon
name = "chevron-down"
class = "gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
: size = "16"
/ >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / fieldset >
< div class = "form-actions" >
< gl-button
ref = "submitButton"
: disabled = "readOnly"
type = "button"
variant = "success"
class = "js-ff-submit col-xs-12"
@ click = "handleSubmit"
> { { submitText } } < / g l - b u t t o n
>
< gl-button :href = "cancelPath" class = "js-ff-cancel col-xs-12 float-right" >
{ { _ _ ( 'Cancel' ) } }
< / gl-button >
< / div >
< / form >
< / template >