diff options
Diffstat (limited to 'app/assets/javascripts/feature_flags')
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(', '), + }); +}; |