Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/feature_flags')
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue254
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue184
-rw-r--r--app/assets/javascripts/feature_flags/components/environments_dropdown.vue184
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue354
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_tab.vue108
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue274
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue616
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue106
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue134
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue327
-rw-r--r--app/assets/javascripts/feature_flags/components/user_lists_table.vue122
-rw-r--r--app/assets/javascripts/feature_flags/constants.js28
-rw-r--r--app/assets/javascripts/feature_flags/edit.js33
-rw-r--r--app/assets/javascripts/feature_flags/index.js41
-rw-r--r--app/assets/javascripts/feature_flags/new.js32
-rw-r--r--app/assets/javascripts/feature_flags/store/index.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/actions.js75
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js12
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/mutations.js45
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/helpers.js213
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/actions.js107
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js26
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/mutations.js125
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/actions.js51
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js6
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/mutations.js21
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/state.js6
-rw-r--r--app/assets/javascripts/feature_flags/utils.js48
33 files changed, 3616 insertions, 0 deletions
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
new file mode 100644
index 00000000000..b652cb329d7
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -0,0 +1,254 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlTooltipDirective,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import Callout from '~/vue_shared/components/callout.vue';
+
+export default {
+ cancelActionLabel: __('Close'),
+ modalTitle: s__('FeatureFlags|Configure feature flags'),
+ apiUrlLabelText: s__('FeatureFlags|API URL'),
+ apiUrlCopyText: __('Copy URL'),
+ instanceIdLabelText: s__('FeatureFlags|Instance ID'),
+ instanceIdCopyText: __('Copy ID'),
+ instanceIdRegenerateError: __('Unable to generate new instance ID'),
+ instanceIdRegenerateText: __(
+ 'Regenerating the instance ID can break integration depending on the client you are using.',
+ ),
+ instanceIdRegenerateActionLabel: __('Regenerate instance ID'),
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ ModalCopyButton,
+ GlIcon,
+ Callout,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ },
+
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+
+ props: {
+ helpClientLibrariesPath: {
+ type: String,
+ required: true,
+ },
+ helpClientExamplePath: {
+ type: String,
+ required: true,
+ },
+ apiUrl: {
+ type: String,
+ required: true,
+ },
+ instanceId: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: false,
+ default: 'configure-feature-flags',
+ },
+ isRotating: {
+ type: Boolean,
+ required: true,
+ },
+ hasRotateError: {
+ type: Boolean,
+ required: true,
+ },
+ canUserRotateToken: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ inject: ['projectName', 'featureFlagsHelpPagePath'],
+ data() {
+ return {
+ enteredProjectName: '',
+ };
+ },
+ computed: {
+ cancelActionProps() {
+ return {
+ text: this.$options.cancelActionLabel,
+ };
+ },
+ canRegenerateInstanceId() {
+ return this.canUserRotateToken && this.enteredProjectName === this.projectName;
+ },
+ regenerateInstanceIdActionProps() {
+ return this.canUserRotateToken
+ ? {
+ text: this.$options.instanceIdRegenerateActionLabel,
+ attributes: [
+ {
+ category: 'secondary',
+ disabled: !this.canRegenerateInstanceId,
+ loading: this.isRotating,
+ variant: 'danger',
+ },
+ ],
+ }
+ : null;
+ },
+ },
+
+ methods: {
+ clearState() {
+ this.enteredProjectName = '';
+ },
+ rotateToken() {
+ this.$emit('token');
+ this.clearState();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :action-cancel="cancelActionProps"
+ :action-primary="regenerateInstanceIdActionProps"
+ @canceled="clearState"
+ @hide="clearState"
+ @primary.prevent="rotateToken"
+ >
+ <template #modal-title>
+ {{ $options.modalTitle }}
+ </template>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Install a %{docsLinkAnchoredStart}compatible client library%{docsLinkAnchoredEnd} and specify the API URL, application name, and instance ID during the configuration setup. %{docsLinkStart}More Information%{docsLinkEnd}',
+ )
+ "
+ >
+ <template #docsLinkAnchored="{ content }">
+ <gl-link :href="helpClientLibrariesPath" target="_blank" data-testid="help-client-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank" data-testid="help-link">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <callout category="warning">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </callout>
+ <div class="form-group">
+ <label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label>
+ <div class="input-group">
+ <input
+ id="api_url"
+ :value="apiUrl"
+ readonly
+ class="form-control"
+ type="text"
+ name="api_url"
+ />
+ <span class="input-group-append">
+ <modal-copy-button
+ :text="apiUrl"
+ :title="$options.apiUrlCopyText"
+ :modal-id="modalId"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="instance_id" class="label-bold">{{ $options.instanceIdLabelText }}</label>
+ <div class="input-group">
+ <input
+ id="instance_id"
+ :value="instanceId"
+ class="form-control"
+ type="text"
+ name="instance_id"
+ readonly
+ :disabled="isRotating"
+ />
+
+ <gl-loading-icon
+ v-if="isRotating"
+ class="position-absolute align-self-center instance-id-loading-icon"
+ />
+
+ <div class="input-group-append">
+ <modal-copy-button
+ :text="instanceId"
+ :title="$options.instanceIdCopyText"
+ :modal-id="modalId"
+ :disabled="isRotating"
+ class="input-group-text"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="hasRotateError"
+ class="text-danger d-flex align-items-center font-weight-normal mb-2"
+ >
+ <gl-icon name="warning" class="mr-1" />
+ <span>{{ $options.instanceIdRegenerateError }}</span>
+ </div>
+ <callout
+ v-if="canUserRotateToken"
+ category="danger"
+ :message="$options.instanceIdRegenerateText"
+ />
+ <p v-if="canUserRotateToken" data-testid="prevent-accident-text">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.',
+ )
+ "
+ >
+ <template #projectName>
+ <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group>
+ <gl-form-input
+ v-if="canUserRotateToken"
+ id="project_name_verification"
+ v-model="enteredProjectName"
+ name="project_name"
+ type="text"
+ :disabled="isRotating"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
new file mode 100644
index 00000000000..7c9744da0e8
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -0,0 +1,184 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import { createNamespacedHelpers } from 'vuex';
+import axios from '~/lib/utils/axios_utils';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants';
+import store from '../store/index';
+import FeatureFlagForm from './form.vue';
+
+const { mapState, mapActions } = createNamespacedHelpers('edit');
+
+export default {
+ store,
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ GlToggle,
+ FeatureFlagForm,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ environmentsEndpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ featureFlagIssuesEndpoint: {
+ type: String,
+ required: true,
+ },
+ showUserCallout: {
+ type: Boolean,
+ required: true,
+ },
+ userCalloutId: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ userCalloutsPath: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags, and in 13.4, this feature flag will become read-only. Please create a new feature flag.',
+ ),
+ legacyReadOnlyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
+ ),
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState([
+ 'error',
+ 'name',
+ 'description',
+ 'scopes',
+ 'strategies',
+ 'isLoading',
+ 'hasError',
+ 'iid',
+ 'active',
+ 'version',
+ ]),
+ title() {
+ return this.iid
+ ? `^${this.iid} ${this.name}`
+ : sprintf(s__('Edit %{name}'), { name: this.name });
+ },
+ deprecated() {
+ return this.hasNewVersionFlags && this.version === LEGACY_FLAG;
+ },
+ deprecatedAndEditable() {
+ return this.deprecated && !this.hasLegacyReadOnlyFlags;
+ },
+ deprecatedAndReadOnly() {
+ return this.deprecated && this.hasLegacyReadOnlyFlags;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ hasLegacyReadOnlyFlags() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ },
+ created() {
+ this.setPath(this.path);
+ return this.setEndpoint(this.endpoint).then(() => this.fetchFeatureFlag());
+ },
+ methods: {
+ ...mapActions([
+ 'updateFeatureFlag',
+ 'setEndpoint',
+ 'setPath',
+ 'fetchFeatureFlag',
+ 'toggleActive',
+ ]),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <gl-loading-icon v-if="isLoading" />
+
+ <template v-else-if="!isLoading && !hasError">
+ <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyFlagAlert }}
+ </gl-alert>
+ <gl-alert v-if="deprecatedAndReadOnly" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyReadOnlyFlagAlert }}
+ </gl-alert>
+ <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4">
+ <gl-toggle
+ :value="active"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ class="gl-mr-4"
+ @change="toggleActive"
+ />
+ <h3 class="page-title gl-m-0">{{ title }}</h3>
+ </div>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :name="name"
+ :description="description"
+ :project-id="projectId"
+ :scopes="scopes"
+ :strategies="strategies"
+ :cancel-path="path"
+ :submit-text="__('Save changes')"
+ :environments-endpoint="environmentsEndpoint"
+ :feature-flag-issues-endpoint="featureFlagIssuesEndpoint"
+ :active="active"
+ :version="version"
+ @handleSubmit="data => updateFeatureFlag(data)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
new file mode 100644
index 00000000000..3533771e3ad
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
@@ -0,0 +1,184 @@
+<script>
+import { debounce } from 'lodash';
+import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+/**
+ * Creates a searchable input for environments.
+ *
+ * When given a value, it will render it as selected value
+ * Otherwise it will render a placeholder for the search input.
+ * It will fetch the available environments on focus.
+ *
+ * When the user types, it will trigger an event to allow
+ * for API queries outside of the component.
+ *
+ * When results are returned, it renders a selectable
+ * list with the suggestions
+ *
+ * When no results are returned, it will render a
+ * button with a `Create` label. When clicked, it will
+ * emit an event to allow for the creation of a new
+ * record.
+ *
+ */
+
+export default {
+ name: 'EnvironmentsSearchableInput',
+ components: {
+ GlDeprecatedButton,
+ GlSearchBoxByType,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Search an environment spec'),
+ },
+ createButtonLabel: {
+ type: String,
+ required: false,
+ default: __('Create'),
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ environmentSearch: this.value,
+ results: [],
+ showSuggestions: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ /**
+ * Creates a label with the value of the filter
+ * @returns {String}
+ */
+ composedCreateButtonLabel() {
+ return `${this.createButtonLabel} ${this.environmentSearch}`;
+ },
+ shouldRenderCreateButton() {
+ return !this.isLoading && !this.results.length;
+ },
+ },
+ methods: {
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ this.openSuggestions();
+ axios
+ .get(this.endpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.isLoading = false;
+ this.closeSuggestions();
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ });
+ }, 250),
+ /**
+ * Opens the list of suggestions
+ */
+ openSuggestions() {
+ this.showSuggestions = true;
+ },
+ /**
+ * Closes the list of suggestions and cleans the results
+ */
+ closeSuggestions() {
+ this.showSuggestions = false;
+ this.environmentSearch = '';
+ },
+ /**
+ * On click, it will:
+ * 1. clear the input value
+ * 2. close the list of suggestions
+ * 3. emit an event
+ */
+ clearInput() {
+ this.closeSuggestions();
+ this.$emit('clearInput');
+ },
+ /**
+ * When the user selects a value from the list of suggestions
+ *
+ * It emits an event with the selected value
+ * Clears the filter
+ * and closes the list of suggestions
+ *
+ * @param {String} selected
+ */
+ selectEnvironment(selected) {
+ this.$emit('selectEnvironment', selected);
+ this.results = [];
+ this.closeSuggestions();
+ },
+
+ /**
+ * When the user clicks the create button
+ * it emits an event with the filter value
+ */
+ createClicked() {
+ this.$emit('createClicked', this.environmentSearch);
+ this.closeSuggestions();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="dropdown position-relative">
+ <gl-search-box-by-type
+ v-model.trim="environmentSearch"
+ class="js-env-search"
+ :aria-label="placeholder"
+ :placeholder="placeholder"
+ :disabled="disabled"
+ :is-loading="isLoading"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <div
+ v-if="showSuggestions"
+ class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width"
+ >
+ <div class="dropdown-content">
+ <ul v-if="results.length">
+ <li v-for="(result, i) in results" :key="i">
+ <gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{
+ result
+ }}</gl-deprecated-button>
+ </li>
+ </ul>
+ <div v-else-if="!results.length" class="text-secondary gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="shouldRenderCreateButton" class="dropdown-footer">
+ <gl-deprecated-button
+ class="js-create-button btn-blank dropdown-item"
+ @click="createClicked"
+ >{{ composedCreateButtonLabel }}</gl-deprecated-button
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
new file mode 100644
index 00000000000..18008111a18
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -0,0 +1,354 @@
+<script>
+import { createNamespacedHelpers } from 'vuex';
+import { isEmpty } from 'lodash';
+import { GlButton, GlModalDirective, GlTabs } from '@gitlab/ui';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
+import FeatureFlagsTab from './feature_flags_tab.vue';
+import FeatureFlagsTable from './feature_flags_table.vue';
+import UserListsTable from './user_lists_table.vue';
+import store from '../store';
+import { s__ } from '~/locale';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import {
+ getParameterByName,
+ historyPushState,
+ buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+
+import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
+
+const { mapState, mapActions } = createNamespacedHelpers('index');
+
+const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
+
+export default {
+ store,
+ components: {
+ FeatureFlagsTable,
+ UserListsTable,
+ TablePagination,
+ GlButton,
+ GlTabs,
+ FeatureFlagsTab,
+ ConfigureFeatureFlagsModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ featureFlagsClientLibrariesHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ featureFlagsClientExampleHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ rotateInstanceIdPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ unleashApiUrl: {
+ type: String,
+ required: true,
+ },
+ unleashApiInstanceId: {
+ type: String,
+ required: true,
+ },
+ canUserConfigure: {
+ type: Boolean,
+ required: true,
+ },
+ newFeatureFlagPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ newUserListPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE;
+ return {
+ scope,
+ page: getParameterByName('page') || '1',
+ isUserListAlertDismissed: false,
+ selectedTab: Object.values(SCOPES).indexOf(scope),
+ };
+ },
+ computed: {
+ ...mapState([
+ FEATURE_FLAG_SCOPE,
+ USER_LIST_SCOPE,
+ 'alerts',
+ 'count',
+ 'pageInfo',
+ 'isLoading',
+ 'hasError',
+ 'options',
+ 'instanceId',
+ 'isRotating',
+ 'hasRotateError',
+ ]),
+ topAreaBaseClasses() {
+ return ['gl-display-flex', 'gl-flex-direction-column'];
+ },
+ canUserRotateToken() {
+ return this.rotateInstanceIdPath !== '';
+ },
+ currentlyDisplayedData() {
+ return this.dataForScope(this.scope);
+ },
+ shouldRenderPagination() {
+ return (
+ !this.isLoading &&
+ !this.hasError &&
+ this.currentlyDisplayedData.length > 0 &&
+ this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage
+ );
+ },
+ shouldShowEmptyState() {
+ return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0;
+ },
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
+ shouldRenderFeatureFlags() {
+ return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ shouldRenderUserLists() {
+ return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE);
+ },
+ hasNewPath() {
+ return !isEmpty(this.newFeatureFlagPath);
+ },
+ emptyStateTitle() {
+ return s__('FeatureFlags|Get started with feature flags');
+ },
+ },
+ created() {
+ this.setFeatureFlagsEndpoint(this.endpoint);
+ this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
+ this.setProjectId(this.projectId);
+ this.fetchFeatureFlags();
+ this.fetchUserLists();
+ this.setInstanceId(this.unleashApiInstanceId);
+ this.setInstanceIdEndpoint(this.rotateInstanceIdPath);
+ },
+ methods: {
+ ...mapActions([
+ 'setFeatureFlagsEndpoint',
+ 'setFeatureFlagsOptions',
+ 'fetchFeatureFlags',
+ 'fetchUserLists',
+ 'setInstanceIdEndpoint',
+ 'setInstanceId',
+ 'setProjectId',
+ 'rotateInstanceId',
+ 'toggleFeatureFlag',
+ 'deleteUserList',
+ 'clearAlert',
+ ]),
+ onChangeTab(scope) {
+ this.scope = scope;
+ this.updateFeatureFlagOptions({
+ scope,
+ page: '1',
+ });
+ },
+ onFeatureFlagsTab() {
+ this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ onUserListsTab() {
+ this.onChangeTab(SCOPES.USER_LIST_SCOPE);
+ },
+ onChangePage(page) {
+ this.updateFeatureFlagOptions({
+ scope: this.scope,
+ /* URLS parameters are strings, we need to parse to match types */
+ page: Number(page).toString(),
+ });
+ },
+ updateFeatureFlagOptions(parameters) {
+ const queryString = Object.keys(parameters)
+ .map(parameter => {
+ const value = parameters[parameter];
+ return `${parameter}=${encodeURIComponent(value)}`;
+ })
+ .join('&');
+
+ historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
+ this.setFeatureFlagsOptions(parameters);
+ if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) {
+ this.fetchFeatureFlags();
+ } else {
+ this.fetchUserLists();
+ }
+ },
+ shouldRenderTable(scope) {
+ return (
+ !this.isLoading &&
+ this.dataForScope(scope).length > 0 &&
+ !this.hasError &&
+ this.scope === scope
+ );
+ },
+ dataForScope(scope) {
+ return this[scope];
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <configure-feature-flags-modal
+ v-if="canUserConfigure"
+ :help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath"
+ :help-client-example-path="featureFlagsClientExampleHelpPagePath"
+ :api-url="unleashApiUrl"
+ :instance-id="instanceId"
+ :is-rotating="isRotating"
+ :has-rotate-error="hasRotateError"
+ :can-user-rotate-token="canUserRotateToken"
+ modal-id="configure-feature-flags"
+ @token="rotateInstanceId()"
+ />
+ <div :class="topAreaBaseClasses">
+ <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!">
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-3"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-3"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full">
+ <feature-flags-tab
+ :title="s__('FeatureFlags|Feature Flags')"
+ :count="count.featureFlags"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading feature flags')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="feature-flags-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onFeatureFlagsTab"
+ >
+ <feature-flags-table
+ v-if="shouldRenderFeatureFlags"
+ :csrf-token="csrfToken"
+ :feature-flags="featureFlags"
+ @toggle-flag="toggleFeatureFlag"
+ />
+ </feature-flags-tab>
+ <feature-flags-tab
+ :title="s__('FeatureFlags|User Lists')"
+ :count="count.userLists"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading user lists')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="user-lists-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onUserListsTab"
+ >
+ <user-lists-table
+ v-if="shouldRenderUserLists"
+ :user-lists="userLists"
+ @delete="deleteUserList"
+ />
+ </feature-flags-tab>
+ <template #tabs-end>
+ <div
+ class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end"
+ >
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-0 gl-mr-4"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-0 gl-mr-4"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-tabs>
+ </div>
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="onChangePage"
+ :page-info="pageInfo[scope]"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
new file mode 100644
index 00000000000..5c35aa33e14
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui';
+
+export default {
+ components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab },
+ props: {
+ title: {
+ required: true,
+ type: String,
+ },
+ count: {
+ required: false,
+ type: Number,
+ default: null,
+ },
+ alerts: {
+ required: true,
+ type: Array,
+ },
+ isLoading: {
+ required: true,
+ type: Boolean,
+ },
+ loadingLabel: {
+ required: true,
+ type: String,
+ },
+ errorState: {
+ required: true,
+ type: Boolean,
+ },
+ errorTitle: {
+ required: true,
+ type: String,
+ },
+ emptyState: {
+ required: true,
+ type: Boolean,
+ },
+ emptyTitle: {
+ required: true,
+ type: String,
+ },
+ },
+ inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'],
+ computed: {
+ itemCount() {
+ return this.count ?? 0;
+ },
+ },
+ methods: {
+ clearAlert(index) {
+ this.$emit('dismissAlert', index);
+ },
+ onClick(event) {
+ return this.$emit('changeTab', event);
+ },
+ },
+};
+</script>
+<template>
+ <gl-tab @click="onClick">
+ <template #title>
+ <span data-testid="feature-flags-tab-title">{{ title }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge>
+ </template>
+ <template>
+ <gl-alert
+ v-for="(message, index) in alerts"
+ :key="index"
+ data-testid="serverErrors"
+ variant="danger"
+ @dismiss="clearAlert(index)"
+ >
+ {{ message }}
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" />
+
+ <gl-empty-state
+ v-else-if="errorState"
+ :title="errorTitle"
+ :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)"
+ :svg-path="errorStateSvgPath"
+ data-testid="error-state"
+ />
+
+ <gl-empty-state
+ v-else-if="emptyState"
+ :title="emptyTitle"
+ :svg-path="errorStateSvgPath"
+ data-testid="empty-state"
+ >
+ <template #description>
+ {{
+ s__(
+ 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ )
+ }}
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank">
+ {{ s__('FeatureFlags|More information') }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ <slot> </slot>
+ </template>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
new file mode 100644
index 00000000000..7881ae523fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -0,0 +1,274 @@
+<script>
+import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
+import labelForStrategy from '../utils';
+
+export default {
+ components: {
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlToggle,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ featureFlags: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteFeatureFlagUrl: null,
+ deleteFeatureFlagName: null,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__('FeatureFlags|Flag becomes read only soon'),
+ legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'),
+ },
+ computed: {
+ permissions() {
+ return this.glFeatures.featureFlagPermissions;
+ },
+ isNewVersionFlagsEnabled() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ isLegacyReadOnlyFlagsEnabled() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ modalTitle() {
+ return sprintf(s__('FeatureFlags|Delete %{name}?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ deleteModalMessage() {
+ return sprintf(s__('FeatureFlags|Feature flag %{name} will be removed. Are you sure?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ modalId() {
+ return 'delete-feature-flag';
+ },
+ legacyFlagToolTipText() {
+ const { legacyFlagReadOnlyAlert, legacyFlagAlert } = this.$options.translations;
+
+ return this.isLegacyReadOnlyFlagsEnabled ? legacyFlagReadOnlyAlert : legacyFlagAlert;
+ },
+ },
+ methods: {
+ isLegacyFlag(flag) {
+ return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG;
+ },
+ statusToggleDisabled(flag) {
+ return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG;
+ },
+ scopeTooltipText(scope) {
+ return !scope.active
+ ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
+ scope: scope.environmentScope,
+ })
+ : '';
+ },
+ badgeText(scope) {
+ const displayName =
+ scope.environmentScope === '*'
+ ? s__('FeatureFlags|* (All environments)')
+ : scope.environmentScope;
+
+ const displayPercentage =
+ scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
+ ? `: ${scope.rolloutPercentage}%`
+ : '';
+
+ return `${displayName}${displayPercentage}`;
+ },
+ badgeVariant(scope) {
+ return scope.active ? 'info' : 'muted';
+ },
+ strategyBadgeText(strategy) {
+ return labelForStrategy(strategy);
+ },
+ featureFlagIidText(featureFlag) {
+ return featureFlag.iid ? `^${featureFlag.iid}` : '';
+ },
+ canDeleteFlag(flag) {
+ return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
+ },
+ setDeleteModalData(featureFlag) {
+ this.deleteFeatureFlagUrl = featureFlag.destroy_path;
+ this.deleteFeatureFlagName = featureFlag.name;
+
+ this.$refs[this.modalId].show();
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ toggleFeatureFlag(flag) {
+ this.$emit('toggle-flag', {
+ ...flag,
+ active: !flag.active,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-holder js-feature-flag-table">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10">
+ {{ s__('FeatureFlags|ID') }}
+ </div>
+ <div class="table-section section-10" role="columnheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-section section-20" role="columnheader">
+ {{ s__('FeatureFlags|Feature Flag') }}
+ </div>
+ <div class="table-section section-40" role="columnheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ </div>
+
+ <template v-for="featureFlag in featureFlags">
+ <div :key="featureFlag.id" class="gl-responsive-table-row" role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
+ <div class="table-mobile-content js-feature-flag-id">
+ {{ featureFlagIidText(featureFlag) }}
+ </div>
+ </div>
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
+ <div class="table-mobile-content">
+ <gl-toggle
+ v-if="featureFlag.update_path"
+ :value="featureFlag.active"
+ :disabled="statusToggleDisabled(featureFlag)"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ @change="toggleFeatureFlag(featureFlag)"
+ />
+ <gl-badge
+ v-else-if="featureFlag.active"
+ variant="success"
+ data-testid="feature-flag-status-badge"
+ >
+ {{ s__('FeatureFlags|Active') }}
+ </gl-badge>
+ <gl-badge v-else variant="danger">{{ s__('FeatureFlags|Inactive') }}</gl-badge>
+ </div>
+ </div>
+
+ <div class="table-section section-20" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Feature Flag') }}
+ </div>
+ <div class="table-mobile-content d-flex flex-column js-feature-flag-title">
+ <div class="gl-display-flex gl-align-items-center">
+ <div class="feature-flag-name text-monospace text-truncate">
+ {{ featureFlag.name }}
+ </div>
+ <gl-icon
+ v-if="isLegacyFlag(featureFlag)"
+ v-gl-tooltip.hover="legacyFlagToolTipText"
+ class="gl-ml-3"
+ name="information-o"
+ />
+ </div>
+ <div class="feature-flag-description text-secondary text-truncate">
+ {{ featureFlag.description }}
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ <div
+ class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
+ >
+ <template v-if="isLegacyFlag(featureFlag)">
+ <gl-badge
+ v-for="scope in featureFlag.scopes"
+ :key="scope.id"
+ v-gl-tooltip.hover="scopeTooltipText(scope)"
+ :variant="badgeVariant(scope)"
+ :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ badgeText(scope) }}
+ </gl-badge>
+ </template>
+ <template v-else>
+ <gl-badge
+ v-for="strategy in featureFlag.strategies"
+ :key="strategy.id"
+ data-testid="strategy-badge"
+ variant="info"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ strategyBadgeText(strategy) }}
+ </gl-badge>
+ </template>
+ </div>
+ </div>
+
+ <div class="table-section section-20 table-button-footer" role="gridcell">
+ <div class="table-action-buttons btn-group">
+ <template v-if="featureFlag.edit_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Edit')"
+ class="js-feature-flag-edit-button"
+ icon="pencil"
+ :href="featureFlag.edit_path"
+ />
+ </template>
+ <template v-if="featureFlag.destroy_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Delete')"
+ class="js-feature-flag-delete-button"
+ variant="danger"
+ icon="remove"
+ :disabled="!canDeleteFlag(featureFlag)"
+ @click="setDeleteModalData(featureFlag)"
+ />
+ </template>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <gl-modal
+ :ref="modalId"
+ :title="modalTitle"
+ :ok-title="s__('FeatureFlags|Delete feature flag')"
+ :modal-id="modalId"
+ title-tag="h4"
+ ok-variant="danger"
+ category="primary"
+ @ok="onSubmit"
+ >
+ {{ deleteModalMessage }}
+ <form ref="form" :action="deleteFeatureFlagUrl" method="post" class="js-requires-input">
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
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>
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
new file mode 100644
index 00000000000..2888746005e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -0,0 +1,106 @@
+<script>
+import { debounce } from 'lodash';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlIcon,
+ GlLoadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ environmentSearch: '',
+ results: [],
+ isLoading: false,
+ };
+ },
+ translations: {
+ addEnvironmentsLabel: __('Add environment'),
+ noResultsLabel: __('No matching results'),
+ },
+ computed: {
+ createEnvironmentLabel() {
+ return sprintf(__('Create %{environment}'), { environment: this.environmentSearch });
+ },
+ },
+ methods: {
+ addEnvironment(newEnvironment) {
+ this.$emit('add', newEnvironment);
+ this.environmentSearch = '';
+ this.results = [];
+ },
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ axios
+ .get(this.endpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ })
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ }, 250),
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus">
+ <template #button-content>
+ <span class="d-md-none mr-1">
+ {{ $options.translations.addEnvironmentsLabel }}
+ </span>
+ <gl-icon class="d-none d-md-inline-flex" name="plus" />
+ </template>
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model.trim="environmentSearch"
+ class="gl-m-3"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <gl-loading-icon v-if="isLoading" />
+ <gl-dropdown-item
+ v-for="environment in results"
+ v-else-if="results.length"
+ :key="environment"
+ @click="addEnvironment(environment)"
+ >
+ {{ environment }}
+ </gl-dropdown-item>
+ <template v-else-if="environmentSearch.length">
+ <span ref="noResults" class="text-secondary gl-p-3">
+ {{ $options.translations.noMatchingResults }}
+ </span>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="addEnvironment(environmentSearch)">
+ {{ createEnvironmentLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
new file mode 100644
index 00000000000..df19667a3ae
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -0,0 +1,134 @@
+<script>
+import { createNamespacedHelpers } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import store from '../store/index';
+import FeatureFlagForm from './form.vue';
+import {
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+ NEW_FLAG_ALERT,
+ ROLLOUT_STRATEGY_ALL_USERS,
+} from '../constants';
+import { createNewEnvironmentScope } from '../store/modules/helpers';
+
+import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+const { mapState, mapActions } = createNamespacedHelpers('new');
+
+export default {
+ store,
+ components: {
+ GlAlert,
+ FeatureFlagForm,
+ },
+ mixins: [featureFlagsMixin()],
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ environmentsEndpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ showUserCallout: {
+ type: Boolean,
+ required: true,
+ },
+ userCalloutId: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ userCalloutsPath: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState(['error']),
+ scopes() {
+ return [
+ createNewEnvironmentScope(
+ {
+ environmentScope: '*',
+ active: true,
+ },
+ this.glFeatures.featureFlagsPermissions,
+ ),
+ ];
+ },
+ version() {
+ return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ strategies() {
+ return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
+ },
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+ this.setPath(this.path);
+ },
+ methods: {
+ ...mapActions(['createFeatureFlag', 'setEndpoint', 'setPath']),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :project-id="projectId"
+ :cancel-path="path"
+ :submit-text="s__('FeatureFlags|Create feature flag')"
+ :scopes="scopes"
+ :strategies="strategies"
+ :environments-endpoint="environmentsEndpoint"
+ :version="version"
+ @handleSubmit="data => createFeatureFlag(data)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
new file mode 100644
index 00000000000..3f10ec00aa5
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -0,0 +1,327 @@
+<script>
+import Vue from 'vue';
+import { isNumber } from 'lodash';
+import {
+ GlButton,
+ GlFormSelect,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ GlIcon,
+ GlLink,
+ GlToken,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import {
+ PERCENT_ROLLOUT_GROUP_ID,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from '../constants';
+
+import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlToken,
+ NewEnvironmentsDropdown,
+ },
+ model: {
+ prop: 'strategy',
+ event: 'change',
+ },
+ inject: {
+ strategyTypeDocsPagePath: {
+ type: String,
+ },
+ environmentsScopeDocsPath: {
+ type: String,
+ },
+ },
+ props: {
+ strategy: {
+ type: Object,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ userLists: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+
+ i18n: {
+ allEnvironments: __('All environments'),
+ environmentsLabel: __('Environments'),
+ environmentsSelectDescription: __('Select the environment scope for this feature flag.'),
+ rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
+ rolloutPercentageInvalid: s__(
+ 'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
+ ),
+ rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
+ rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'),
+ rolloutUserIdsLabel: s__('FeatureFlag|User IDs'),
+ rolloutUserListLabel: s__('FeatureFlag|List'),
+ rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
+ rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
+ strategyTypeDescription: __('Select strategy activation method.'),
+ strategyTypeLabel: s__('FeatureFlag|Type'),
+ },
+
+ data() {
+ return {
+ environments: this.strategy.scopes || [],
+ formStrategy: { ...this.strategy },
+ formPercentage:
+ this.strategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
+ ? this.strategy.parameters.percentage
+ : '',
+ formUserIds:
+ this.strategy.name === ROLLOUT_STRATEGY_USER_ID ? this.strategy.parameters.userIds : '',
+ formUserListId:
+ this.strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST ? this.strategy.userListId : '',
+ strategies: [
+ {
+ value: ROLLOUT_STRATEGY_ALL_USERS,
+ text: __('All users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ text: __('Percent of users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_USER_ID,
+ text: __('User IDs'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ text: __('User List'),
+ },
+ ],
+ };
+ },
+ computed: {
+ strategyTypeId() {
+ return `strategy-type-${this.index}`;
+ },
+ strategyPercentageId() {
+ return `strategy-percentage-${this.index}`;
+ },
+ strategyUserIdsId() {
+ return `strategy-user-ids-${this.index}`;
+ },
+ strategyUserListId() {
+ return `strategy-user-list-${this.index}`;
+ },
+ environmentsDropdownId() {
+ return `environments-dropdown-${this.index}`;
+ },
+ isPercentRollout() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_PERCENT_ROLLOUT);
+ },
+ isUserWithId() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_USER_ID);
+ },
+ isUserList() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_GITLAB_USER_LIST);
+ },
+ appliesToAllEnvironments() {
+ return (
+ this.filteredEnvironments.length === 1 &&
+ this.filteredEnvironments[0].environmentScope === '*'
+ );
+ },
+ filteredEnvironments() {
+ return this.environments.filter(e => !e.shouldBeDestroyed);
+ },
+ userListOptions() {
+ return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
+ },
+ hasUserLists() {
+ return this.userListOptions.length > 0;
+ },
+ },
+ methods: {
+ addEnvironment(environment) {
+ const allEnvironmentsScope = this.environments.find(scope => scope.environmentScope === '*');
+ if (allEnvironmentsScope) {
+ allEnvironmentsScope.shouldBeDestroyed = true;
+ }
+ this.environments.push({ environmentScope: environment });
+ this.onStrategyChange();
+ },
+ onStrategyChange() {
+ const parameters = {};
+ const strategy = {
+ ...this.formStrategy,
+ scopes: this.environments,
+ };
+ switch (this.formStrategy.name) {
+ case ROLLOUT_STRATEGY_PERCENT_ROLLOUT:
+ parameters.percentage = this.formPercentage;
+ parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
+ break;
+ case ROLLOUT_STRATEGY_USER_ID:
+ parameters.userIds = this.formUserIds;
+ break;
+ case ROLLOUT_STRATEGY_GITLAB_USER_LIST:
+ strategy.userListId = this.formUserListId;
+ break;
+ default:
+ break;
+ }
+ this.$emit('change', {
+ ...strategy,
+ parameters,
+ });
+ },
+ removeScope(environment) {
+ if (isNumber(environment.id)) {
+ Vue.set(environment, 'shouldBeDestroyed', true);
+ } else {
+ this.environments = this.environments.filter(e => e !== environment);
+ }
+ if (this.filteredEnvironments.length === 0) {
+ this.environments.push({ environmentScope: '*' });
+ }
+ this.onStrategyChange();
+ },
+ isStrategyType(type) {
+ return this.formStrategy.name === type;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6">
+ <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap">
+ <div class="mr-5">
+ <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId">
+ <p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p>
+ <gl-link :href="strategyTypeDocsPagePath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ <gl-form-select
+ :id="strategyTypeId"
+ v-model="formStrategy.name"
+ :options="strategies"
+ @change="onStrategyChange"
+ />
+ </gl-form-group>
+ </div>
+
+ <div data-testid="strategy">
+ <gl-form-group
+ v-if="isPercentRollout"
+ :label="$options.i18n.rolloutPercentageLabel"
+ :description="$options.i18n.rolloutPercentageDescription"
+ :label-for="strategyPercentageId"
+ :invalid-feedback="$options.i18n.rolloutPercentageInvalid"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-input
+ :id="strategyPercentageId"
+ v-model="formPercentage"
+ class="rollout-percentage gl-text-right gl-w-9"
+ type="number"
+ @input="onStrategyChange"
+ />
+ <span class="gl-ml-2">%</span>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="isUserWithId"
+ :label="$options.i18n.rolloutUserIdsLabel"
+ :description="$options.i18n.rolloutUserIdsDescription"
+ :label-for="strategyUserIdsId"
+ >
+ <gl-form-textarea
+ :id="strategyUserIdsId"
+ v-model="formUserIds"
+ @input="onStrategyChange"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isUserList"
+ :state="hasUserLists"
+ :invalid-feedback="$options.i18n.rolloutUserListNoListError"
+ :label="$options.i18n.rolloutUserListLabel"
+ :description="$options.i18n.rolloutUserListDescription"
+ :label-for="strategyUserListId"
+ >
+ <gl-form-select
+ :id="strategyUserListId"
+ v-model="formUserListId"
+ :options="userListOptions"
+ @change="onStrategyChange"
+ />
+ </gl-form-group>
+ </div>
+
+ <div
+ class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto"
+ >
+ <gl-button
+ data-testid="delete-strategy-button"
+ variant="danger"
+ icon="remove"
+ @click="$emit('delete')"
+ />
+ </div>
+ </div>
+ <label class="gl-display-block" :for="environmentsDropdownId">{{
+ $options.i18n.environmentsLabel
+ }}</label>
+ <p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p>
+ <gl-link :href="environmentsScopeDocsPath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
+ >
+ <new-environments-dropdown
+ :id="environmentsDropdownId"
+ :endpoint="endpoint"
+ class="gl-mr-3"
+ @add="addEnvironment"
+ />
+ <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
+ {{ $options.i18n.allEnvironments }}
+ </span>
+ <div v-else class="gl-display-flex gl-align-items-center">
+ <gl-token
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
+ @close="removeScope(environment)"
+ >
+ {{ environment.environmentScope }}
+ </gl-token>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
new file mode 100644
index 00000000000..0bfd18f992c
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
@@ -0,0 +1,122 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: { GlButton, GlButtonGroup, GlModal, GlSprintf },
+ directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective },
+ mixins: [timeagoMixin],
+ props: {
+ userLists: {
+ type: Array,
+ required: true,
+ },
+ },
+ translations: {
+ createdTimeagoLabel: s__('UserList|created %{timeago}'),
+ deleteListTitle: s__('UserList|Delete %{name}?'),
+ deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ },
+ modal: {
+ id: 'deleteListModal',
+ actionPrimary: {
+ text: s__('Delete user list'),
+ attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
+ },
+ },
+ data() {
+ return {
+ deleteUserList: null,
+ };
+ },
+ computed: {
+ deleteListName() {
+ return this.deleteUserList?.name;
+ },
+ modalTitle() {
+ return sprintf(this.$options.translations.deleteListTitle, {
+ name: this.deleteListName,
+ });
+ },
+ },
+ methods: {
+ createdTimeago(list) {
+ return sprintf(this.$options.translations.createdTimeagoLabel, {
+ timeago: this.timeFormatted(list.created_at),
+ });
+ },
+ displayList(list) {
+ return list.user_xids.replace(/,/g, ', ');
+ },
+ onDelete() {
+ this.$emit('delete', this.deleteUserList);
+ },
+ confirmDeleteList(list) {
+ this.deleteUserList = list;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-for="list in userLists"
+ :key="list.id"
+ data-testid="ffUserList"
+ class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between"
+ >
+ <div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1">
+ <span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2">
+ {{ list.name }}
+ </span>
+ <span
+ v-gl-tooltip
+ :title="tooltipTitle(list.created_at)"
+ data-testid="ffUserListTimestamp"
+ class="gl-text-gray-300 gl-mb-2"
+ >
+ {{ createdTimeago(list) }}
+ </span>
+ <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
+ </div>
+
+ <gl-button-group class="gl-align-self-start gl-mt-2">
+ <gl-button
+ :href="list.path"
+ category="secondary"
+ icon="pencil"
+ data-testid="edit-user-list"
+ />
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ data-testid="delete-user-list"
+ @click="confirmDeleteList(list)"
+ />
+ </gl-button-group>
+ </div>
+ <gl-modal
+ :title="modalTitle"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ static
+ @primary="onDelete"
+ >
+ <gl-sprintf :message="$options.translations.deleteListMessage">
+ <template #name>
+ <b>{{ deleteListName }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
new file mode 100644
index 00000000000..f59414ab1a7
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -0,0 +1,28 @@
+import { property } from 'lodash';
+import { s__ } from '~/locale';
+
+export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
+export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
+export const ROLLOUT_STRATEGY_USER_ID = 'userWithId';
+export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
+
+export const PERCENT_ROLLOUT_GROUP_ID = 'default';
+
+export const DEFAULT_PERCENT_ROLLOUT = '100';
+
+export const ALL_ENVIRONMENTS_NAME = '*';
+
+export const INTERNAL_ID_PREFIX = 'internal_';
+
+export const fetchPercentageParams = property(['parameters', 'percentage']);
+export const fetchUserIdParams = property(['parameters', 'userIds']);
+
+export const NEW_VERSION_FLAG = 'new_version_flag';
+export const LEGACY_FLAG = 'legacy_flag';
+
+export const NEW_FLAG_ALERT = s__(
+ 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
+);
+
+export const FEATURE_FLAG_SCOPE = 'featureFlags';
+export const USER_LIST_SCOPE = 'userLists';
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
new file mode 100644
index 00000000000..390a1f7555d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default () => {
+ const el = document.querySelector('#js-edit-feature-flag');
+ const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ EditFeatureFlag,
+ },
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ },
+ render(createElement) {
+ return createElement('edit-feature-flag', {
+ props: {
+ endpoint: el.dataset.endpoint,
+ path: el.dataset.featureFlagsPath,
+ environmentsEndpoint: el.dataset.environmentsEndpoint,
+ projectId: el.dataset.projectId,
+ featureFlagIssuesEndpoint: el.dataset.featureFlagIssuesEndpoint,
+ userCalloutsPath: el.dataset.userCalloutsPath,
+ userCalloutId: el.dataset.userCalloutId,
+ showUserCallout: parseBoolean(el.dataset.showUserCallout),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js
new file mode 100644
index 00000000000..90857c5f2da
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue';
+import csrf from '~/lib/utils/csrf';
+
+export default () =>
+ new Vue({
+ el: '#feature-flags-vue',
+ components: {
+ FeatureFlagsComponent,
+ },
+ data() {
+ return {
+ dataset: document.querySelector(this.$options.el).dataset,
+ };
+ },
+ provide() {
+ return {
+ projectName: this.dataset.projectName,
+ featureFlagsHelpPagePath: this.dataset.featureFlagsHelpPagePath,
+ errorStateSvgPath: this.dataset.errorStateSvgPath,
+ };
+ },
+ render(createElement) {
+ return createElement('feature-flags-component', {
+ props: {
+ endpoint: this.dataset.endpoint,
+ projectId: this.dataset.projectId,
+ featureFlagsClientLibrariesHelpPagePath: this.dataset
+ .featureFlagsClientLibrariesHelpPagePath,
+ featureFlagsClientExampleHelpPagePath: this.dataset.featureFlagsClientExampleHelpPagePath,
+ unleashApiUrl: this.dataset.unleashApiUrl,
+ unleashApiInstanceId: this.dataset.unleashApiInstanceId || '',
+ csrfToken: csrf.token,
+ canUserConfigure: this.dataset.canUserAdminFeatureFlag,
+ newFeatureFlagPath: this.dataset.newFeatureFlagPath,
+ rotateInstanceIdPath: this.dataset.rotateInstanceIdPath,
+ newUserListPath: this.dataset.newUserListPath,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js
new file mode 100644
index 00000000000..f14dd151910
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/new.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default () => {
+ const el = document.querySelector('#js-new-feature-flag');
+ const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ NewFeatureFlag,
+ },
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ },
+ render(createElement) {
+ return createElement('new-feature-flag', {
+ props: {
+ endpoint: el.dataset.endpoint,
+ path: el.dataset.featureFlagsPath,
+ environmentsEndpoint: el.dataset.environmentsEndpoint,
+ projectId: el.dataset.projectId,
+ userCalloutsPath: el.dataset.userCalloutsPath,
+ userCalloutId: el.dataset.userCalloutId,
+ showUserCallout: parseBoolean(el.dataset.showUserCallout),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/store/index.js b/app/assets/javascripts/feature_flags/store/index.js
new file mode 100644
index 00000000000..f4f49c20895
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import indexModule from './modules/index';
+import newModule from './modules/new';
+import editModule from './modules/edit';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ modules: {
+ index: indexModule,
+ new: newModule,
+ edit: editModule,
+ },
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/actions.js b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js
new file mode 100644
index 00000000000..351f36d8fa6
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js
@@ -0,0 +1,75 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+import { NEW_VERSION_FLAG } from '../../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Commits mutation to set the main endpoint
+ * @param {String} endpoint
+ */
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * Commits mutation to set the feature flag path.
+ * Used to redirect the user after form submission
+ *
+ * @param {String} path
+ */
+export const setPath = ({ commit }, path) => commit(types.SET_PATH, path);
+
+/**
+ * Handles the edition of a feature flag.
+ *
+ * Will dispatch `requestUpdateFeatureFlag`
+ * Serializes the params and makes a put request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const updateFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestUpdateFeatureFlag');
+
+ axios
+ .put(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveUpdateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveUpdateFeatureFlagError', error.response.data));
+};
+
+export const requestUpdateFeatureFlag = ({ commit }) => commit(types.REQUEST_UPDATE_FEATURE_FLAG);
+export const receiveUpdateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS);
+export const receiveUpdateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, error);
+
+/**
+ * Fetches the feature flag data for the edit form
+ */
+export const fetchFeatureFlag = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlag');
+
+ axios
+ .get(state.endpoint)
+ .then(({ data }) => dispatch('receiveFeatureFlagSuccess', data))
+ .catch(() => dispatch('receiveFeatureFlagError'));
+};
+
+export const requestFeatureFlag = ({ commit }) => commit(types.REQUEST_FEATURE_FLAG);
+export const receiveFeatureFlagSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response);
+export const receiveFeatureFlagError = ({ commit }) => {
+ commit(types.RECEIVE_FEATURE_FLAG_ERROR);
+ createFlash(__('Something went wrong on our end. Please try again!'));
+};
+
+export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active);
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/index.js b/app/assets/javascripts/feature_flags/store/modules/edit/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js
new file mode 100644
index 00000000000..b2715e501f4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js
@@ -0,0 +1,12 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATH = 'SET_PATH';
+
+export const REQUEST_UPDATE_FEATURE_FLAG = 'REQUEST_UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_FEATURE_FLAG = 'REQUEST_FEATURE_FLAG';
+export const RECEIVE_FEATURE_FLAG_SUCCESS = 'RECEIVE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_FEATURE_FLAG_ERROR = 'RECEIVE_FEATURE_FLAG_ERROR';
+
+export const TOGGLE_ACTIVE = 'TOGGLE_ACTIVE';
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js
new file mode 100644
index 00000000000..1d2721e037d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js
@@ -0,0 +1,45 @@
+import * as types from './mutation_types';
+import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers';
+import { LEGACY_FLAG } from '../../../constants';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_PATH](state, path) {
+ state.path = path;
+ },
+ [types.REQUEST_FEATURE_FLAG](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAG_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+
+ state.name = response.name;
+ state.description = response.description;
+ state.iid = response.iid;
+ state.active = response.active;
+ state.scopes = mapToScopesViewModel(response.scopes);
+ state.strategies = mapStrategiesToViewModel(response.strategies);
+ state.version = response.version || LEGACY_FLAG;
+ },
+ [types.RECEIVE_FEATURE_FLAG_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_UPDATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+ [types.TOGGLE_ACTIVE](state, active) {
+ state.active = active;
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/state.js b/app/assets/javascripts/feature_flags/store/modules/edit/state.js
new file mode 100644
index 00000000000..7de05b49482
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/state.js
@@ -0,0 +1,18 @@
+import { LEGACY_FLAG } from '../../../constants';
+
+export default () => ({
+ endpoint: null,
+ path: null,
+ isSendingRequest: false,
+ error: [],
+
+ name: null,
+ description: null,
+ scopes: [],
+ isLoading: false,
+ hasError: false,
+ iid: null,
+ active: true,
+ strategies: [],
+ version: LEGACY_FLAG,
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/helpers.js b/app/assets/javascripts/feature_flags/store/modules/helpers.js
new file mode 100644
index 00000000000..5a8d7bc6af3
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/helpers.js
@@ -0,0 +1,213 @@
+import { isEmpty, uniqueId, isString } from 'lodash';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ PERCENT_ROLLOUT_GROUP_ID,
+ fetchPercentageParams,
+ fetchUserIdParams,
+ LEGACY_FLAG,
+} from '../../constants';
+
+/**
+ * Converts raw scope objects fetched from the API into an array of scope
+ * objects that is easier/nicer to bind to in Vue.
+ * @param {Array} scopesFromRails An array of scope objects fetched from the API
+ */
+export const mapToScopesViewModel = scopesFromRails =>
+ (scopesFromRails || []).map(s => {
+ const percentStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ );
+
+ const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT;
+
+ const userStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_USER_ID,
+ );
+
+ const rolloutStrategy =
+ (percentStrategy && percentStrategy.name) ||
+ (userStrategy && userStrategy.name) ||
+ ROLLOUT_STRATEGY_ALL_USERS;
+
+ const rolloutUserIds = (fetchUserIdParams(userStrategy) || '')
+ .split(',')
+ .filter(id => id)
+ .join(', ');
+
+ return {
+ id: s.id,
+ environmentScope: s.environment_scope,
+ active: Boolean(s.active),
+ canUpdate: Boolean(s.can_update),
+ protected: Boolean(s.protected),
+ rolloutStrategy,
+ rolloutPercentage,
+ rolloutUserIds,
+
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null,
+ };
+ });
+/**
+ * Converts the parameters emitted by the Vue component into
+ * the shape that the Rails API expects.
+ * @param {Array} scopesFromVue An array of scope objects from the Vue component
+ */
+export const mapFromScopesViewModel = params => {
+ const scopes = (params.scopes || []).map(s => {
+ const parameters = {};
+ if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) {
+ parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
+ parameters.percentage = s.rolloutPercentage;
+ } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) {
+ parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ const userIdParameters = {};
+
+ if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) {
+ userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ // Strip out any internal IDs
+ const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id;
+
+ const strategies = [
+ {
+ name: s.rolloutStrategy,
+ parameters,
+ },
+ ];
+
+ if (!isEmpty(userIdParameters)) {
+ strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters });
+ }
+
+ return {
+ id,
+ environment_scope: s.environmentScope,
+ active: s.active,
+ can_update: s.canUpdate,
+ protected: s.protected,
+ _destroy: s.shouldBeDestroyed,
+ strategies,
+ };
+ });
+
+ const model = {
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ active: params.active,
+ scopes_attributes: scopes,
+ version: LEGACY_FLAG,
+ },
+ };
+
+ return model;
+};
+
+/**
+ * Creates a new feature flag environment scope object for use
+ * in a Vue component. An optional parameter can be passed to
+ * override the property values that are created by default.
+ *
+ * @param {Object} overrides An optional object whose
+ * property values will be used to override the default values.
+ *
+ */
+export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => {
+ const defaultScope = {
+ environmentScope: '',
+ active: false,
+ id: uniqueId(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const newScope = {
+ ...defaultScope,
+ ...overrides,
+ };
+
+ if (featureFlagPermissions) {
+ newScope.canUpdate = true;
+ newScope.protected = false;
+ }
+
+ return newScope;
+};
+
+const mapStrategyScopesToRails = scopes =>
+ scopes.length === 0
+ ? [{ environment_scope: '*' }]
+ : scopes.map(s => ({
+ id: s.id,
+ _destroy: s.shouldBeDestroyed,
+ environment_scope: s.environmentScope,
+ }));
+
+const mapStrategyScopesToView = scopes =>
+ scopes.map(s => ({
+ id: s.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ environmentScope: s.environment_scope,
+ }));
+
+const mapStrategiesParametersToViewModel = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.split(',').join(', ') };
+ }
+ return params;
+};
+
+export const mapStrategiesToViewModel = strategiesFromRails =>
+ (strategiesFromRails || []).map(s => ({
+ id: s.id,
+ name: s.name,
+ parameters: mapStrategiesParametersToViewModel(s.parameters),
+ userListId: s.user_list?.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ scopes: mapStrategyScopesToView(s.scopes),
+ }));
+
+const mapStrategiesParametersToRails = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.split(', ').join(',') };
+ }
+ return params;
+};
+
+const mapStrategyToRails = strategy => {
+ const mappedStrategy = {
+ id: strategy.id,
+ name: strategy.name,
+ _destroy: strategy.shouldBeDestroyed,
+ scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []),
+ parameters: mapStrategiesParametersToRails(strategy.parameters),
+ };
+
+ if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
+ mappedStrategy.user_list_id = strategy.userListId;
+ }
+ return mappedStrategy;
+};
+
+export const mapStrategiesToRails = params => ({
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ version: params.version,
+ active: params.active,
+ strategies_attributes: (params.strategies || []).map(mapStrategyToRails),
+ },
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/actions.js b/app/assets/javascripts/feature_flags/store/modules/index/actions.js
new file mode 100644
index 00000000000..ed41dd34e4d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/actions.js
@@ -0,0 +1,107 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+export const setFeatureFlagsEndpoint = ({ commit }, endpoint) =>
+ commit(types.SET_FEATURE_FLAGS_ENDPOINT, endpoint);
+
+export const setFeatureFlagsOptions = ({ commit }, options) =>
+ commit(types.SET_FEATURE_FLAGS_OPTIONS, options);
+
+export const setInstanceIdEndpoint = ({ commit }, endpoint) =>
+ commit(types.SET_INSTANCE_ID_ENDPOINT, endpoint);
+
+export const setProjectId = ({ commit }, endpoint) => commit(types.SET_PROJECT_ID, endpoint);
+
+export const setInstanceId = ({ commit }, instanceId) => commit(types.SET_INSTANCE_ID, instanceId);
+
+export const fetchFeatureFlags = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlags');
+
+ axios
+ .get(state.endpoint, {
+ params: state.options,
+ })
+ .then(response =>
+ dispatch('receiveFeatureFlagsSuccess', {
+ data: response.data || {},
+ headers: response.headers,
+ }),
+ )
+ .catch(() => dispatch('receiveFeatureFlagsError'));
+};
+
+export const requestFeatureFlags = ({ commit }) => commit(types.REQUEST_FEATURE_FLAGS);
+export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
+export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
+
+export const fetchUserLists = ({ state, dispatch }) => {
+ dispatch('requestUserLists');
+
+ return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
+ .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
+ .catch(() => dispatch('receiveUserListsError'));
+};
+
+export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
+export const receiveUserListsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
+export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
+
+export const toggleFeatureFlag = ({ dispatch }, flag) => {
+ dispatch('updateFeatureFlag', flag);
+
+ axios
+ .put(flag.update_path, {
+ operations_feature_flag: flag,
+ })
+ .then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data))
+ .catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id));
+};
+
+export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag);
+
+export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data);
+export const receiveUpdateFeatureFlagError = ({ commit }, id) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id);
+
+export const deleteUserList = ({ state, dispatch }, list) => {
+ dispatch('requestDeleteUserList', list);
+
+ return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
+ .then(() => dispatch('fetchUserLists'))
+ .catch(error =>
+ dispatch('receiveDeleteUserListError', {
+ list,
+ error: error?.response?.data ?? error,
+ }),
+ );
+};
+
+export const requestDeleteUserList = ({ commit }, list) =>
+ commit(types.REQUEST_DELETE_USER_LIST, list);
+
+export const receiveDeleteUserListError = ({ commit }, { error, list }) => {
+ commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
+};
+
+export const rotateInstanceId = ({ state, dispatch }) => {
+ dispatch('requestRotateInstanceId');
+
+ axios
+ .post(state.rotateEndpoint)
+ .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers }))
+ .catch(() => dispatch('receiveRotateInstanceIdError'));
+};
+
+export const requestRotateInstanceId = ({ commit }) => commit(types.REQUEST_ROTATE_INSTANCE_ID);
+export const receiveRotateInstanceIdSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, response);
+export const receiveRotateInstanceIdError = ({ commit }) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_ERROR);
+
+export const clearAlert = ({ commit }, index) => {
+ commit(types.RECEIVE_CLEAR_ALERT, index);
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/index.js b/app/assets/javascripts/feature_flags/store/modules/index/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js
new file mode 100644
index 00000000000..4a4bd13c945
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js
@@ -0,0 +1,26 @@
+export const SET_FEATURE_FLAGS_ENDPOINT = 'SET_FEATURE_FLAGS_ENDPOINT';
+export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
+export const SET_INSTANCE_ID_ENDPOINT = 'SET_INSTANCE_ID_ENDPOINT';
+export const SET_INSTANCE_ID = 'SET_INSTANCE_ID';
+export const SET_PROJECT_ID = 'SET_PROJECT_ID';
+
+export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
+export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
+export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
+
+export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
+export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
+export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
+
+export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
+export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
+
+export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID';
+export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS';
+export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR';
+
+export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT';
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutations.js b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js
new file mode 100644
index 00000000000..948786a3533
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
+import { mapToScopesViewModel } from '../helpers';
+
+const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
+
+const updateFlag = (state, flag) => {
+ const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id);
+ Vue.set(state[FEATURE_FLAG_SCOPE], index, flag);
+};
+
+const createPaginationInfo = (state, headers) => {
+ let paginationInfo;
+ if (Object.keys(headers).length) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = headers;
+ }
+ return paginationInfo;
+};
+
+export default {
+ [types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_FEATURE_FLAGS_OPTIONS](state, options = {}) {
+ state.options = options;
+ },
+ [types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) {
+ state.rotateEndpoint = endpoint;
+ },
+ [types.SET_INSTANCE_ID](state, instance) {
+ state.instanceId = instance;
+ },
+ [types.SET_PROJECT_ID](state, project) {
+ state.projectId = project;
+ },
+ [types.REQUEST_FEATURE_FLAGS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag);
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [FEATURE_FLAG_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_FEATURE_FLAGS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_USER_LISTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_USER_LISTS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[USER_LIST_SCOPE] = response.data || [];
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [USER_LIST_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_USER_LISTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_ROTATE_INSTANCE_ID](state) {
+ state.isRotating = true;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](
+ state,
+ {
+ data: { token },
+ },
+ ) {
+ state.isRotating = false;
+ state.instanceId = token;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](state) {
+ state.isRotating = false;
+ state.hasRotateError = true;
+ },
+ [types.UPDATE_FEATURE_FLAG](state, flag) {
+ updateFlag(state, flag);
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) {
+ updateFlag(state, mapFlag(data));
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
+ const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id);
+ updateFlag(state, { ...flag, active: !flag.active });
+ },
+ [types.REQUEST_DELETE_USER_LIST](state, list) {
+ state.userLists = state.userLists.filter(l => l !== list);
+ },
+ [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
+ state.isLoading = false;
+ state.hasError = false;
+ state.alerts = [].concat(error.message);
+ state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
+ },
+ [types.RECEIVE_CLEAR_ALERT](state, index) {
+ state.alerts.splice(index, 1);
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/state.js b/app/assets/javascripts/feature_flags/store/modules/index/state.js
new file mode 100644
index 00000000000..443a12d485d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/state.js
@@ -0,0 +1,18 @@
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
+
+export default () => ({
+ [FEATURE_FLAG_SCOPE]: [],
+ [USER_LIST_SCOPE]: [],
+ alerts: [],
+ count: {},
+ pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} },
+ isLoading: true,
+ hasError: false,
+ endpoint: null,
+ rotateEndpoint: null,
+ instanceId: '',
+ isRotating: false,
+ hasRotateError: false,
+ options: {},
+ projectId: '',
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/actions.js b/app/assets/javascripts/feature_flags/store/modules/new/actions.js
new file mode 100644
index 00000000000..d2159d55d53
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/actions.js
@@ -0,0 +1,51 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { NEW_VERSION_FLAG } from '../../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Commits mutation to set the main endpoint
+ * @param {String} endpoint
+ */
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * Commits mutation to set the feature flag path.
+ * Used to redirect the user after form submission
+ *
+ * @param {String} path
+ */
+export const setPath = ({ commit }, path) => commit(types.SET_PATH, path);
+
+/**
+ * Handles the creation of a new feature flag.
+ *
+ * Will dispatch `requestCreateFeatureFlag`
+ * Serializes the params and makes a post request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const createFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestCreateFeatureFlag');
+
+ return axios
+ .post(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveCreateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveCreateFeatureFlagError', error.response.data));
+};
+
+export const requestCreateFeatureFlag = ({ commit }) => commit(types.REQUEST_CREATE_FEATURE_FLAG);
+export const receiveCreateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS);
+export const receiveCreateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, error);
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/index.js b/app/assets/javascripts/feature_flags/store/modules/new/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js
new file mode 100644
index 00000000000..317f3689dfd
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATH = 'SET_PATH';
+
+export const REQUEST_CREATE_FEATURE_FLAG = 'REQUEST_CREATE_FEATURE_FLAG';
+export const RECEIVE_CREATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_CREATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_CREATE_FEATURE_FLAG_ERROR = 'RECEIVE_CREATE_FEATURE_FLAG_ERROR';
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutations.js b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js
new file mode 100644
index 00000000000..06e467c04f1
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js
@@ -0,0 +1,21 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_PATH](state, path) {
+ state.path = path;
+ },
+ [types.REQUEST_CREATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/state.js b/app/assets/javascripts/feature_flags/store/modules/new/state.js
new file mode 100644
index 00000000000..6f9263dbb2a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ endpoint: null,
+ path: null,
+ isSendingRequest: false,
+ error: [],
+});
diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js
new file mode 100644
index 00000000000..1017a3d0c2a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/utils.js
@@ -0,0 +1,48 @@
+import { s__, n__, sprintf } from '~/locale';
+import {
+ ALL_ENVIRONMENTS_NAME,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from './constants';
+
+const badgeTextByType = {
+ [ROLLOUT_STRATEGY_ALL_USERS]: {
+ name: s__('FeatureFlags|All Users'),
+ parameters: null,
+ },
+ [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: {
+ name: s__('FeatureFlags|Percent of users'),
+ parameters: ({ parameters: { percentage } }) => `${percentage}%`,
+ },
+ [ROLLOUT_STRATEGY_USER_ID]: {
+ name: s__('FeatureFlags|User IDs'),
+ parameters: ({ parameters: { userIds } }) =>
+ sprintf(n__('FeatureFlags|%d user', 'FeatureFlags|%d users', userIds.split(',').length)),
+ },
+ [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: {
+ name: s__('FeatureFlags|User List'),
+ parameters: ({ user_list: { name } }) => name,
+ },
+};
+
+const scopeName = ({ environment_scope: scope }) =>
+ scope === ALL_ENVIRONMENTS_NAME ? s__('FeatureFlags|All Environments') : scope;
+
+export default strategy => {
+ const { name, parameters } = badgeTextByType[strategy.name];
+
+ if (parameters) {
+ return sprintf('%{name} - %{parameters}: %{scopes}', {
+ name,
+ parameters: parameters(strategy),
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+ }
+
+ return sprintf('%{name}: %{scopes}', {
+ name,
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+};