diff options
Diffstat (limited to 'app/assets/javascripts/feature_flags/components/form.vue')
-rw-r--r-- | app/assets/javascripts/feature_flags/components/form.vue | 616 |
1 files changed, 616 insertions, 0 deletions
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue new file mode 100644 index 00000000000..04bea2d80d4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -0,0 +1,616 @@ +<script> +import Vue from 'vue'; +import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash'; +import { + GlButton, + GlDeprecatedBadge as GlBadge, + GlTooltip, + GlTooltipDirective, + GlFormTextarea, + GlFormCheckbox, + GlSprintf, + GlIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash'; +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/modules/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: '', + }, + projectId: { + type: String, + required: true, + }, + scopes: { + type: Array, + required: false, + default: () => [], + }, + cancelPath: { + type: String, + required: true, + }, + submitText: { + type: String, + required: true, + }, + environmentsEndpoint: { + type: String, + required: true, + }, + featureFlagIssuesEndpoint: { + type: String, + required: false, + default: '', + }, + strategies: { + type: Array, + required: false, + default: () => [], + }, + version: { + type: String, + required: false, + default: LEGACY_FLAG, + }, + }, + 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: '', + userLists: [], + }; + }, + 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 + ); + }, + }, + mounted() { + if (this.supportsStrategies) { + Api.fetchFeatureFlagUserLists(this.projectId) + .then(({ data }) => { + this.userLists = data; + }) + .catch(() => { + flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING); + }); + } + }, + 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" + :endpoint="environmentsEndpoint" + :user-lists="userLists" + @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" + :endpoint="environmentsEndpoint" + :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="w-3rem"> + <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)" + > + {{ + s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100') + }} + </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') }}</gl-form-checkbox + > + <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" + :endpoint="environmentsEndpoint" + :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 }}</gl-button + > + <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right"> + {{ __('Cancel') }} + </gl-button> + </div> + </form> +</template> |