Commit d340b6b9 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch 'show-user-list-fe' into 'master'

Show User List Details

See merge request gitlab-org/gitlab!35369
parents c8d0190e c68e32b8
<script> <script>
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { GlButton, GlModal, GlSprintf, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import {
GlButton,
GlButtonGroup,
GlModal,
GlSprintf,
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
export default { export default {
components: { GlButton, GlModal, GlSprintf }, components: { GlButton, GlButtonGroup, GlModal, GlSprintf },
directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective }, directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
...@@ -80,15 +87,23 @@ export default { ...@@ -80,15 +87,23 @@ export default {
</span> </span>
<span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span> <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
</div> </div>
<gl-button
v-gl-modal="$options.modal.id" <gl-button-group class="gl-align-self-start gl-mt-2">
class="gl-align-self-start gl-mt-2" <gl-button
category="secondary" :href="list.path"
variant="danger" category="secondary"
icon="remove" icon="pencil"
data-testid="delete-user-list" data-testid="edit-user-list"
@click="confirmDeleteList(list)" />
/> <gl-button
v-gl-modal="$options.modal.id"
category="secondary"
variant="danger"
icon="remove"
data-testid="delete-user-list"
@click="confirmDeleteList(list)"
/>
</gl-button-group>
</div> </div>
<gl-modal <gl-modal
:title="modalTitle" :title="modalTitle"
......
import Vue from 'vue';
import Vuex from 'vuex';
import UserList from 'ee/user_lists/components/user_list.vue';
import createStore from 'ee/user_lists/store/show';
Vue.use(Vuex);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-edit-user-list');
return new Vue({
el,
store: createStore(el.dataset),
render(h) {
const { emptyStatePath } = el.dataset;
return h(UserList, { props: { emptyStatePath } });
},
});
});
<script>
import { mapActions, mapState } from 'vuex';
import { GlAlert, GlEmptyState, GlLoadingIcon, GlModalDirective as GlModal } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import states from '../constants/show';
const commonTableClasses = ['gl-py-5', 'gl-border-b-1', 'gl-border-b-solid', 'gl-border-gray-100'];
export default {
components: {
GlAlert,
GlEmptyState,
GlLoadingIcon,
},
directives: {
GlModal,
},
props: {
emptyStatePath: {
required: true,
type: String,
},
},
translations: {
emptyStateTitle: s__('UserLists|There are no users'),
emptyStateDescription: s__(
'UserLists|Define a set of users to be used within feature flag strategies',
),
userIdLabel: s__('UserLists|User IDs'),
userIdColumnHeader: s__('UserLists|User ID'),
errorMessage: __('Something went wrong on our end. Please try again!'),
},
classes: {
headerClasses: [
'gl-display-flex',
'gl-justify-content-space-between',
'gl-pb-5',
'gl-border-b-1',
'gl-border-b-solid',
'gl-border-gray-100',
].join(' '),
tableHeaderClasses: commonTableClasses.join(' '),
tableRowClasses: [
...commonTableClasses,
'gl-display-flex',
'gl-justify-content-space-between',
'gl-align-items-center',
].join(' '),
},
modalId: 'add-userids-modal',
computed: {
...mapState(['userList', 'userIds', 'state']),
name() {
return this.userList?.name ?? '';
},
hasUserIds() {
return this.userIds.length > 0;
},
isLoading() {
return this.state === states.LOADING;
},
hasError() {
return this.state === states.ERROR;
},
},
mounted() {
this.fetchUserList();
},
methods: {
...mapActions(['fetchUserList', 'dismissErrorAlert']),
},
};
</script>
<template>
<div>
<gl-alert v-if="hasError" variant="danger" @dismiss="dismissErrorAlert">
{{ $options.translations.errorMessage }}
</gl-alert>
<gl-loading-icon v-if="isLoading" size="xl" class="mt-5" />
<div v-else>
<div :class="$options.classes.headerClasses">
<div>
<h3>{{ name }}</h3>
<h4 class="gl-text-gray-700">{{ $options.translations.userIdLabel }}</h4>
</div>
</div>
<div v-if="hasUserIds">
<div :class="$options.classes.tableHeaderClasses">
{{ $options.translations.userIdColumnHeader }}
</div>
<div
v-for="id in userIds"
:key="id"
data-testid="user-id-row"
:class="$options.classes.tableRowClasses"
>
<span data-testid="user-id">{{ id }}</span>
</div>
</div>
<gl-empty-state
v-else
:title="$options.translations.emptyStateTitle"
:description="$options.translations.emptyStateDescription"
:svg-path="emptyStatePath"
/>
</div>
</div>
</template>
export default Object.freeze({
LOADING: 'LOADING',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR',
ERROR_DISMISSED: 'ERROR_DISMISSED',
});
import Api from 'ee/api';
import * as types from './mutation_types';
export const fetchUserList = ({ commit, state }) => {
commit(types.REQUEST_USER_LIST);
return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid)
.then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data))
.catch(() => commit(types.RECEIVE_USER_LIST_ERROR));
};
export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
import Vuex from 'vuex';
import createState from './state';
import * as actions from './actions';
import mutations from './mutations';
export default initialState =>
new Vuex.Store({
actions,
mutations,
state: createState(initialState),
});
export const REQUEST_USER_LIST = 'REQUEST_USER_LIST';
export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS';
export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR';
export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
import states from '../../constants/show';
import * as types from './mutation_types';
export default {
[types.REQUEST_USER_LIST](state) {
state.state = states.LOADING;
},
[types.RECEIVE_USER_LIST_SUCCESS](state, userList) {
state.state = states.SUCCESS;
state.userIds = userList.user_xids?.length > 0 ? userList.user_xids.split(',') : [];
state.userList = userList;
},
[types.RECEIVE_USER_LIST_ERROR](state) {
state.state = states.ERROR;
},
[types.DISMISS_ERROR_ALERT](state) {
state.state = states.ERROR_DISMISSED;
},
};
import states from '../../constants/show';
export default ({ projectId = '', userListIid = '' }) => ({
state: states.LOADING,
projectId,
userListIid,
userIds: [],
userList: null,
});
# frozen_string_literal: true # frozen_string_literal: true
class Projects::FeatureFlagsUserListsController < Projects::ApplicationController class Projects::FeatureFlagsUserListsController < Projects::ApplicationController
before_action :check_feature_flag! before_action :check_feature_flag!, only: [:new, :edit]
before_action :authorize_admin_feature_flags_user_lists! before_action :authorize_admin_feature_flags_user_lists!
before_action :user_list, only: [:edit, :show] before_action :user_list, only: [:edit, :show]
......
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
- breadcrumb_title s_('FeatureFlags|List details') - breadcrumb_title s_('FeatureFlags|List details')
- page_title s_('FeatureFlags|Feature Flag User List Details') - page_title s_('FeatureFlags|Feature Flag User List Details')
List details #js-edit-user-list{ data: { project_id: @project.id,
user_list_iid: @user_list.iid,
%p= @user_list.name empty_state_path: image_path('illustrations/feature_flag.svg') } }
---
title: Show User List Details
merge_request: 35369
author:
type: added
...@@ -5,6 +5,8 @@ module EE ...@@ -5,6 +5,8 @@ module EE
module Entities module Entities
class FeatureFlag < Grape::Entity class FeatureFlag < Grape::Entity
class UserList < Grape::Entity class UserList < Grape::Entity
include RequestAwareEntity
expose :id expose :id
expose :iid expose :iid
expose :project_id expose :project_id
...@@ -12,6 +14,10 @@ module EE ...@@ -12,6 +14,10 @@ module EE
expose :updated_at expose :updated_at
expose :name expose :name
expose :user_xids expose :user_xids
expose :path do |list|
project_feature_flags_user_list_path(list.project, list)
end
end end
end end
end end
......
...@@ -140,14 +140,5 @@ RSpec.describe Projects::FeatureFlagsUserListsController do ...@@ -140,14 +140,5 @@ RSpec.describe Projects::FeatureFlagsUserListsController do
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end end
it 'returns not found when the feature flag is off' do
stub_feature_flags(feature_flag_user_lists: false)
list = create(:operations_feature_flag_user_list, project: project)
get(:show, params: request_params(iid: list.iid))
expect(response).to have_gitlab_http_status(:not_found)
end
end end
end end
...@@ -49,6 +49,12 @@ describe('User Lists Table', () => { ...@@ -49,6 +49,12 @@ describe('User Lists Table', () => {
}); });
}); });
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', () => { describe('delete button', () => {
it('should display the confirmation modal', () => { it('should display the confirmation modal', () => {
const modal = wrapper.find(GlModal); const modal = wrapper.find(GlModal);
......
...@@ -98,4 +98,5 @@ export const userList = { ...@@ -98,4 +98,5 @@ export const userList = {
project_id: 1, project_id: 1,
created_at: '2020-02-04T08:13:10.507Z', created_at: '2020-02-04T08:13:10.507Z',
updated_at: '2020-02-04T08:13:10.507Z', updated_at: '2020-02-04T08:13:10.507Z',
path: '/path/to/user/list',
}; };
import Vue from 'vue';
import Vuex from 'vuex';
import { mount } from '@vue/test-utils';
import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import Api from 'ee/api';
import { userList } from '../../feature_flags/mock_data';
import createStore from 'ee/user_lists/store/show';
import UserList from 'ee/user_lists/components/user_list.vue';
jest.mock('ee/api');
Vue.use(Vuex);
describe('User List', () => {
let wrapper;
const findUserIds = () => wrapper.findAll('[data-testid="user-id"]');
const destroy = () => wrapper?.destroy();
const factory = () => {
destroy();
wrapper = mount(UserList, {
store: createStore({ projectId: '1', userListIid: '2' }),
propsData: {
emptyStatePath: '/empty_state.svg',
},
});
};
describe('loading', () => {
let resolveFn;
beforeEach(() => {
Api.fetchFeatureFlagUserList.mockReturnValue(
new Promise(resolve => {
resolveFn = resolve;
}),
);
factory();
});
afterEach(() => {
resolveFn();
});
it('shows a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('success', () => {
let userIds;
beforeEach(() => {
userIds = userList.user_xids.split(',');
Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: userList });
factory();
return wrapper.vm.$nextTick();
});
it('requests the user list on mount', () => {
expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2');
});
it('shows the list name', () => {
expect(wrapper.find('h3').text()).toBe(userList.name);
});
it('shows a row for every id', () => {
expect(wrapper.findAll('[data-testid="user-id-row"]')).toHaveLength(userIds.length);
});
it('shows one id on each row', () => {
findUserIds().wrappers.forEach((w, i) => expect(w.text()).toBe(userIds[i]));
});
});
describe('error', () => {
const findAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
Api.fetchFeatureFlagUserList.mockRejectedValue();
factory();
return wrapper.vm.$nextTick();
});
it('displays the alert message', () => {
const alert = findAlert();
expect(alert.text()).toBe('Something went wrong on our end. Please try again!');
});
it('can dismiss the alert', async () => {
const alert = findAlert();
alert.find('button').trigger('click');
await wrapper.vm.$nextTick();
expect(alert.exists()).toBe(false);
});
});
describe('empty list', () => {
beforeEach(() => {
Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: { ...userList, user_xids: '' } });
factory();
return wrapper.vm.$nextTick();
});
it('displays an empty state', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
});
});
});
import testAction from 'helpers/vuex_action_helper';
import Api from 'ee/api';
import createState from 'ee/user_lists/store/show/state';
import * as types from 'ee/user_lists/store/show/mutation_types';
import * as actions from 'ee/user_lists/store/show/actions';
import { userList } from 'ee_jest/feature_flags/mock_data';
jest.mock('ee/api');
describe('User Lists Show Actions', () => {
let mockState;
beforeEach(() => {
mockState = createState({ projectId: '1', userListIid: '2' });
});
describe('fetchUserList', () => {
it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => {
Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
return testAction(
actions.fetchUserList,
undefined,
mockState,
[
{ type: types.REQUEST_USER_LIST },
{ type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
],
[],
() => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
);
});
it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => {
Api.fetchFeatureFlagUserList.mockRejectedValue({ message: 'fail' });
return testAction(
actions.fetchUserList,
undefined,
mockState,
[{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }],
[],
);
});
});
describe('dismissErrorAlert', () => {
it('commits DISMISS_ERROR_ALERT', () => {
return testAction(
actions.dismissErrorAlert,
undefined,
mockState,
[{ type: types.DISMISS_ERROR_ALERT }],
[],
);
});
});
});
import createState from 'ee/user_lists/store/show/state';
import mutations from 'ee/user_lists/store/show/mutations';
import states from 'ee/user_lists/constants/show';
import * as types from 'ee/user_lists/store/show/mutation_types';
import { userList } from 'ee_jest/feature_flags/mock_data';
describe('User Lists Show Mutations', () => {
let mockState;
beforeEach(() => {
mockState = createState({ projectId: '1', userListIid: '2' });
});
describe(types.REQUEST_USER_LIST, () => {
it('puts us in the loading state', () => {
mutations[types.REQUEST_USER_LIST](mockState);
expect(mockState.state).toBe(states.LOADING);
});
});
describe(types.RECEIVE_USER_LIST_SUCCESS, () => {
beforeEach(() => {
mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
});
it('sets the state to LOADED', () => {
expect(mockState.state).toBe(states.SUCCESS);
});
it('sets the active user list', () => {
expect(mockState.userList).toEqual(userList);
});
it('splits the user IDs into an Array', () => {
expect(mockState.userIds).toEqual(userList.user_xids.split(','));
});
it('sets user IDs to an empty Array if an empty string is received', () => {
mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, { ...userList, user_xids: '' });
expect(mockState.userIds).toEqual([]);
});
});
describe(types.RECEIVE_USER_LIST_ERROR, () => {
it('sets the state to error', () => {
mutations[types.RECEIVE_USER_LIST_ERROR](mockState);
expect(mockState.state).toBe(states.ERROR);
});
});
});
...@@ -58,7 +58,8 @@ RSpec.describe API::FeatureFlagsUserLists do ...@@ -58,7 +58,8 @@ RSpec.describe API::FeatureFlagsUserLists do
'created_at' => user_list.created_at.as_json, 'created_at' => user_list.created_at.as_json,
'updated_at' => user_list.updated_at.as_json, 'updated_at' => user_list.updated_at.as_json,
'name' => 'list_a', 'name' => 'list_a',
'user_xids' => 'user1' 'user_xids' => 'user1',
'path' => project_feature_flags_user_list_path(user_list.project, user_list)
}]) }])
end end
...@@ -122,7 +123,8 @@ RSpec.describe API::FeatureFlagsUserLists do ...@@ -122,7 +123,8 @@ RSpec.describe API::FeatureFlagsUserLists do
'iid' => list.iid, 'iid' => list.iid,
'project_id' => project.id, 'project_id' => project.id,
'created_at' => list.created_at.as_json, 'created_at' => list.created_at.as_json,
'updated_at' => list.updated_at.as_json 'updated_at' => list.updated_at.as_json,
'path' => project_feature_flags_user_list_path(list.project, list)
}) })
end end
......
...@@ -25172,6 +25172,18 @@ msgstr "" ...@@ -25172,6 +25172,18 @@ msgstr ""
msgid "User was successfully updated." msgid "User was successfully updated."
msgstr "" msgstr ""
msgid "UserLists|Define a set of users to be used within feature flag strategies"
msgstr ""
msgid "UserLists|There are no users"
msgstr ""
msgid "UserLists|User ID"
msgstr ""
msgid "UserLists|User IDs"
msgstr ""
msgid "UserList|Delete %{name}?" msgid "UserList|Delete %{name}?"
msgstr "" msgstr ""
......
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