diff options
Diffstat (limited to 'spec/frontend/feature_flags/components')
18 files changed, 2816 insertions, 0 deletions
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js new file mode 100644 index 00000000000..0e364c47f8d --- /dev/null +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -0,0 +1,159 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import Component from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import Callout from '~/vue_shared/components/callout.vue'; + +describe('Configure Feature Flags Modal', () => { + const mockEvent = { preventDefault: jest.fn() }; + const provide = { + projectName: 'fakeProjectName', + featureFlagsHelpPagePath: '/help/path', + featureFlagsClientLibrariesHelpPagePath: '/help/path/#flags', + featureFlagsClientExampleHelpPagePath: '/feature-flags#clientexample', + unleashApiUrl: '/api/url', + }; + + const propsData = { + instanceId: 'instance-id-token', + isRotating: false, + hasRotateError: false, + canUserRotateToken: true, + }; + + let wrapper; + const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(Component, { + provide, + stubs: { GlSprintf }, + propsData: { + ...propsData, + ...props, + }, + ...options, + }); + }; + + const findGlModal = () => wrapper.find(GlModal); + const findPrimaryAction = () => findGlModal().props('actionPrimary'); + const findProjectNameInput = () => wrapper.find('#project_name_verification'); + const findDangerCallout = () => + wrapper.findAll(Callout).filter(c => c.props('category') === 'danger'); + + describe('idle', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should have Primary and Cancel actions', () => { + expect(findGlModal().props('actionCancel').text).toBe('Close'); + expect(findPrimaryAction().text).toBe('Regenerate instance ID'); + }); + + it('should default disable the primary action', async () => { + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(true); + }); + + it('should emit a `token` event when clicking on the Primary action', async () => { + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('token')).toEqual([[]]); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should clear the project name input after generating the token', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should provide an input for filling the project name', () => { + expect(findProjectNameInput().exists()).toBe(true); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should display an help text', () => { + const help = wrapper.find('p'); + expect(help.text()).toMatch(/More Information/); + }); + + it('should have links to the documentation', () => { + expect(wrapper.find('[data-testid="help-link"]').attributes('href')).toBe( + provide.featureFlagsHelpPagePath, + ); + expect(wrapper.find('[data-testid="help-client-link"]').attributes('href')).toBe( + provide.featureFlagsClientLibrariesHelpPagePath, + ); + }); + + it('should display one and only one danger callout', () => { + const dangerCallout = findDangerCallout(); + expect(dangerCallout.length).toBe(1); + expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/); + }); + + it('should display a message asking to fill the project name', () => { + expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch( + provide.projectName, + ); + }); + + it('should display the api URL in an input box', () => { + const input = wrapper.find('#api_url'); + expect(input.element.value).toBe('/api/url'); + }); + + it('should display the instance ID in an input box', () => { + const input = wrapper.find('#instance_id'); + expect(input.element.value).toBe('instance-id-token'); + }); + }); + + describe('verified', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should enable the primary action', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + await wrapper.vm.$nextTick(); + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(false); + }); + }); + + describe('cannot rotate token', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { canUserRotateToken: false })); + + it('should not display the primary action', async () => { + expect(findPrimaryAction()).toBe(null); + }); + + it('shold not display regenerating instance ID', async () => { + expect(findDangerCallout().exists()).toBe(false); + }); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().exists()).toBe(false); + }); + }); + + describe('has rotate error', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { hasRotateError: false })); + + it('should display an error', async () => { + expect(wrapper.find('.text-danger')).toExist(); + expect(wrapper.find('[name="warning"]')).toExist(); + }); + }); + + describe('is rotating', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { isRotating: true })); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().attributes('disabled')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js new file mode 100644 index 00000000000..6a394251060 --- /dev/null +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -0,0 +1,183 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlToggle, GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants'; +import Form from '~/feature_flags/components/form.vue'; +import createStore from '~/feature_flags/store/edit'; +import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; +import axios from '~/lib/utils/axios_utils'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const userCalloutId = 'feature_flags_new_version'; +const userCalloutsPath = `${TEST_HOST}/user_callouts`; + +describe('Edit feature flag form', () => { + let wrapper; + let mock; + + const store = createStore({ + path: '/feature_flags', + endpoint: `${TEST_HOST}/feature_flags.json`, + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(EditFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + glFeatures: { + featureFlagsNewVersion: true, + }, + ...opts, + }, + }); + }; + + beforeEach(done => { + mock = new MockAdapter(axios); + mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { + id: 21, + iid: 5, + active: true, + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + name: 'feature_flag', + description: '', + version: LEGACY_FLAG, + edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', + destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', + scopes: [ + { + id: 21, + active: false, + environment_scope: '*', + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + }, + ], + }); + factory(); + setImmediate(() => done()); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + it('should display the iid', () => { + expect(wrapper.find('h3').text()).toContain('^5'); + }); + + it('should render the toggle', () => { + expect(wrapper.find(GlToggle).exists()).toBe(true); + }); + + it('should set the value of the toggle to whether or not the flag is active', () => { + expect(wrapper.find(GlToggle).props('value')).toBe(true); + }); + + it('should not alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags'); + }); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('receiveUpdateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert-danger').exists()).toEqual(true); + expect(wrapper.find('.alert-danger').text()).toContain('The name is required'); + }); + }); + }); + + describe('without error', () => { + it('renders form title', () => { + expect(wrapper.text()).toContain('^5 feature_flag'); + }); + + it('should render feature flag form', () => { + expect(wrapper.find(Form).exists()).toEqual(true); + }); + + it('should set the version of the form from the feature flag', () => { + expect(wrapper.find(Form).props('version')).toBe(LEGACY_FLAG); + + mock.resetHandlers(); + + mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { + id: 21, + iid: 5, + active: true, + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + name: 'feature_flag', + description: '', + version: NEW_VERSION_FLAG, + edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', + destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', + strategies: [], + }); + + factory(); + + return axios.waitForAll().then(() => { + expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG); + }); + }); + + it('should track when the toggle is clicked', () => { + const toggle = wrapper.find(GlToggle); + const spy = mockTracking('_category_', toggle.element, jest.spyOn); + + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + beforeEach(() => { + factory({ glFeatures: { featureFlagsNewVersion: false } }); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js new file mode 100644 index 00000000000..917f5f5ccd3 --- /dev/null +++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js @@ -0,0 +1,147 @@ +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +describe('Feature flags > Environments dropdown ', () => { + let wrapper; + let mock; + const results = ['production', 'staging']; + const factory = props => { + wrapper = shallowMount(EnvironmentsDropdown, { + propsData: { + ...props, + }, + provide: { + environmentsEndpoint: `${TEST_HOST}/environments.json'`, + }, + }); + }; + + const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType); + const findDropdownMenu = () => wrapper.find('.dropdown-menu'); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + describe('without value', () => { + it('renders the placeholder', () => { + factory(); + expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec'); + }); + }); + + describe('with value', () => { + it('sets filter to equal the value', () => { + factory({ value: 'production' }); + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + }); + + describe('on focus', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on keyup', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('keyup'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on input change', () => { + describe('on success', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('sets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + + describe('with received data', () => { + it('sets is loading to false', () => { + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('shows the suggestions', () => { + expect(findDropdownMenu().exists()).toBe(true); + }); + + it('emits event when a suggestion is clicked', async () => { + const button = wrapper + .findAll(GlDeprecatedButton) + .filter(b => b.text() === 'production') + .at(0); + button.vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]); + }); + }); + + describe('on click clear button', () => { + beforeEach(async () => { + wrapper.find(GlDeprecatedButton).vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('resets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe(''); + }); + + it('closes list of suggestions', () => { + expect(wrapper.vm.showSuggestions).toBe(false); + }); + }); + }); + }); + + describe('on click create button', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('emits create event', async () => { + wrapper + .findAll(GlDeprecatedButton) + .at(0) + .vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('createClicked')).toEqual([['production']]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js new file mode 100644 index 00000000000..3c1234fea94 --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -0,0 +1,371 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import createStore from '~/feature_flags/store/index'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; +import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import axios from '~/lib/utils/axios_utils'; +import { getRequestData, userList } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Feature flags', () => { + const mockData = { + canUserConfigure: true, + csrfToken: 'testToken', + featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', + featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', + featureFlagsHelpPagePath: '/help/feature-flags', + featureFlagsLimit: '200', + featureFlagsLimitExceeded: false, + newFeatureFlagPath: 'feature-flags/new', + newUserListPath: '/user-list/new', + unleashApiUrl: `${TEST_HOST}/api/unleash`, + projectName: 'fakeProjectName', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + }; + + const mockState = { + endpoint: `${TEST_HOST}/endpoint.json`, + projectId: '8', + unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', + }; + + let wrapper; + let mock; + let store; + + const factory = (provide = mockData, fn = shallowMount) => { + store = createStore(mockState); + wrapper = fn(FeatureFlagsComponent, { + localVue, + store, + provide, + stubs: { + FeatureFlagsTab, + }, + }); + }; + + const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]'); + const newButton = () => wrapper.find('[data-testid="ff-new-button"]'); + const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]'); + const limitAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({ + data: [userList], + headers: { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '8', + 'X-Prev-Page': '', + 'X-TOTAL': '40', + 'X-Total-Pages': '5', + }, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + describe('when limit exceeded', () => { + const provideData = { ...mockData, featureFlagsLimitExceeded: true }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + setImmediate(done); + }); + + it('makes the new feature flag button do nothing if clicked', () => { + expect(newButton().exists()).toBe(true); + expect(newButton().props('disabled')).toBe(false); + expect(newButton().props('href')).toBe(undefined); + }); + + it('shows a feature flags limit reached alert', () => { + expect(limitAlert().exists()).toBe(true); + expect( + limitAlert() + .find(GlSprintf) + .attributes('message'), + ).toContain('Feature flags limit reached'); + }); + + describe('when the alert is dismissed', () => { + beforeEach(async () => { + await limitAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', async () => { + expect(limitAlert().exists()).toBe(false); + }); + + it('re-shows the alert if the new feature flag button is clicked', async () => { + await newButton().vm.$emit('click'); + + expect(limitAlert().exists()).toBe(true); + }); + }); + }); + + describe('without permissions', () => { + const provideData = { + ...mockData, + canUserConfigure: false, + canUserRotateToken: false, + newFeatureFlagPath: null, + newUserListPath: null, + }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + setImmediate(done); + }); + + it('does not render configure button', () => { + expect(configureButton().exists()).toBe(false); + }); + + it('does not render new feature flag button', () => { + expect(newButton().exists()).toBe(false); + }); + + it('does not render new user list button', () => { + expect(newUserListButton().exists()).toBe(false); + }); + }); + + describe('loading state', () => { + it('renders a loading icon', () => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, {}); + + factory(); + + const loadingElement = wrapper.find(GlLoadingIcon); + + expect(loadingElement.exists()).toBe(true); + expect(loadingElement.props('label')).toEqual('Loading feature flags'); + }); + }); + + describe('successful request', () => { + describe('without feature flags', () => { + let emptyState; + + beforeEach(async () => { + mock.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply( + 200, + { + feature_flags: [], + count: { + all: 0, + enabled: 0, + disabled: 0, + }, + }, + {}, + ); + + factory(); + await wrapper.vm.$nextTick(); + + emptyState = wrapper.find(GlEmptyState); + }); + + it('should render the empty state', async () => { + expect(emptyState.exists()).toBe(true); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('in feature flags tab', () => { + it('renders generic title', () => { + expect(emptyState.props('title')).toEqual('Get started with feature flags'); + }); + }); + }); + + describe('with paginated feature flags', () => { + beforeEach(done => { + mock + .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }); + + factory(); + jest.spyOn(store, 'dispatch'); + setImmediate(done); + }); + + it('should render a table with feature flags', () => { + const table = wrapper.find(FeatureFlagsTable); + expect(table.exists()).toBe(true); + expect(table.props(FEATURE_FLAG_SCOPE)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: getRequestData.feature_flags[0].name, + description: getRequestData.feature_flags[0].description, + }), + ]), + ); + }); + + it('should toggle a flag when receiving the toggle-flag event', () => { + const table = wrapper.find(FeatureFlagsTable); + + const [flag] = table.props(FEATURE_FLAG_SCOPE); + table.vm.$emit('toggle-flag', flag); + + expect(store.dispatch).toHaveBeenCalledWith('toggleFeatureFlag', flag); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('pagination', () => { + it('should render pagination', () => { + expect(wrapper.find(TablePagination).exists()).toBe(true); + }); + + it('should make an API request when page is clicked', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find(TablePagination).vm.change(4); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: FEATURE_FLAG_SCOPE, + page: '4', + }); + }); + + it('should make an API request when using tabs', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: USER_LIST_SCOPE, + page: '1', + }); + }); + }); + }); + + describe('in user lists tab', () => { + beforeEach(done => { + factory(); + setImmediate(done); + }); + beforeEach(() => { + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + return wrapper.vm.$nextTick(); + }); + + it('should display the user list table', () => { + expect(wrapper.find(UserListsTable).exists()).toBe(true); + }); + + it('should set the user lists to display', () => { + expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]); + }); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(done => { + mock + .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(500, {}); + Api.fetchFeatureFlagUserLists.mockRejectedValueOnce(); + + factory(); + setImmediate(done); + }); + + it('should render error state', () => { + const emptyState = wrapper.find(GlEmptyState); + expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.'); + expect(emptyState.props('description')).toEqual( + 'Try again in a few moments or contact your support team.', + ); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + }); + + describe('rotate instance id', () => { + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(); + setImmediate(done); + }); + + it('should fire the rotate action when a `token` event is received', () => { + const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId'); + const modal = wrapper.find(ConfigureFeatureFlagsModal); + modal.vm.$emit('token'); + + expect(actionSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js new file mode 100644 index 00000000000..bc90c5ceb2d --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js @@ -0,0 +1,168 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; + +const DEFAULT_PROPS = { + title: 'test', + count: 5, + alerts: ['an alert', 'another alert'], + isLoading: false, + loadingLabel: 'test loading', + errorState: false, + errorTitle: 'test title', + emptyState: true, + emptyTitle: 'test empty', +}; + +const DEFAULT_PROVIDE = { + errorStateSvgPath: '/error.svg', + featureFlagsHelpPagePath: '/help/page/path', +}; + +describe('feature_flags/components/feature_flags_tab.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount( + { + components: { + GlTabs, + FeatureFlagsTab, + }, + render(h) { + return h(GlTabs, [ + h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default), + ]); + }, + }, + { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: DEFAULT_PROVIDE, + slots: { + default: '<p data-testid="test-slot">testing</p>', + }, + }, + ); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('alerts', () => { + let alerts; + + beforeEach(() => { + wrapper = factory(); + alerts = wrapper.findAll(GlAlert); + }); + + it('should show any alerts', () => { + expect(alerts).toHaveLength(DEFAULT_PROPS.alerts.length); + alerts.wrappers.forEach((alert, i) => expect(alert.text()).toBe(DEFAULT_PROPS.alerts[i])); + }); + + it('should emit a dismiss event for a dismissed alert', () => { + alerts.at(0).vm.$emit('dismiss'); + + expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]); + }); + }); + + describe('loading', () => { + beforeEach(() => { + wrapper = factory({ isLoading: true }); + }); + + it('should show a loading icon and nothing else', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findAll(GlEmptyState)).toHaveLength(0); + }); + }); + + describe('error', () => { + let emptyState; + + beforeEach(() => { + wrapper = factory({ errorState: true }); + emptyState = wrapper.find(GlEmptyState); + }); + + it('should show an error state if there has been an error', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.errorTitle); + expect(emptyState.text()).toContain( + 'Try again in a few moments or contact your support team.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + }); + }); + + describe('empty', () => { + let emptyState; + let emptyStateLink; + + beforeEach(() => { + wrapper = factory({ emptyState: true }); + emptyState = wrapper.find(GlEmptyState); + emptyStateLink = emptyState.find(GlLink); + }); + + it('should show an empty state if it is empty', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle); + expect(emptyState.text()).toContain( + 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath); + expect(emptyStateLink.text()).toBe('More information'); + }); + }); + + describe('slot', () => { + let slot; + + beforeEach(async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + slot = wrapper.find('[data-testid="test-slot"]'); + }); + + it('should display the passed slot', () => { + expect(slot.exists()).toBe(true); + expect(slot.text()).toBe('testing'); + }); + }); + + describe('count', () => { + it('should display a count if there is one', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString()); + }); + it('should display 0 if there is no count', async () => { + wrapper = factory({ count: undefined }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe('0'); + }); + }); + + describe('title', () => { + it('should show the title', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe( + DEFAULT_PROPS.title, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js new file mode 100644 index 00000000000..a488662470e --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -0,0 +1,266 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlToggle, GlBadge } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + NEW_VERSION_FLAG, + LEGACY_FLAG, + DEFAULT_PERCENT_ROLLOUT, +} from '~/feature_flags/constants'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; + +const getDefaultProps = () => ({ + featureFlags: [ + { + id: 1, + iid: 1, + active: true, + name: 'flag name', + description: 'flag description', + destroy_path: 'destroy/path', + edit_path: 'edit/path', + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + active: true, + environmentScope: 'scope', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + shouldBeDestroyed: false, + }, + ], + }, + ], +}); + +describe('Feature flag table', () => { + let wrapper; + let props; + + const createWrapper = (propsData, opts = {}) => { + wrapper = shallowMount(FeatureFlagsTable, { + propsData, + provide: { + csrfToken: 'fakeToken', + }, + ...opts, + }); + }; + + beforeEach(() => { + props = getDefaultProps(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with an active scope and a standard rollout strategy', () => { + beforeEach(() => { + createWrapper(props); + }); + + it('Should render a table', () => { + expect(wrapper.classes('table-holder')).toBe(true); + }); + + it('Should render rows', () => { + expect(wrapper.find('.gl-responsive-table-row').exists()).toBe(true); + }); + + it('should render an ID column', () => { + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toEqual('^1'); + }); + + it('Should render a status column', () => { + const badge = wrapper.find('[data-testid="feature-flag-status-badge"]'); + + expect(badge.exists()).toBe(true); + expect(trimText(badge.text())).toEqual('Active'); + }); + + it('Should render a feature flag column', () => { + expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true); + expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name'); + + expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual( + 'flag description', + ); + }); + + it('should render an environments specs column', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(envColumn).toBeDefined(); + expect(trimText(envColumn.text())).toBe('scope'); + }); + + it('should render an environments specs badge with active class', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); + }); + + it('should render an actions column', () => { + expect(wrapper.find('.table-action-buttons').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-delete-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').attributes('href')).toEqual('edit/path'); + }); + }); + + describe('when active and with an update toggle', () => { + let toggle; + let spy; + + beforeEach(() => { + props.featureFlags[0].update_path = props.featureFlags[0].destroy_path; + createWrapper(props); + toggle = wrapper.find(GlToggle); + spy = mockTracking('_category_', toggle.element, jest.spyOn); + }); + + it('should have a toggle', () => { + expect(toggle.exists()).toBe(true); + expect(toggle.props('value')).toBe(true); + }); + + it('should trigger a toggle event', () => { + toggle.vm.$emit('change'); + const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active }; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]); + }); + }); + + it('should track a click', () => { + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + describe('with an active scope and a percentage rollout strategy', () => { + beforeEach(() => { + props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + props.featureFlags[0].scopes[0].rolloutPercentage = '54'; + createWrapper(props); + }); + + it('should render an environments specs badge with percentage', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%'); + }); + }); + + describe('with an inactive scope', () => { + beforeEach(() => { + props.featureFlags[0].scopes[0].active = false; + createWrapper(props); + }); + + it('should render an environments specs badge with inactive class', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); + }); + }); + + describe('with a new version flag', () => { + let badges; + + beforeEach(() => { + const newVersionProps = { + ...props, + featureFlags: [ + { + id: 1, + iid: 1, + active: true, + name: 'flag name', + description: 'flag description', + destroy_path: 'destroy/path', + edit_path: 'edit/path', + version: NEW_VERSION_FLAG, + scopes: [], + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: '*' }], + }, + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }], + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3,4' }, + scopes: [{ environment_scope: 'review/*' }], + }, + { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + parameters: {}, + user_list: { name: 'test list' }, + scopes: [{ environment_scope: '*' }], + }, + ], + }, + ], + }; + createWrapper(newVersionProps, { + provide: { csrfToken: 'fakeToken', glFeatures: { featureFlagsNewVersion: true } }, + }); + + badges = wrapper.findAll('[data-testid="strategy-badge"]'); + }); + + it('shows All Environments if the environment scope is *', () => { + expect(badges.at(0).text()).toContain('All Environments'); + }); + + it('shows the environment scope if another is set', () => { + expect(badges.at(1).text()).toContain('production'); + expect(badges.at(1).text()).toContain('staging'); + expect(badges.at(2).text()).toContain('review/*'); + }); + + it('shows All Users for the default strategy', () => { + expect(badges.at(0).text()).toContain('All Users'); + }); + + it('shows the percent for a percent rollout', () => { + expect(badges.at(1).text()).toContain('Percent of users - 50%'); + }); + + it('shows the number of users for users with ID', () => { + expect(badges.at(2).text()).toContain('User IDs - 4 users'); + }); + + it('shows the name of a user list for user list', () => { + expect(badges.at(3).text()).toContain('User List - test list'); + }); + }); + + it('renders a feature flag without an iid', () => { + delete props.featureFlags[0].iid; + createWrapper(props); + + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toBe(''); + }); +}); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js new file mode 100644 index 00000000000..33c7eeb54b7 --- /dev/null +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -0,0 +1,493 @@ +import { uniqueId } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui'; +import Api from '~/api'; +import Form from '~/feature_flags/components/form.vue'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; +import Strategy from '~/feature_flags/components/strategy.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + INTERNAL_ID_PREFIX, + DEFAULT_PERCENT_ROLLOUT, + LEGACY_FLAG, + NEW_VERSION_FLAG, +} from '~/feature_flags/constants'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import { featureFlag, userList, allUsersStrategy } from '../mock_data'; + +jest.mock('~/api.js'); + +describe('feature flag form', () => { + let wrapper; + const requiredProps = { + cancelPath: 'feature_flags', + submitText: 'Create', + }; + + const requiredInjections = { + environmentsEndpoint: '/environments.json', + projectId: '1', + glFeatures: { + featureFlagPermissions: true, + featureFlagsNewVersion: true, + }, + }; + + const factory = (props = {}, provide = {}) => { + wrapper = shallowMount(Form, { + propsData: { ...requiredProps, ...props }, + provide: { + ...requiredInjections, + ...provide, + }, + }); + }; + + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render provided submitText', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-submit').text()).toEqual(requiredProps.submitText); + }); + + it('should render provided cancelPath', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath); + }); + + it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => { + factory(requiredProps); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false); + }); + + it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => { + factory( + {}, + { + ...requiredInjections, + featureFlagIssuesEndpoint: '/some/endpoint', + }, + ); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true); + }); + + describe('without provided data', () => { + beforeEach(() => { + factory(requiredProps); + }); + + it('should render name input text', () => { + expect(wrapper.find('#feature-flag-name').exists()).toBe(true); + }); + + it('should render description textarea', () => { + expect(wrapper.find('#feature-flag-description').exists()).toBe(true); + }); + + describe('scopes', () => { + it('should render scopes table', () => { + expect(wrapper.find('.js-scopes-table').exists()).toBe(true); + }); + + it('should render scopes table with a new row ', () => { + expect(wrapper.find('.js-add-new-scope').exists()).toBe(true); + }); + + describe('status toggle', () => { + describe('without filled text input', () => { + it('should add a new scope with the text value empty and the status', () => { + wrapper.find(ToggleButton).vm.$emit('change', true); + + expect(wrapper.vm.formScopes).toHaveLength(1); + expect(wrapper.vm.formScopes[0].active).toEqual(true); + expect(wrapper.vm.formScopes[0].environmentScope).toEqual(''); + + expect(wrapper.vm.newScope).toEqual(''); + }); + }); + + it('should be disabled if the feature flag is not active', done => { + wrapper.setProps({ active: false }); + wrapper.vm.$nextTick(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true); + done(); + }); + }); + }); + }); + }); + + describe('with provided data', () => { + beforeEach(() => { + factory({ + ...requiredProps, + name: featureFlag.name, + description: featureFlag.description, + active: true, + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + active: true, + environmentScope: 'scope', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '54', + rolloutUserIds: '123', + shouldIncludeUserIds: true, + }, + { + id: 2, + active: true, + environmentScope: 'scope', + canUpdate: false, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '54', + rolloutUserIds: '123', + shouldIncludeUserIds: true, + }, + ], + }); + }); + + describe('scopes', () => { + it('should be possible to remove a scope', () => { + expect(wrapper.find('.js-feature-flag-delete').exists()).toEqual(true); + }); + + it('renders empty row to add a new scope', () => { + expect(wrapper.find('.js-add-new-scope').exists()).toEqual(true); + }); + + it('renders the user id checkbox', () => { + expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); + }); + + it('renders the user id text area', () => { + expect(wrapper.find(GlFormTextarea).exists()).toBe(true); + + expect(wrapper.find(GlFormTextarea).vm.value).toBe('123'); + }); + + describe('update scope', () => { + describe('on click on toggle', () => { + it('should update the scope', () => { + wrapper.find(ToggleButton).vm.$emit('change', false); + + expect(wrapper.vm.formScopes[0].active).toBe(false); + }); + + it('should be disabled if the feature flag is not active', done => { + wrapper.setProps({ active: false }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true); + done(); + }); + }); + }); + describe('on strategy change', () => { + it('should not include user IDs if All Users is selected', () => { + const scope = wrapper.find({ ref: 'scopeRow' }); + scope.find('select').setValue(ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(scope.find('#rollout-user-id-0').exists()).toBe(false); + }); + }); + }); + }); + + describe('deleting an existing scope', () => { + beforeEach(() => { + wrapper.find('.js-delete-scope').vm.$emit('click'); + }); + + it('should add `shouldBeDestroyed` key the clicked scope', () => { + expect(wrapper.vm.formScopes[0].shouldBeDestroyed).toBe(true); + }); + + it('should not render deleted scopes', () => { + expect(wrapper.vm.filteredScopes).toEqual([expect.objectContaining({ id: 2 })]); + }); + }); + + describe('deleting a new scope', () => { + it('should remove the scope from formScopes', () => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + description: 'this is a feature flag', + scopes: [ + { + environmentScope: 'new_scope', + active: false, + id: uniqueId(INTERNAL_ID_PREFIX), + canUpdate: true, + protected: false, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + }, + ], + }, + ], + }); + + wrapper.find('.js-delete-scope').vm.$emit('click'); + + expect(wrapper.vm.formScopes).toEqual([]); + }); + }); + + describe('with * scope', () => { + beforeEach(() => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + description: 'this is a feature flag', + scopes: [ + { + environmentScope: '*', + active: false, + canUpdate: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + }, + ], + }); + }); + + it('renders read only name', () => { + expect(wrapper.find('.js-scope-all').exists()).toEqual(true); + }); + }); + + describe('without permission to update', () => { + it('should have the flag name input disabled', () => { + const input = wrapper.find('#feature-flag-name'); + + expect(input.element.disabled).toBe(true); + }); + + it('should have the flag discription text area disabled', () => { + const textarea = wrapper.find('#feature-flag-description'); + + expect(textarea.element.disabled).toBe(true); + }); + + it('should have the scope that cannot be updated be disabled', () => { + const row = wrapper.findAll('.gl-responsive-table-row').at(2); + + expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true); + expect(row.find(ToggleButton).vm.disabledInput).toBe(true); + expect(row.find('.js-delete-scope').exists()).toBe(false); + }); + }); + }); + + describe('on submit', () => { + const selectFirstRolloutStrategyOption = dropdownIndex => { + wrapper + .findAll('select.js-rollout-strategy') + .at(dropdownIndex) + .findAll('option') + .at(1) + .setSelected(); + }; + + beforeEach(() => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + active: true, + description: 'this is a feature flag', + scopes: [ + { + id: 1, + environmentScope: 'production', + canUpdate: true, + protected: true, + active: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }, + ], + }); + + return wrapper.vm.$nextTick(); + }); + + it('should emit handleSubmit with the updated data', () => { + wrapper.find('#feature-flag-name').setValue('feature_flag_2'); + + return wrapper.vm + .$nextTick() + .then(() => { + wrapper + .find('.js-new-scope-name') + .find(EnvironmentsDropdown) + .vm.$emit('selectEnvironment', 'review'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper + .find('.js-add-new-scope') + .find(ToggleButton) + .vm.$emit('change', true); + }) + .then(() => { + wrapper.find(ToggleButton).vm.$emit('change', true); + return wrapper.vm.$nextTick(); + }) + + .then(() => { + selectFirstRolloutStrategyOption(0); + return wrapper.vm.$nextTick(); + }) + .then(() => { + selectFirstRolloutStrategyOption(2); + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper.find('.js-rollout-percentage').setValue('55'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper.find({ ref: 'submitButton' }).vm.$emit('click'); + + const data = wrapper.emitted().handleSubmit[0][0]; + + expect(data.name).toEqual('feature_flag_2'); + expect(data.description).toEqual('this is a feature flag'); + expect(data.active).toBe(true); + + expect(data.scopes).toEqual([ + { + id: 1, + active: true, + environmentScope: 'production', + canUpdate: true, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '55', + rolloutUserIds: '', + shouldIncludeUserIds: false, + }, + { + id: expect.any(String), + active: false, + environmentScope: 'review', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }, + { + id: expect.any(String), + active: true, + environmentScope: '', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + shouldIncludeUserIds: false, + }, + ]); + }); + }); + }); + }); + + describe('with strategies', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); + factory({ + ...requiredProps, + name: featureFlag.name, + description: featureFlag.description, + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }, + { + type: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: 'review/*' }], + }, + ], + }); + }); + + it('should request the user lists on mount', () => { + return wrapper.vm.$nextTick(() => { + expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1'); + }); + }); + + it('should show the strategy component', () => { + const strategy = wrapper.find(Strategy); + expect(strategy.exists()).toBe(true); + expect(strategy.props('strategy')).toEqual({ + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }); + }); + + it('should show one strategy component per strategy', () => { + expect(wrapper.findAll(Strategy)).toHaveLength(2); + }); + + it('adds an all users strategy when clicking the Add button', () => { + wrapper.find(GlButton).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + const strategies = wrapper.findAll(Strategy); + + expect(strategies).toHaveLength(3); + expect(strategies.at(2).props('strategy')).toEqual(allUsersStrategy); + }); + }); + + it('should remove a strategy on delete', () => { + const strategy = { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }; + wrapper.find(Strategy).vm.$emit('delete'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(Strategy)).toHaveLength(1); + expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy); + }); + }); + + it('should provide the user lists to the strategy', () => { + expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js new file mode 100644 index 00000000000..12dc98fbde8 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -0,0 +1,105 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +const TEST_HOST = '/test'; +const TEST_SEARCH = 'production'; + +describe('New Environments Dropdown', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + wrapper = shallowMount(NewEnvironmentsDropdown, { + provide: { environmentsEndpoint: TEST_HOST }, + }); + }); + + afterEach(() => { + axiosMock.restore(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('before results', () => { + it('should show a loading icon', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + + it('should not show any dropdown items', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(0); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + }); + + describe('with empty results', () => { + let item; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(200, []); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH); + return axios + .waitForAll() + .then(() => wrapper.vm.$nextTick()) + .then(() => { + item = wrapper.find(GlDropdownItem); + }); + }); + + it('should display a Create item label', () => { + expect(item.text()).toBe('Create production'); + }); + + it('should display that no matching items are found', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true); + }); + + it('should emit a new scope when selected', () => { + item.vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]); + }); + }); + + describe('with results', () => { + let items; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod'); + return axios.waitForAll().then(() => { + items = wrapper.findAll(GlDropdownItem); + }); + }); + + it('should display one item per result', () => { + expect(items).toHaveLength(2); + }); + + it('should emit an add if an item is clicked', () => { + items.at(0).vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([['prod']]); + }); + + it('should not display a create label', () => { + items = items.filter(i => i.text().startsWith('Create')); + expect(items).toHaveLength(0); + }); + + it('should not display a message about no results', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js new file mode 100644 index 00000000000..dbc6e03d922 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -0,0 +1,136 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Form from '~/feature_flags/components/form.vue'; +import createStore from '~/feature_flags/store/new'; +import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + DEFAULT_PERCENT_ROLLOUT, + NEW_FLAG_ALERT, +} from '~/feature_flags/constants'; +import axios from '~/lib/utils/axios_utils'; +import { allUsersStrategy } from '../mock_data'; + +const userCalloutId = 'feature_flags_new_version'; +const userCalloutsPath = `${TEST_HOST}/user_callouts`; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('New feature flag form', () => { + let wrapper; + + const store = createStore({ + endpoint: `${TEST_HOST}/feature_flags.json`, + path: '/feature_flags', + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(NewFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + environmentsEndpoint: 'environments.json', + projectId: '8', + glFeatures: { + featureFlagsNewVersion: true, + }, + ...opts, + }, + }); + }; + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert').exists()).toEqual(true); + expect(wrapper.find('.alert').text()).toContain('The name is required'); + }); + }); + }); + + it('renders form title', () => { + expect(wrapper.find('h3').text()).toEqual('New feature flag'); + }); + + it('should render feature flag form', () => { + expect(wrapper.find(Form).exists()).toEqual(true); + }); + + it('should render default * row', () => { + const defaultScope = { + id: expect.any(String), + environmentScope: '*', + active: true, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + expect(wrapper.vm.scopes).toEqual([defaultScope]); + + expect(wrapper.find(Form).props('scopes')).toContainEqual(defaultScope); + }); + + it('should not alert users that feature flags are changing soon', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('has an all users strategy by default', () => { + const strategies = wrapper.find(Form).props('strategies'); + + expect(strategies).toEqual([allUsersStrategy]); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + factory({ glFeatures: { featureFlagsNewVersion: false } }); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/default_spec.js b/spec/frontend/feature_flags/components/strategies/default_spec.js new file mode 100644 index 00000000000..1315cd7d735 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/default_spec.js @@ -0,0 +1,10 @@ +import { shallowMount } from '@vue/test-utils'; +import Default from '~/feature_flags/components/strategies/default.vue'; + +describe('~/feature_flags/components/strategies/default.vue', () => { + it('should emit an empty parameter object on mount', () => { + const wrapper = shallowMount(Default); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: {} }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js new file mode 100644 index 00000000000..f3f70a325d0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import FlexibleRollout from '~/feature_flags/components/strategies/flexible_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { flexibleRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: flexibleRolloutStrategy, +}; + +describe('feature_flags/components/strategies/flexible_rollout.vue', () => { + let wrapper; + let percentageFormGroup; + let percentageInput; + let stickinessFormGroup; + let stickinessSelect; + + const factory = (props = {}) => + mount(FlexibleRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + percentageFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + percentageInput = percentageFormGroup.find(GlFormInput); + stickinessFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-stickiness"]') + .find(ParameterFormGroup); + stickinessSelect = stickinessFormGroup.find(GlFormSelect); + }); + + it('displays the current percentage value', () => { + expect(percentageInput.element.value).toBe(flexibleRolloutStrategy.parameters.rollout); + }); + + it('displays the current stickiness value', () => { + expect(stickinessSelect.element.value).toBe(flexibleRolloutStrategy.parameters.stickiness); + }); + + it('emits a change when the percentage value changes', async () => { + percentageInput.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: '75', + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: flexibleRolloutStrategy.parameters.stickiness, + }, + }, + ], + ]); + }); + + it('emits a change when the stickiness value changes', async () => { + stickinessSelect.setValue('USERID'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: flexibleRolloutStrategy.parameters.rollout, + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: 'USERID', + }, + }, + ], + ]); + }); + + it('does not show errors', () => { + expect(percentageFormGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '101' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js new file mode 100644 index 00000000000..014c6dd98b9 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import { GlFormSelect } from '@gitlab/ui'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import { userListStrategy, userList } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: userListStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } }); + + describe('with user lists', () => { + beforeEach(() => { + wrapper = factory(); + }); + + it('should show the input for userListId with the correct value', () => { + const inputWrapper = wrapper.find(GlFormSelect); + expect(inputWrapper.exists()).toBe(true); + expect(inputWrapper.element.value).toBe('2'); + }); + + it('should emit a change event when altering the userListId', () => { + const inputWrapper = wrapper.find(GitlabUserList); + inputWrapper.vm.$emit('change', { + userListId: '3', + }); + expect(wrapper.emitted('change')).toEqual([ + [ + { + userListId: '3', + }, + ], + ]); + }); + }); + describe('without user lists', () => { + beforeEach(() => { + wrapper = factory({ userLists: [] }); + }); + + it('should display a message that there are no user lists', () => { + expect(wrapper.text()).toContain('There are no configured user lists'); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js new file mode 100644 index 00000000000..a0ffdb1fca0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; + +describe('~/feature_flags/strategies/parameter_form_group.vue', () => { + let wrapper; + let formGroup; + let slot; + + beforeEach(() => { + wrapper = mount(ParameterFormGroup, { + propsData: { inputId: 'test-id', label: 'test' }, + attrs: { description: 'test description' }, + scopedSlots: { + default(props) { + return this.$createElement(GlFormInput, { + attrs: { id: props.inputId, 'data-testid': 'slot' }, + }); + }, + }, + }); + + formGroup = wrapper.find(GlFormGroup); + slot = wrapper.find('[data-testid="slot"]'); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the default slot', () => { + expect(slot.exists()).toBe(true); + }); + + it('should bind the input id to the slot', () => { + expect(slot.attributes('id')).toBe('test-id'); + }); + + it('should bind the label-for to the input id', () => { + expect(formGroup.find('[for="test-id"]').exists()).toBe(true); + }); + + it('should bind extra attributes to the form group', () => { + expect(formGroup.attributes('description')).toBe('test description'); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js new file mode 100644 index 00000000000..de0b439f1c5 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { percentRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: percentRolloutStrategy, +}; + +describe('~/feature_flags/components/strategies/percent_rollout.vue', () => { + let wrapper; + let input; + let formGroup; + + const factory = (props = {}) => + mount(PercentRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('displays the current value', () => { + expect(input.element.value).toBe(percentRolloutStrategy.parameters.percentage); + }); + + it('emits a change when the value changes', async () => { + input.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [{ parameters: { percentage: '75', groupId: PERCENT_ROLLOUT_GROUP_ID } }], + ]); + }); + + it('does not show errors', () => { + expect(formGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '101' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js new file mode 100644 index 00000000000..460df6ef2ec --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils'; +import { GlFormTextarea } from '@gitlab/ui'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import { usersWithIdStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: usersWithIdStrategy, +}; + +describe('~/feature_flags/components/users_with_id.vue', () => { + let wrapper; + let textarea; + + const factory = (props = {}) => mount(UsersWithId, { propsData: { ...DEFAULT_PROPS, ...props } }); + + beforeEach(() => { + wrapper = factory(); + textarea = wrapper.find(GlFormTextarea); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the current value of the parameters', () => { + expect(textarea.element.value).toBe(usersWithIdStrategy.parameters.userIds); + }); + + it('should emit a change event when the IDs change', () => { + textarea.setValue('4,5,6'); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: { userIds: '4,5,6' } }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js new file mode 100644 index 00000000000..314fb0f21f4 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Default from '~/feature_flags/components/strategies/default.vue'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; +import { allUsersStrategy, userList } from '../mock_data'; + +const DEFAULT_PROPS = { + strategy: allUsersStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategy_parameters.vue', () => { + let wrapper; + + const factory = (props = {}) => + shallowMount(StrategyParameters, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe.each` + name | component + ${ROLLOUT_STRATEGY_ALL_USERS} | ${Default} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${PercentRollout} + ${ROLLOUT_STRATEGY_USER_ID} | ${UsersWithId} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} | ${GitlabUserList} + `('with $name', ({ name, component }) => { + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {} }; + wrapper = factory({ strategy }); + }); + + it('should show the correct component', () => { + expect(wrapper.contains(component)).toBe(true); + }); + + it('should emit changes from the lower component', () => { + const strategyParameterWrapper = wrapper.find(component); + + strategyParameterWrapper.vm.$emit('change', { parameters: { foo: 'bar' } }); + + expect(last(wrapper.emitted('change'))).toEqual([ + { + name, + parameters: { foo: 'bar' }, + }, + ]); + }); + }); + + describe('pass through props', () => { + it('should pass through any extra props that might be needed', () => { + wrapper = factory({ + strategy: { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + }, + }); + + expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js new file mode 100644 index 00000000000..7d6700ba184 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -0,0 +1,264 @@ +import { mount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import { + PERCENT_ROLLOUT_GROUP_ID, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Strategy from '~/feature_flags/components/strategy.vue'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; + +import { userList } from '../mock_data'; + +const provide = { + strategyTypeDocsPagePath: 'link-to-strategy-docs', + environmentsScopeDocsPath: 'link-scope-docs', + environmentsEndpoint: '', +}; + +describe('Feature flags strategy', () => { + let wrapper; + + const findStrategyParameters = () => wrapper.find(StrategyParameters); + const findDocsLinks = () => wrapper.findAll(GlLink); + + const factory = ( + opts = { + propsData: { + strategy: {}, + index: 0, + userLists: [userList], + }, + provide, + }, + ) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = mount(Strategy, opts); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('helper links', () => { + const propsData = { strategy: {}, index: 0, userLists: [userList] }; + factory({ propsData, provide }); + + it('should display 2 helper links', () => { + const links = findDocsLinks(); + expect(links.exists()).toBe(true); + expect(links.at(0).attributes('href')).toContain('docs'); + expect(links.at(1).attributes('href')).toContain('docs'); + }); + }); + + describe.each` + name + ${ROLLOUT_STRATEGY_ALL_USERS} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} + ${ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT} + ${ROLLOUT_STRATEGY_USER_ID} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} + `('with strategy $name', ({ name }) => { + let propsData; + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {}, scopes: [] }; + propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + return wrapper.vm.$nextTick(); + }); + + it('should set the select to match the strategy name', () => { + expect(wrapper.find(GlFormSelect).element.value).toBe(name); + }); + + it('should emit a change if the parameters component does', () => { + findStrategyParameters().vm.$emit('change', { name, parameters: { test: 'parameters' } }); + expect(last(wrapper.emitted('change'))).toEqual([ + { name, parameters: { test: 'parameters' } }, + ]); + }); + }); + + describe('with the gradualRolloutByUserId strategy', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('shows an alert asking users to consider using flexibleRollout instead', () => { + expect(wrapper.find(GlAlert).text()).toContain( + 'Consider using the more flexible "Percent rollout" strategy instead.', + ); + }); + }); + + describe('with a strategy', () => { + describe('with a single environment scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should revert to all-environments scope when last scope is removed', () => { + const token = wrapper.find(GlToken); + token.vm.$emit('close'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(0); + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }, + ]); + }); + }); + }); + + describe('with an all-environments scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should change the parameters if a different strategy is chosen', () => { + const select = wrapper.find(GlFormSelect); + select.setValue(ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environmentScope: '*' }], + }, + ]); + }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [ + { environmentScope: '*', shouldBeDestroyed: true }, + { environmentScope: 'production' }, + ], + }, + ]); + }); + }); + + it('should emit a delete if the delete button is clicked', () => { + wrapper.find(GlButton).vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + + describe('without scopes defined', () => { + beforeEach(() => { + const strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: 'production' }], + }, + ]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/feature_flags/components/user_lists_table_spec.js new file mode 100644 index 00000000000..d6ced3be168 --- /dev/null +++ b/spec/frontend/feature_flags/components/user_lists_table_spec.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils'; +import * as timeago from 'timeago.js'; +import { GlModal } from '@gitlab/ui'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import { userList } from '../mock_data'; + +jest.mock('timeago.js', () => ({ + format: jest.fn().mockReturnValue('2 weeks ago'), + register: jest.fn(), +})); + +describe('User Lists Table', () => { + let wrapper; + let userLists; + + beforeEach(() => { + userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i })); + wrapper = mount(UserListsTable, { + propsData: { userLists }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should display the details of a user list', () => { + expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name); + expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe( + userList.user_xids.replace(/,/g, ', '), + ); + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago'); + expect(timeago.format).toHaveBeenCalledWith(userList.created_at); + }); + + it('should set the title for a tooltip on the created stamp', () => { + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( + 'Feb 4, 2020 8:13am GMT+0000', + ); + }); + + it('should display a user list entry per user list', () => { + const lists = wrapper.findAll('[data-testid="ffUserList"]'); + expect(lists).toHaveLength(5); + lists.wrappers.forEach(list => { + expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true); + }); + }); + + describe('edit button', () => { + it('should link to the path for the user list', () => { + expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path); + }); + }); + + describe('delete button', () => { + it('should display the confirmation modal', () => { + const modal = wrapper.find(GlModal); + + wrapper.find('[data-testid="delete-user-list"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(modal.text()).toContain(`Delete ${userList.name}?`); + expect(modal.text()).toContain(`User list ${userList.name} will be removed.`); + }); + }); + }); + + describe('confirmation modal', () => { + let modal; + + beforeEach(() => { + modal = wrapper.find(GlModal); + + wrapper.find('button').trigger('click'); + + return wrapper.vm.$nextTick(); + }); + + it('should emit delete with list on confirmation', () => { + modal.find('[data-testid="modal-confirm"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]); + }); + }); + + it('should not emit delete with list when not confirmed', () => { + modal.find('button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + }); + }); +}); |