Commit 184de1e5 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Heinrich Lee Yu

Add Search for User Lists in Strategy

This allows a user with too many user lists to search for the one they
wish to apply to a strategy.
parent 041d04e3
...@@ -737,6 +737,12 @@ const Api = { ...@@ -737,6 +737,12 @@ const Api = {
return axios.get(url, { params: { page } }); return axios.get(url, { params: { page } });
}, },
searchFeatureFlagUserLists(id, search) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
return axios.get(url, { params: { search } });
},
createFeatureFlagUserList(id, list) { createFeatureFlagUserList(id, list) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
......
...@@ -11,10 +11,8 @@ import { ...@@ -11,10 +11,8 @@ import {
GlSprintf, GlSprintf,
GlIcon, GlIcon,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Api from '~/api';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import EnvironmentsDropdown from './environments_dropdown.vue'; import EnvironmentsDropdown from './environments_dropdown.vue';
...@@ -89,7 +87,6 @@ export default { ...@@ -89,7 +87,6 @@ export default {
}, },
}, },
inject: { inject: {
projectId: {},
featureFlagIssuesEndpoint: { featureFlagIssuesEndpoint: {
default: '', default: '',
}, },
...@@ -124,7 +121,6 @@ export default { ...@@ -124,7 +121,6 @@ export default {
formStrategies: cloneDeep(this.strategies), formStrategies: cloneDeep(this.strategies),
newScope: '', newScope: '',
userLists: [],
}; };
}, },
computed: { computed: {
...@@ -155,17 +151,6 @@ export default { ...@@ -155,17 +151,6 @@ export default {
); );
}, },
}, },
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: { methods: {
keyFor(strategy) { keyFor(strategy) {
if (strategy.id) { if (strategy.id) {
...@@ -346,7 +331,6 @@ export default { ...@@ -346,7 +331,6 @@ export default {
:key="keyFor(strategy)" :key="keyFor(strategy)"
:strategy="strategy" :strategy="strategy"
:index="index" :index="index"
:user-lists="userLists"
@change="onFormStrategyChange($event, index)" @change="onFormStrategyChange($event, index)"
@delete="deleteStrategy(strategy)" @delete="deleteStrategy(strategy)"
/> />
......
<script> <script>
import { GlFormSelect } from '@gitlab/ui'; import { debounce } from 'lodash';
import { createNamespacedHelpers } from 'vuex';
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ParameterFormGroup from './parameter_form_group.vue'; import ParameterFormGroup from './parameter_form_group.vue';
const { mapActions, mapGetters, mapState } = createNamespacedHelpers('userLists');
const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter']);
export default { export default {
components: { components: {
GlFormSelect, GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
ParameterFormGroup, ParameterFormGroup,
}, },
props: { props: {
...@@ -13,34 +22,40 @@ export default { ...@@ -13,34 +22,40 @@ export default {
required: true, required: true,
type: Object, type: Object,
}, },
userLists: {
required: false,
type: Array,
default: () => [],
},
}, },
translations: { translations: {
rolloutUserListLabel: s__('FeatureFlag|List'), rolloutUserListLabel: s__('FeatureFlag|User List'),
rolloutUserListDescription: s__('FeatureFlag|Select a user list'), rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
defaultDropdownText: s__('FeatureFlags|Select a user list'),
}, },
computed: { computed: {
userListOptions() { ...mapGetters(['hasUserLists', 'isLoading', 'hasError', 'userListOptions']),
return this.userLists.map(({ name, id }) => ({ value: id, text: name })); ...mapState(['filter', 'userLists']),
},
hasUserLists() {
return this.userListOptions.length > 0;
},
userListId() { userListId() {
return this.strategy?.userListId ?? ''; return this.strategy?.userList?.id ?? '';
}, },
dropdownText() {
return this.strategy?.userList?.name ?? this.$options.defaultDropdownText;
},
},
mounted() {
fetchUserLists.apply(this);
}, },
methods: { methods: {
setFilter: debounce(setFilter, 250),
fetchUserLists: debounce(fetchUserLists, 250),
onUserListChange(list) { onUserListChange(list) {
this.$emit('change', { this.$emit('change', {
userListId: list, userList: list,
}); });
}, },
isSelectedUserList({ id }) {
return id === this.userListId;
},
setFocus() {
this.$refs.searchBox.focusInput();
},
}, },
}; };
</script> </script>
...@@ -52,12 +67,26 @@ export default { ...@@ -52,12 +67,26 @@ export default {
:description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
> >
<template #default="{ inputId }"> <template #default="{ inputId }">
<gl-form-select <gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus">
:id="inputId" <gl-search-box-by-type
:value="userListId" ref="searchBox"
:options="userListOptions" class="gl-m-3"
@change="onUserListChange" :value="filter"
/> @input="setFilter"
@focus="fetchUserLists"
@keyup="fetchUserLists"
/>
<gl-loading-icon v-if="isLoading" />
<gl-dropdown-item
v-for="list in userLists"
:key="list.id"
:is-checked="isSelectedUserList(list)"
is-check-item
@click="onUserListChange(list)"
>
{{ list.name }}
</gl-dropdown-item>
</gl-dropdown>
</template> </template>
</parameter-form-group> </parameter-form-group>
</template> </template>
...@@ -22,7 +22,7 @@ export default () => { ...@@ -22,7 +22,7 @@ export default () => {
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
store: createStore({ endpoint, path: featureFlagsPath }), store: createStore({ endpoint, projectId, path: featureFlagsPath }),
el, el,
provide: { provide: {
environmentsScopeDocsPath, environmentsScopeDocsPath,
......
...@@ -22,7 +22,7 @@ export default () => { ...@@ -22,7 +22,7 @@ export default () => {
return new Vue({ return new Vue({
el, el,
store: createStore({ endpoint, path: featureFlagsPath }), store: createStore({ endpoint, projectId, path: featureFlagsPath }),
provide: { provide: {
environmentsScopeDocsPath, environmentsScopeDocsPath,
strategyTypeDocsPagePath, strategyTypeDocsPagePath,
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import state from './state'; import state from './state';
import * as actions from './actions'; import * as actions from './actions';
import mutations from './mutations'; import mutations from './mutations';
...@@ -8,4 +9,7 @@ export default data => ...@@ -8,4 +9,7 @@ export default data =>
actions, actions,
mutations, mutations,
state: state(data), state: state(data),
modules: {
userLists: userLists(data),
},
}); });
import Api from '~/api';
import * as types from './mutation_types';
const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message);
export const fetchUserLists = ({ commit, state: { filter, projectId } }) => {
commit(types.FETCH_USER_LISTS);
return Api.searchFeatureFlagUserLists(projectId, filter)
.then(({ data }) => commit(types.RECEIVE_USER_LISTS_SUCCESS, data))
.catch(error => commit(types.RECEIVE_USER_LISTS_ERROR, getErrorMessages(error)));
};
export const setFilter = ({ commit, dispatch }, filter) => {
commit(types.SET_FILTER, filter);
return dispatch('fetchUserLists');
};
import statuses from './status';
export const userListOptions = ({ userLists }) =>
userLists.map(({ name, id }) => ({ value: id, text: name }));
export const hasUserLists = ({ userLists, status }) =>
[statuses.START, statuses.LOADING].indexOf(status) > -1 || userLists.length > 0;
export const isLoading = ({ status }) => status === statuses.LOADING;
export const hasError = ({ status }) => status === statuses.ERROR;
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default data => ({
state: state(data),
actions,
getters,
mutations,
namespaced: true,
});
export const FETCH_USER_LISTS = 'FETCH_USER_LISTS';
export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
export const SET_FILTER = 'SET_FILTER';
import statuses from './status';
import * as types from './mutation_types';
export default {
[types.FETCH_USER_LISTS](state) {
state.status = statuses.LOADING;
},
[types.RECEIVE_USER_LISTS_SUCCESS](state, lists) {
state.userLists = lists;
state.status = statuses.IDLE;
},
[types.RECEIVE_USER_LISTS_ERROR](state, error) {
state.error = error;
state.status = statuses.ERROR;
},
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
};
import statuses from './status';
export default ({ projectId }) => ({
projectId,
userLists: [],
filter: '',
status: statuses.START,
error: '',
});
export default {
START: 'START',
LOADING: 'LOADING',
IDLE: 'IDLE',
ERROR: 'ERROR',
};
...@@ -174,7 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails => ...@@ -174,7 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails =>
id: s.id, id: s.id,
name: s.name, name: s.name,
parameters: mapStrategiesParametersToViewModel(s.parameters), parameters: mapStrategiesParametersToViewModel(s.parameters),
userListId: s.user_list?.id, userList: s.user_list,
// eslint-disable-next-line no-underscore-dangle // eslint-disable-next-line no-underscore-dangle
shouldBeDestroyed: Boolean(s._destroy), shouldBeDestroyed: Boolean(s._destroy),
scopes: mapStrategyScopesToView(s.scopes), scopes: mapStrategyScopesToView(s.scopes),
...@@ -197,7 +197,7 @@ const mapStrategyToRails = strategy => { ...@@ -197,7 +197,7 @@ const mapStrategyToRails = strategy => {
}; };
if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) { if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
mappedStrategy.user_list_id = strategy.userListId; mappedStrategy.user_list_id = strategy.userList.id;
} }
return mappedStrategy; return mappedStrategy;
}; };
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import state from './state'; import state from './state';
import * as actions from './actions'; import * as actions from './actions';
import mutations from './mutations'; import mutations from './mutations';
...@@ -8,4 +9,7 @@ export default data => ...@@ -8,4 +9,7 @@ export default data =>
actions, actions,
mutations, mutations,
state: state(data), state: state(data),
modules: {
userLists: userLists(data),
},
}); });
# frozen_string_literal: true
class FeatureFlagsUserListsFinder
attr_reader :project, :current_user, :params
def initialize(project, current_user, params = {})
@project = project
@current_user = current_user
@params = params
end
def execute
unless Ability.allowed?(current_user, :read_feature_flag, project)
return Operations::FeatureFlagsUserList.none
end
items = feature_flags_user_lists
by_search(items)
end
private
def feature_flags_user_lists
project.operations_feature_flags_user_lists
end
def by_search(items)
if params[:search].present?
items.for_name_like(params[:search])
else
items
end
end
end
...@@ -5,6 +5,7 @@ module Operations ...@@ -5,6 +5,7 @@ module Operations
class UserList < ApplicationRecord class UserList < ApplicationRecord
include AtomicInternalId include AtomicInternalId
include IidRoutes include IidRoutes
include ::Gitlab::SQL::Pattern
self.table_name = 'operations_user_lists' self.table_name = 'operations_user_lists'
...@@ -23,6 +24,10 @@ module Operations ...@@ -23,6 +24,10 @@ module Operations
before_destroy :ensure_no_associated_strategies before_destroy :ensure_no_associated_strategies
scope :for_name_like, -> (query) do
fuzzy_search(query, [:name], use_minimum_char_limit: false)
end
private private
def ensure_no_associated_strategies def ensure_no_associated_strategies
......
---
title: Add Search for User Lists in Strategy
merge_request: 45820
author:
type: added
...@@ -25,9 +25,10 @@ Gets all feature flag user lists for the requested project. ...@@ -25,9 +25,10 @@ Gets all feature flag user lists for the requested project.
GET /projects/:id/feature_flags_user_lists GET /projects/:id/feature_flags_user_lists
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | | --------- | -------------- | -------- | -------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `search` | string | no | Return user lists matching the search criteria. |
```shell ```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/feature_flags_user_lists" curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/feature_flags_user_lists"
......
...@@ -760,83 +760,6 @@ describe('Api', () => { ...@@ -760,83 +760,6 @@ describe('Api', () => {
}); });
}); });
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
let mockUserList;
beforeEach(() => {
projectId = 1000;
expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/feature_flags_user_lists`;
mockUserList = {
name: 'mock_user_list',
user_xids: '1,2,3,4',
project_id: 1,
id: 1,
iid: 1,
};
});
describe('fetchFeatureFlagUserLists', () => {
it('GETs the right url', () => {
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => {
expect(data).toEqual([]);
});
});
});
describe('createFeatureFlagUserList', () => {
it('POSTs data to the right url', () => {
const mockUserListData = {
name: 'mock_user_list',
user_xids: '1,2,3,4',
};
mock.onPost(expectedUrl, mockUserListData).replyOnce(httpStatus.OK, mockUserList);
return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
});
describe('fetchFeatureFlagUserList', () => {
it('GETs the right url', () => {
mock.onGet(`${expectedUrl}/1`).replyOnce(httpStatus.OK, mockUserList);
return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
});
describe('updateFeatureFlagUserList', () => {
it('PUTs the right url', () => {
mock
.onPut(`${expectedUrl}/1`)
.replyOnce(httpStatus.OK, { ...mockUserList, user_xids: '5' });
return Api.updateFeatureFlagUserList(projectId, {
...mockUserList,
user_xids: '5',
}).then(({ data }) => {
expect(data).toEqual({ ...mockUserList, user_xids: '5' });
});
});
});
describe('deleteFeatureFlagUserList', () => {
it('DELETEs the right url', () => {
mock.onDelete(`${expectedUrl}/1`).replyOnce(httpStatus.OK, 'deleted');
return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toBe('deleted');
});
});
});
});
describe('Application Settings', () => { describe('Application Settings', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/application/settings`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/application/settings`;
const apiResponse = { mock_setting: 1, mock_setting2: 2, mock_setting3: 3 }; const apiResponse = { mock_setting: 1, mock_setting2: 2, mock_setting3: 3 };
......
...@@ -24,10 +24,13 @@ module API ...@@ -24,10 +24,13 @@ module API
success ::API::Entities::FeatureFlag::UserList success ::API::Entities::FeatureFlag::UserList
end end
params do params do
optional :search, type: String, desc: 'Returns the list of user lists matching the search critiera'
use :pagination use :pagination
end end
get do get do
present paginate(user_project.operations_feature_flags_user_lists), user_lists = ::FeatureFlagsUserListsFinder.new(user_project, current_user, params).execute
present paginate(user_lists),
with: ::API::Entities::FeatureFlag::UserList with: ::API::Entities::FeatureFlag::UserList
end end
......
...@@ -11462,6 +11462,9 @@ msgstr "" ...@@ -11462,6 +11462,9 @@ msgstr ""
msgid "FeatureFlags|Rollout Strategy" msgid "FeatureFlags|Rollout Strategy"
msgstr "" msgstr ""
msgid "FeatureFlags|Select a user list"
msgstr ""
msgid "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}." msgid "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}."
msgstr "" msgstr ""
...@@ -11480,9 +11483,6 @@ msgstr "" ...@@ -11480,9 +11483,6 @@ msgstr ""
msgid "FeatureFlags|There was an error fetching the user lists." msgid "FeatureFlags|There was an error fetching the user lists."
msgstr "" msgstr ""
msgid "FeatureFlags|There was an error retrieving user lists"
msgstr ""
msgid "FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel." msgid "FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel."
msgstr "" msgstr ""
...@@ -11498,9 +11498,6 @@ msgstr "" ...@@ -11498,9 +11498,6 @@ msgstr ""
msgid "FeatureFlags|User Lists" msgid "FeatureFlags|User Lists"
msgstr "" msgstr ""
msgid "FeatureFlag|List"
msgstr ""
msgid "FeatureFlag|Percentage" msgid "FeatureFlag|Percentage"
msgstr "" msgstr ""
...@@ -11519,6 +11516,9 @@ msgstr "" ...@@ -11519,6 +11516,9 @@ msgstr ""
msgid "FeatureFlag|User IDs" msgid "FeatureFlag|User IDs"
msgstr "" msgstr ""
msgid "FeatureFlag|User List"
msgstr ""
msgid "Feb" msgid "Feb"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe FeatureFlagsUserListsFinder do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
before_all do
project.add_maintainer(user)
end
describe '#execute' do
it 'returns user lists' do
finder = described_class.new(project, user, {})
user_list = create(:operations_feature_flag_user_list, project: project)
expect(finder.execute).to contain_exactly(user_list)
end
context 'with search' do
it 'returns only matching user lists' do
create(:operations_feature_flag_user_list, name: 'do not find', project: project)
user_list = create(:operations_feature_flag_user_list, name: 'testing', project: project)
finder = described_class.new(project, user, { search: "test" })
expect(finder.execute).to contain_exactly(user_list)
end
end
end
end
...@@ -1232,4 +1232,91 @@ describe('Api', () => { ...@@ -1232,4 +1232,91 @@ describe('Api', () => {
}); });
}); });
}); });
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
let mockUserList;
beforeEach(() => {
projectId = 1000;
expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/feature_flags_user_lists`;
mockUserList = {
name: 'mock_user_list',
user_xids: '1,2,3,4',
project_id: 1,
id: 1,
iid: 1,
};
});
describe('fetchFeatureFlagUserLists', () => {
it('GETs the right url', () => {
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => {
expect(data).toEqual([]);
});
});
});
describe('searchFeatureFlagUserLists', () => {
it('GETs the right url', () => {
mock.onGet(expectedUrl, { params: { search: 'test' } }).replyOnce(httpStatus.OK, []);
return Api.searchFeatureFlagUserLists(projectId, 'test').then(({ data }) => {
expect(data).toEqual([]);
});
});
});
describe('createFeatureFlagUserList', () => {
it('POSTs data to the right url', () => {
const mockUserListData = {
name: 'mock_user_list',
user_xids: '1,2,3,4',
};
mock.onPost(expectedUrl, mockUserListData).replyOnce(httpStatus.OK, mockUserList);
return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
});
describe('fetchFeatureFlagUserList', () => {
it('GETs the right url', () => {
mock.onGet(`${expectedUrl}/1`).replyOnce(httpStatus.OK, mockUserList);
return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
});
describe('updateFeatureFlagUserList', () => {
it('PUTs the right url', () => {
mock
.onPut(`${expectedUrl}/1`)
.replyOnce(httpStatus.OK, { ...mockUserList, user_xids: '5' });
return Api.updateFeatureFlagUserList(projectId, {
...mockUserList,
user_xids: '5',
}).then(({ data }) => {
expect(data).toEqual({ ...mockUserList, user_xids: '5' });
});
});
});
describe('deleteFeatureFlagUserList', () => {
it('DELETEs the right url', () => {
mock.onDelete(`${expectedUrl}/1`).replyOnce(httpStatus.OK, 'deleted');
return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toBe('deleted');
});
});
});
});
}); });
...@@ -442,12 +442,6 @@ describe('feature flag form', () => { ...@@ -442,12 +442,6 @@ describe('feature flag form', () => {
}); });
}); });
it('should request the user lists on mount', () => {
return wrapper.vm.$nextTick(() => {
expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1');
});
});
it('should show the strategy component', () => { it('should show the strategy component', () => {
const strategy = wrapper.find(Strategy); const strategy = wrapper.find(Strategy);
expect(strategy.exists()).toBe(true); expect(strategy.exists()).toBe(true);
...@@ -485,9 +479,5 @@ describe('feature flag form', () => { ...@@ -485,9 +479,5 @@ describe('feature flag form', () => {
expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy); 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]);
});
}); });
}); });
import { mount } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import { GlFormSelect } from '@gitlab/ui'; import Vuex from 'vuex';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import createStore from '~/feature_flags/store/new';
import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue';
import { userListStrategy, userList } from '../../mock_data'; import { userListStrategy, userList } from '../../mock_data';
jest.mock('~/api');
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
strategy: userListStrategy, strategy: userListStrategy,
userLists: [userList],
}; };
const localVue = createLocalVue();
localVue.use(Vuex);
describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => {
let wrapper; let wrapper;
const factory = (props = {}) => const factory = (props = {}) =>
mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } }); mount(GitlabUserList, {
localVue,
store: createStore({ projectId: '1' }),
propsData: { ...DEFAULT_PROPS, ...props },
});
describe('with user lists', () => { describe('with user lists', () => {
const findDropdownItem = () => wrapper.find(GlDropdownItem);
beforeEach(() => { beforeEach(() => {
Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
wrapper = factory(); wrapper = factory();
}); });
it('should show the input for userListId with the correct value', () => { it('should show the input for userListId with the correct value', () => {
const inputWrapper = wrapper.find(GlFormSelect); const dropdownWrapper = wrapper.find(GlDropdown);
expect(inputWrapper.exists()).toBe(true); expect(dropdownWrapper.exists()).toBe(true);
expect(inputWrapper.element.value).toBe('2'); expect(dropdownWrapper.props('text')).toBe(userList.name);
});
it('should show a check for the selected list', () => {
const itemWrapper = findDropdownItem();
expect(itemWrapper.props('isChecked')).toBe(true);
});
it('should display the name of the list in the drop;down', () => {
const itemWrapper = findDropdownItem();
expect(itemWrapper.text()).toBe(userList.name);
}); });
it('should emit a change event when altering the userListId', () => { it('should emit a change event when altering the userListId', () => {
const inputWrapper = wrapper.find(GitlabUserList); const inputWrapper = findDropdownItem();
inputWrapper.vm.$emit('change', { inputWrapper.vm.$emit('click');
userListId: '3',
});
expect(wrapper.emitted('change')).toEqual([ expect(wrapper.emitted('change')).toEqual([
[ [
{ {
userListId: '3', userList,
}, },
], ],
]); ]);
}); });
it('should search when the filter changes', async () => {
let r;
Api.searchFeatureFlagUserLists.mockReturnValue(
new Promise(resolve => {
r = resolve;
}),
);
const searchWrapper = wrapper.find(GlSearchBoxByType);
searchWrapper.vm.$emit('input', 'new');
await wrapper.vm.$nextTick();
const loadingIcon = wrapper.find(GlLoadingIcon);
expect(loadingIcon.exists()).toBe(true);
expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'new');
r({ data: [userList] });
await wrapper.vm.$nextTick();
expect(loadingIcon.exists()).toBe(false);
});
}); });
describe('without user lists', () => { describe('without user lists', () => {
beforeEach(() => { beforeEach(() => {
wrapper = factory({ userLists: [] }); Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [] });
wrapper = factory();
}); });
it('should display a message that there are no user lists', () => { it('should display a message that there are no user lists', () => {
......
...@@ -11,11 +11,10 @@ import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_li ...@@ -11,11 +11,10 @@ import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_li
import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue';
import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue';
import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue';
import { allUsersStrategy, userList } from '../mock_data'; import { allUsersStrategy } from '../mock_data';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
strategy: allUsersStrategy, strategy: allUsersStrategy,
userLists: [userList],
}; };
describe('~/feature_flags/components/strategy_parameters.vue', () => { describe('~/feature_flags/components/strategy_parameters.vue', () => {
...@@ -71,13 +70,14 @@ describe('~/feature_flags/components/strategy_parameters.vue', () => { ...@@ -71,13 +70,14 @@ describe('~/feature_flags/components/strategy_parameters.vue', () => {
describe('pass through props', () => { describe('pass through props', () => {
it('should pass through any extra props that might be needed', () => { it('should pass through any extra props that might be needed', () => {
const strategy = {
name: ROLLOUT_STRATEGY_USER_ID,
};
wrapper = factory({ wrapper = factory({
strategy: { strategy,
name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
},
}); });
expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]); expect(wrapper.find(UsersWithId).props('strategy')).toEqual(strategy);
}); });
}); });
}); });
import { mount } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { last } from 'lodash'; import { last } from 'lodash';
import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui';
import Api from '~/api';
import createStore from '~/feature_flags/store/new';
import { import {
PERCENT_ROLLOUT_GROUP_ID, PERCENT_ROLLOUT_GROUP_ID,
ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_ALL_USERS,
...@@ -15,12 +18,17 @@ import StrategyParameters from '~/feature_flags/components/strategy_parameters.v ...@@ -15,12 +18,17 @@ import StrategyParameters from '~/feature_flags/components/strategy_parameters.v
import { userList } from '../mock_data'; import { userList } from '../mock_data';
jest.mock('~/api');
const provide = { const provide = {
strategyTypeDocsPagePath: 'link-to-strategy-docs', strategyTypeDocsPagePath: 'link-to-strategy-docs',
environmentsScopeDocsPath: 'link-scope-docs', environmentsScopeDocsPath: 'link-scope-docs',
environmentsEndpoint: '', environmentsEndpoint: '',
}; };
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Feature flags strategy', () => { describe('Feature flags strategy', () => {
let wrapper; let wrapper;
...@@ -32,7 +40,6 @@ describe('Feature flags strategy', () => { ...@@ -32,7 +40,6 @@ describe('Feature flags strategy', () => {
propsData: { propsData: {
strategy: {}, strategy: {},
index: 0, index: 0,
userLists: [userList],
}, },
provide, provide,
}, },
...@@ -41,9 +48,13 @@ describe('Feature flags strategy', () => { ...@@ -41,9 +48,13 @@ describe('Feature flags strategy', () => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
} }
wrapper = mount(Strategy, opts); wrapper = mount(Strategy, { localVue, store: createStore({ projectId: '1' }), ...opts });
}; };
beforeEach(() => {
Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
});
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
......
...@@ -127,7 +127,7 @@ export const userListStrategy = { ...@@ -127,7 +127,7 @@ export const userListStrategy = {
name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
parameters: {}, parameters: {},
scopes: [], scopes: [],
userListId: userList.id, userList,
}; };
export const percentRolloutStrategy = { export const percentRolloutStrategy = {
......
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
import createState from '~/feature_flags/store/gitlab_user_list/state';
import { fetchUserLists, setFilter } from '~/feature_flags/store/gitlab_user_list/actions';
import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types';
import { userList } from '../../mock_data';
jest.mock('~/api');
describe('~/feature_flags/store/gitlab_user_list/actions', () => {
let mockedState;
beforeEach(() => {
mockedState = createState({ projectId: '1' });
mockedState.filter = 'test';
});
describe('fetchUserLists', () => {
it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_SUCCESS on success', () => {
Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
return testAction(
fetchUserLists,
undefined,
mockedState,
[
{ type: types.FETCH_USER_LISTS },
{ type: types.RECEIVE_USER_LISTS_SUCCESS, payload: [userList] },
],
[],
() => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'),
);
});
it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_ERROR on success', () => {
Api.searchFeatureFlagUserLists.mockRejectedValue({ message: 'error' });
return testAction(
fetchUserLists,
undefined,
mockedState,
[
{ type: types.FETCH_USER_LISTS },
{ type: types.RECEIVE_USER_LISTS_ERROR, payload: ['error'] },
],
[],
() => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'),
);
});
});
describe('setFilter', () => {
it('commits SET_FILTER and fetches new user lists', () =>
testAction(
setFilter,
'filter',
mockedState,
[{ type: types.SET_FILTER, payload: 'filter' }],
[{ type: 'fetchUserLists' }],
));
});
});
import {
userListOptions,
hasUserLists,
isLoading,
hasError,
} from '~/feature_flags/store/gitlab_user_list/getters';
import statuses from '~/feature_flags/store/gitlab_user_list/status';
import createState from '~/feature_flags/store/gitlab_user_list/state';
import { userList } from '../../mock_data';
describe('~/feature_flags/store/gitlab_user_list/getters', () => {
let mockedState;
beforeEach(() => {
mockedState = createState({ projectId: '8' });
mockedState.userLists = [userList];
});
describe('userListOption', () => {
it('should return user lists in a way usable by a dropdown', () => {
expect(userListOptions(mockedState)).toEqual([{ value: userList.id, text: userList.name }]);
});
it('should return an empty array if there are no lists', () => {
mockedState.userLists = [];
expect(userListOptions(mockedState)).toEqual([]);
});
});
describe('hasUserLists', () => {
it.each`
userLists | status | result
${[userList]} | ${statuses.IDLE} | ${true}
${[]} | ${statuses.IDLE} | ${false}
${[]} | ${statuses.START} | ${true}
`(
'should return $result if there are $userLists.length user lists and the status is $status',
({ userLists, status, result }) => {
mockedState.userLists = userLists;
mockedState.status = status;
expect(hasUserLists(mockedState)).toBe(result);
},
);
});
describe('isLoading', () => {
it.each`
status | result
${statuses.LOADING} | ${true}
${statuses.ERROR} | ${false}
${statuses.IDLE} | ${false}
`('should return $result if the status is "$status"', ({ status, result }) => {
mockedState.status = status;
expect(isLoading(mockedState)).toBe(result);
});
});
describe('hasError', () => {
it.each`
status | result
${statuses.LOADING} | ${false}
${statuses.ERROR} | ${true}
${statuses.IDLE} | ${false}
`('should return $result if the status is "$status"', ({ status, result }) => {
mockedState.status = status;
expect(hasError(mockedState)).toBe(result);
});
});
});
import statuses from '~/feature_flags/store/gitlab_user_list/status';
import createState from '~/feature_flags/store/gitlab_user_list/state';
import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types';
import mutations from '~/feature_flags/store/gitlab_user_list/mutations';
import { userList } from '../../mock_data';
describe('~/feature_flags/store/gitlab_user_list/mutations', () => {
let state;
beforeEach(() => {
state = createState({ projectId: '8' });
});
describe(types.SET_FILTER, () => {
it('sets the filter in the state', () => {
mutations[types.SET_FILTER](state, 'test');
expect(state.filter).toBe('test');
});
});
describe(types.FETCH_USER_LISTS, () => {
it('sets the status to loading', () => {
mutations[types.FETCH_USER_LISTS](state);
expect(state.status).toBe(statuses.LOADING);
});
});
describe(types.RECEIVE_USER_LISTS_SUCCESS, () => {
it('sets the user lists to the ones received', () => {
mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]);
expect(state.userLists).toEqual([userList]);
});
it('sets the status to idle', () => {
mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]);
expect(state.status).toBe(statuses.IDLE);
});
});
describe(types.RECEIVE_USER_LISTS_ERROR, () => {
it('sets the status to error', () => {
mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure');
expect(state.status).toBe(statuses.ERROR);
});
it('sets the error message', () => {
mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure');
expect(state.error).toBe('failure');
});
});
});
...@@ -92,6 +92,25 @@ RSpec.describe Operations::FeatureFlags::UserList do ...@@ -92,6 +92,25 @@ RSpec.describe Operations::FeatureFlags::UserList do
end end
end end
describe '.for_name_like' do
let_it_be(:project) { create(:project) }
let_it_be(:user_list_one) { create(:operations_feature_flag_user_list, project: project, name: 'one') }
let_it_be(:user_list_two) { create(:operations_feature_flag_user_list, project: project, name: 'list_two') }
let_it_be(:user_list_three) { create(:operations_feature_flag_user_list, project: project, name: 'list_three') }
it 'returns a found name' do
lists = project.operations_feature_flags_user_lists.for_name_like('list')
expect(lists).to contain_exactly(user_list_two, user_list_three)
end
it 'returns an empty array when no lists match the query' do
lists = project.operations_feature_flags_user_lists.for_name_like('no match')
expect(lists).to be_empty
end
end
it_behaves_like 'AtomicInternalId' do it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid } let(:internal_id_attribute) { :iid }
let(:instance) { build(:operations_feature_flag_user_list) } let(:instance) { build(:operations_feature_flag_user_list) }
......
...@@ -95,6 +95,39 @@ RSpec.describe API::FeatureFlagsUserLists do ...@@ -95,6 +95,39 @@ RSpec.describe API::FeatureFlagsUserLists do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq([]) expect(json_response).to eq([])
end end
context 'when filtering' do
it 'returns lists matching the search term' do
create_list(name: 'test_list', user_xids: 'user1')
create_list(name: 'list_b', user_xids: 'user1,user2,user3')
get api("/projects/#{project.id}/feature_flags_user_lists?search=test", developer)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.map { |list| list['name'] }).to eq(['test_list'])
end
it 'returns lists matching multiple search terms' do
create_list(name: 'test_list', user_xids: 'user1')
create_list(name: 'list_b', user_xids: 'user1,user2,user3')
create_list(name: 'test_again', user_xids: 'user1,user2,user3')
get api("/projects/#{project.id}/feature_flags_user_lists?search=test list", developer)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.map { |list| list['name'] }).to eq(['test_list'])
end
it 'returns all lists with no query' do
create_list(name: 'list_a', user_xids: 'user1')
create_list(name: 'list_b', user_xids: 'user1,user2,user3')
get api("/projects/#{project.id}/feature_flags_user_lists?search=", developer)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.map { |list| list['name'] }.sort).to eq(%w[list_a list_b])
end
end
end end
describe 'GET /projects/:id/feature_flags_user_lists/:iid' do describe 'GET /projects/:id/feature_flags_user_lists/:iid' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment