Commit b7c83cc3 authored by Florie Guibert's avatar Florie Guibert Committed by Natalia Tepluhina

Refactor assignee select in board scope

parent a795f0fa
......@@ -2,7 +2,7 @@
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import ListLabel from '~/boards/models/label';
import { TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants';
import { TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
......@@ -21,7 +21,6 @@ const boardDefaults = {
milestone_id: undefined,
iteration_id: undefined,
assignee: {},
assignee_id: undefined,
weight: null,
hide_backlog_list: false,
hide_closed_list: false,
......@@ -190,9 +189,7 @@ export default {
issueBoardScopeMutationVariables() {
return {
weight: this.board.weight,
assigneeId: this.board.assignee?.id
? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
: null,
assigneeId: this.board.assignee?.id || null,
milestoneId:
this.board.milestone?.id || this.board.milestone?.id === 0
? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id)
......@@ -306,6 +303,11 @@ export default {
}
});
},
setAssignee(assigneeId) {
this.board.assignee = {
id: assigneeId,
};
},
},
};
</script>
......@@ -373,6 +375,7 @@ export default {
:weights="weights"
@set-iteration="setIteration"
@set-board-labels="setBoardLabels"
@set-assignee="setAssignee"
/>
</form>
</gl-modal>
......
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) {
workspace: group(fullPath: $fullPath) {
users: groupMembers(search: $search, relations: [DIRECT, INHERITED]) {
nodes {
user {
...User
...UserAvailability
}
}
}
}
}
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import UsersSelect from '~/users_select';
import {
GlButton,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { s__ } from '~/locale';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
components: {
UserAvatarImage,
GlButton,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
},
inject: ['fullPath'],
props: {
anyUserText: {
type: String,
required: false,
default: __('Any user'),
},
board: {
type: Object,
required: true,
......@@ -25,150 +38,200 @@ export default {
required: false,
default: false,
},
fieldName: {
type: String,
required: true,
},
groupId: {
type: Number,
required: false,
default: 0,
},
label: {
type: String,
required: true,
},
placeholderText: {
type: String,
required: false,
default: __('Select user'),
},
projectId: {
type: Number,
required: false,
default: 0,
},
selected: {
type: Object,
required: false,
default: () => null,
},
wrapperClass: {
type: String,
required: false,
default: '',
},
data() {
return {
search: '',
searchUsers: [],
selected: this.board.assignee,
isEditing: false,
isDropdownShowing: false,
};
},
apollo: {
searchUsers: {
query() {
return this.isProjectBoard ? searchProjectUsers : searchGroupUsers;
},
variables() {
return {
fullPath: this.fullPath,
search: this.search,
first: 20,
};
},
skip() {
return !this.isEditing;
},
update(data) {
// TODO Remove null filter (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/329750
return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.setError({ message: this.$options.i18n.errorSearchingUsers });
},
},
},
computed: {
hasValue() {
return this.selected && this.selected.id > 0;
...mapGetters(['isProjectBoard']),
isLoading() {
return this.$apollo.queries.searchUsers.loading;
},
selectedId() {
return this.selected ? this.selected.id : null;
isSearchEmpty() {
return this.search === '' && !this.isLoading;
},
},
watch: {
selected() {
this.initSelect();
selectedIsEmpty() {
return isEmpty(this.selected);
},
noUsersFound() {
return !this.isSearchEmpty && this.users.length === 0;
},
users() {
const filteredUsers = this.searchUsers.filter(
(user) => user.name.includes(this.search) || user.username.includes(this.search),
);
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
return filteredUsers
.concat(this.searchUsers)
.reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[],
);
},
},
mounted() {
this.initSelect();
},
methods: {
initSelect() {
this.userDropdown = new UsersSelect(null, this.$refs.dropdown, {
handleClick: this.selectUser,
});
},
selectUser(user, isMarking) {
let assignee = user;
if (!isMarking) {
// correctly select "unassigned" in Assignee dropdown
assignee = {
id: undefined,
};
...mapActions(['setError']),
selectAssignee(user) {
this.selected = user;
this.toggleEdit();
this.$emit('set-assignee', user?.id || null);
},
toggleEdit() {
if (!this.isEditing && !this.isDropdownShowing) {
this.isEditing = true;
this.showDropdown();
} else {
this.isEditing = false;
this.isDropdownShowing = false;
}
// eslint-disable-next-line vue/no-mutating-props
this.board.assignee_id = assignee.id;
// eslint-disable-next-line vue/no-mutating-props
this.board.assignee = assignee;
},
isSelected(user) {
return this.selected?.username === user.username;
},
showDropdown() {
this.$refs.editDropdown.show();
this.isDropdownShowing = true;
},
setFocus() {
this.$refs.search.focusInput();
},
hideDropdown() {
this.isEditing = false;
},
},
i18n: {
label: s__('BoardScope|Assignee'),
anyAssignee: s__('BoardScope|Any assignee'),
selectAssignee: s__('BoardScope|Select assignee'),
noMatchingResults: s__('BoardScope|No matching results'),
errorSearchingUsers: s__(
'BoardScope|An error occurred while searching for users, please try again.',
),
edit: s__('BoardScope|Edit'),
},
};
</script>
<template>
<div :class="wrapperClass" class="block">
<div class="block assignee">
<div class="title gl-mb-3">
{{ label }}
<button v-if="canEdit" type="button" class="edit-link btn btn-blank float-right">
{{ __('Edit') }}
</button>
{{ $options.i18n.label }}
<gl-button
v-if="canEdit"
variant="link"
class="edit-link float-right gl-text-gray-900!"
@click="toggleEdit"
>
{{ $options.i18n.edit }}
</gl-button>
</div>
<div class="value">
<div v-if="hasValue" class="media gl-display-flex gl-align-items-center">
<div class="align-center">
<user-avatar-image :img-src="selected.avatar_url" :size="32" />
</div>
<div class="media-body">
<div class="bold author">{{ selected.name }}</div>
<div class="username">@{{ selected.username }}</div>
<div v-if="!isEditing" data-testid="selected-assignee">
<div v-if="!selectedIsEmpty" class="gl-display-flex gl-align-items-center">
<user-avatar-image :img-src="selected.avatarUrl || selected.avatar_url" :size="32" />
<div>
<div class="gl-font-weight-bold">{{ selected.name }}</div>
<div>@{{ selected.username }}</div>
</div>
</div>
<div v-else class="text-secondary">{{ anyUserText }}</div>
<div v-else class="gl-text-gray-500">{{ $options.i18n.anyAssignee }}</div>
</div>
<div class="selectbox" style="display: none">
<div class="dropdown">
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
<button
ref="dropdown"
:data-field-name="fieldName"
:data-dropdown-title="placeholderText"
:data-any-user="anyUserText"
:data-group-id="groupId"
:data-project-id="projectId"
:data-selected="selectedId"
class="dropdown-menu-toggle wide"
data-toggle="dropdown"
aria-expanded="false"
type="button"
>
<span class="dropdown-toggle-text">{{ placeholderText }}</span>
<gl-icon
name="chevron-down"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
:size="16"
/>
</button>
<!-- eslint-enable @gitlab/vue-no-data-toggle -->
<div
class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-user dropdown-menu-selectable dropdown-menu-author"
>
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
:placeholder="__('Search')"
type="search"
/>
<gl-icon
name="search"
class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none"
/>
<gl-icon
name="close"
class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500"
/>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<gl-loading-icon size="sm" />
</div>
</div>
</div>
</div>
<gl-dropdown
v-show="isEditing"
ref="editDropdown"
:text="$options.i18n.selectAssignee"
lazy
menu-class="gl-w-full!"
class="gl-w-full"
@shown="setFocus"
@hide="hideDropdown"
>
<template #header>
<gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
v-if="isLoading"
size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
:is-check-centered="true"
@click="selectAssignee(null)"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">
{{ $options.i18n.anyAssignee }}
</span>
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="user in users"
:key="user.id"
:is-checked="isSelected(user)"
:is-check-centered="true"
:is-check-item="true"
:avatar-url="user.avatar_url || user.avatarUrl"
:secondary-text="user.username"
data-testid="unselected-user"
@click="selectAssignee(user)"
>
{{ user.name }}
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }}
</gl-dropdown-item>
</template>
</gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown>
</div>
</template>
......@@ -134,15 +134,10 @@ export default {
<assignee-select
v-if="isIssueBoard"
:board="board"
:selected="board.assignee"
:can-edit="canAdminBoard"
:project-id="projectId"
:group-id="groupId"
any-user-text="Any assignee"
field-name="assignee_id"
label="Assignee"
placeholder-text="Select assignee"
wrapper-class="assignee"
@set-assignee="$emit('set-assignee', $event)"
/>
<!-- eslint-disable vue/no-mutating-props -->
......
......@@ -229,7 +229,7 @@ RSpec.describe 'Scoped issue boards', :js do
edit_board.click
expect(find('.milestone .value')).to have_content(milestone.title)
expect(find('.assignee .value')).to have_content(user.name)
expect(find('[data-testid="selected-assignee"]')).to have_content(user.name)
expect(find('.weight .value')).to have_content(2)
end
......@@ -564,7 +564,7 @@ RSpec.describe 'Scoped issue boards', :js do
click_button value
end
else
click_link value
click_on value
end
end
end
......
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import { GlButton, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import AssigneeSelect from 'ee/boards/components/assignee_select.vue';
import { boardObj } from 'jest/boards/mock_data';
import boardsStore from '~/boards/stores/boards_store';
import IssuableContext from '~/issuable_context';
import axios from '~/lib/utils/axios_utils';
let vm;
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
function selectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
function activeDropdownItem(index) {
const items = document.querySelectorAll('.is-active');
if (!items[index]) return '';
return items[index].innerText.trim();
}
import { boardObj } from 'jest/boards/mock_data';
import { projectMembersResponse, groupMembersResponse, mockUser2 } from 'jest/sidebar/mock_data';
const assignee = {
id: 1,
name: 'first assignee',
};
import defaultStore from '~/boards/stores';
import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
const assignee2 = {
id: 2,
name: 'second assignee',
};
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Assignee select component', () => {
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
boardsStore.create();
// eslint-disable-next-line no-new
new IssuableContext();
const Component = Vue.extend(AssigneeSelect);
vm = new Component({
let wrapper;
let fakeApollo;
let store;
const selectedText = () => wrapper.find('[data-testid="selected-assignee"]').text();
const findEditButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const usersQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse);
const groupUsersQueryHandlerSuccess = jest.fn().mockResolvedValue(groupMembersResponse);
const createStore = ({ isGroupBoard = false, isProjectBoard = false } = {}) => {
store = new Vuex.Store({
...defaultStore,
getters: {
isGroupBoard: () => isGroupBoard,
isProjectBoard: () => isProjectBoard,
},
});
};
const createComponent = ({ props = {}, usersQueryHandler = usersQueryHandlerSuccess } = {}) => {
fakeApollo = createMockApollo([
[searchProjectUsersQuery, usersQueryHandler],
[searchGroupUsersQuery, groupUsersQueryHandlerSuccess],
]);
wrapper = shallowMount(AssigneeSelect, {
localVue,
store,
apolloProvider: fakeApollo,
propsData: {
board: boardObj,
assigneePath: '/test/issue-boards/assignees.json',
canEdit: true,
label: 'Assignee',
selected: {},
fieldName: 'assignee_id',
anyUserText: 'Any assignee',
...props,
},
}).$mount('.test-container');
provide: {
fullPath: 'gitlab-org',
},
});
setImmediate(done);
});
// We need to mock out `showDropdown` which
// invokes `show` method of BDropdown used inside GlDropdown.
jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
describe('canEdit', () => {
it('hides Edit button', (done) => {
vm.canEdit = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
done();
});
});
beforeEach(() => {
createStore({ isProjectBoard: true });
createComponent();
});
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
});
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
store = null;
});
describe('selected value', () => {
describe('when not editing', () => {
it('defaults to Any Assignee', () => {
expect(selectedText()).toContain('Any assignee');
});
it('shows selected assignee', (done) => {
vm.selected = assignee;
Vue.nextTick(() => {
expect(selectedText()).toContain('first assignee');
done();
});
it('skips the queries and does not render dropdown', () => {
expect(usersQueryHandlerSuccess).not.toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(false);
});
});
describe('clicking dropdown items', () => {
let mock;
describe('when editing', () => {
it('trigger query and renders dropdown with returned users', async () => {
findEditButton().vm.$emit('click');
await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(usersQueryHandlerSuccess).toHaveBeenCalled();
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet('/-/autocomplete/users.json').reply(200, [assignee, assignee2]);
});
expect(findDropdown().isVisible()).toBe(true);
expect(wrapper.findAll('[data-testid="unselected-user"]')).toHaveLength(3); // 2 users + Any assignee item
});
afterEach(() => {
mock.restore();
});
it('renders selected assignee', async () => {
findEditButton().vm.$emit('click');
await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
it('sets assignee', (done) => {
vm.$el.querySelector('.edit-link').click();
wrapper
.findAll('[data-testid="unselected-user"]')
.at(1)
.vm.$emit('click', new Event('click'));
await waitForPromises();
expect(selectedText()).toContain(mockUser2.username);
});
});
jest.runOnlyPendingTimers();
describe('canEdit', () => {
it('hides Edit button', async () => {
wrapper.setProps({ canEdit: false });
await nextTick();
setImmediate(() => {
vm.$el.querySelectorAll('li a')[2].click();
expect(findEditButton().exists()).toBe(false);
});
setImmediate(() => {
expect(activeDropdownItem(0)).toEqual('second assignee');
expect(vm.board.assignee).toEqual(assignee2);
done();
});
});
});
it('shows Edit button if true', () => {
expect(findEditButton().exists()).toBe(true);
});
});
it.each`
boardType | mockedResponse | queryHandler | notCalledHandler
${'group'} | ${groupMembersResponse} | ${groupUsersQueryHandlerSuccess} | ${usersQueryHandlerSuccess}
${'project'} | ${projectMembersResponse} | ${usersQueryHandlerSuccess} | ${groupUsersQueryHandlerSuccess}
`(
'fetches $boardType users',
async ({ boardType, mockedResponse, queryHandler, notCalledHandler }) => {
createStore({ isProjectBoard: boardType === 'project', isGroupBoard: boardType === 'group' });
createComponent({
[queryHandler]: jest.fn().mockResolvedValue(mockedResponse),
});
findEditButton().vm.$emit('click');
await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(queryHandler).toHaveBeenCalled();
expect(notCalledHandler).not.toHaveBeenCalled();
},
);
});
import { createLocalVue, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import BoardScope from 'ee/boards/components/board_scope.vue';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import { TEST_HOST } from 'helpers/test_constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
describe('BoardScope', () => {
let wrapper;
......@@ -25,7 +24,6 @@ describe('BoardScope', () => {
function mountComponent() {
wrapper = mount(BoardScope, {
localVue,
store,
propsData: {
collapseScope: false,
......@@ -37,6 +35,9 @@ describe('BoardScope', () => {
labelsPath: `${TEST_HOST}/labels`,
labelsWebUrl: `${TEST_HOST}/-/labels`,
},
stubs: {
AssigneeSelect: true,
},
});
}
......
......@@ -3912,9 +3912,6 @@ msgstr ""
msgid "Any namespace"
msgstr ""
msgid "Any user"
msgstr ""
msgid "App ID"
msgstr ""
......@@ -5284,6 +5281,24 @@ msgstr ""
msgid "BoardNewIssue|Select a project"
msgstr ""
msgid "BoardScope|An error occurred while searching for users, please try again."
msgstr ""
msgid "BoardScope|Any assignee"
msgstr ""
msgid "BoardScope|Assignee"
msgstr ""
msgid "BoardScope|Edit"
msgstr ""
msgid "BoardScope|No matching results"
msgstr ""
msgid "BoardScope|Select assignee"
msgstr ""
msgid "Boards"
msgstr ""
......@@ -29525,9 +29540,6 @@ msgstr ""
msgid "Select type"
msgstr ""
msgid "Select user"
msgstr ""
msgid "Selected"
msgstr ""
......
......@@ -415,7 +415,7 @@ const mockUser1 = {
status: null,
};
const mockUser2 = {
export const mockUser2 = {
id: 'gid://gitlab/User/4',
avatarUrl: '/avatar2',
name: 'rookie',
......@@ -452,9 +452,40 @@ export const projectMembersResponse = {
null,
null,
// Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822
mockUser1,
mockUser1,
mockUser2,
{ user: mockUser1 },
{ user: mockUser1 },
{ user: mockUser2 },
{
user: {
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
},
],
},
},
},
};
export const groupMembersResponse = {
data: {
workspace: {
__typename: 'roup',
users: {
nodes: [
// Remove nulls https://gitlab.com/gitlab-org/gitlab/-/issues/329750
null,
null,
// Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822
{ user: mockUser1 },
{ user: mockUser1 },
{
user: {
id: 'gid://gitlab/User/2',
......
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