Commit 61c3777c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'refactor-assignee-select-use-dropdown-widget' into 'master'

Use dropdown widget in board scope assignee select

See merge request gitlab-org/gitlab!68022
parents 6c9cb080 fd29d841
...@@ -68,7 +68,11 @@ export default { ...@@ -68,7 +68,11 @@ export default {
this.$emit('set-option', option || null); this.$emit('set-option', option || null);
}, },
isSelected(option) { isSelected(option) {
return this.selected && this.selected.title === option.title; return (
this.selected &&
((option.name && this.selected.name === option.name) ||
(option.title && this.selected.title === option.title))
);
}, },
showDropdown() { showDropdown() {
this.$refs.dropdown.show(); this.$refs.dropdown.show();
...@@ -79,6 +83,13 @@ export default { ...@@ -79,6 +83,13 @@ export default {
setSearchTerm(search) { setSearchTerm(search) {
this.$emit('set-search', search); this.$emit('set-search', search);
}, },
avatarUrl(option) {
return option.avatar_url || option.avatarUrl || null;
},
secondaryText(option) {
// TODO: this has some knowledge of the context where the component is used. We could later rework it.
return option.username || null;
},
}, },
i18n: { i18n: {
noMatchingResults: __('No matching results'), noMatchingResults: __('No matching results'),
...@@ -121,7 +132,9 @@ export default { ...@@ -121,7 +132,9 @@ export default {
:is-check-item="true" :is-check-item="true"
@click="selectOption(option)" @click="selectOption(option)"
> >
{{ option.title }} <slot name="preset-item" :item="option">
{{ option.title }}
</slot>
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-divider /> <gl-dropdown-divider />
</template> </template>
...@@ -131,10 +144,14 @@ export default { ...@@ -131,10 +144,14 @@ export default {
:is-checked="isSelected(option)" :is-checked="isSelected(option)"
:is-check-centered="true" :is-check-centered="true"
:is-check-item="true" :is-check-item="true"
:avatar-url="avatarUrl(option)"
:secondary-text="secondaryText(option)"
data-testid="unselected-option" data-testid="unselected-option"
@click="selectOption(option)" @click="selectOption(option)"
> >
{{ option.title }} <slot name="item" :item="option">
{{ option.title }}
</slot>
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }} {{ $options.i18n.noMatchingResults }}
......
<script> <script>
import { import { GlButton } from '@gitlab/ui';
GlButton,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql'; import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { AssigneesPreset, ANY_ASSIGNEE } from '../constants';
export default { export default {
AssigneesPreset,
components: { components: {
UserAvatarImage, UserAvatarImage,
DropdownWidget,
GlButton, GlButton,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
}, },
inject: ['fullPath'], inject: ['fullPath'],
props: { props: {
...@@ -86,18 +77,12 @@ export default { ...@@ -86,18 +77,12 @@ export default {
}, },
computed: { computed: {
...mapGetters(['isProjectBoard']), ...mapGetters(['isProjectBoard']),
anyAssignee() {
return this.selected.name === ANY_ASSIGNEE.name;
},
isLoading() { isLoading() {
return this.$apollo.queries.searchUsers.loading; return this.$apollo.queries.searchUsers.loading;
}, },
isSearchEmpty() {
return this.search === '' && !this.isLoading;
},
selectedIsEmpty() {
return isEmpty(this.selected);
},
noUsersFound() {
return !this.isSearchEmpty && this.users.length === 0;
},
users() { users() {
const filteredUsers = this.searchUsers.filter( const filteredUsers = this.searchUsers.filter(
(user) => user.name.includes(this.search) || user.username.includes(this.search), (user) => user.name.includes(this.search) || user.username.includes(this.search),
...@@ -113,6 +98,11 @@ export default { ...@@ -113,6 +98,11 @@ export default {
); );
}, },
}, },
created() {
if (isEmpty(this.board.assignee)) {
this.selected = ANY_ASSIGNEE;
}
},
methods: { methods: {
...mapActions(['setError']), ...mapActions(['setError']),
selectAssignee(user) { selectAssignee(user) {
...@@ -129,25 +119,21 @@ export default { ...@@ -129,25 +119,21 @@ export default {
this.isDropdownShowing = false; this.isDropdownShowing = false;
} }
}, },
isSelected(user) {
return this.selected?.username === user.username;
},
showDropdown() { showDropdown() {
this.$refs.editDropdown.show(); this.$refs.editDropdown.showDropdown();
this.isDropdownShowing = true; this.isDropdownShowing = true;
}, },
setFocus() {
this.$refs.search.focusInput();
},
hideDropdown() { hideDropdown() {
this.isEditing = false; this.isEditing = false;
}, },
setSearch(search) {
this.search = search;
},
}, },
i18n: { i18n: {
label: s__('BoardScope|Assignee'), label: s__('BoardScope|Assignee'),
anyAssignee: s__('BoardScope|Any assignee'), anyAssignee: s__('BoardScope|Any assignee'),
selectAssignee: s__('BoardScope|Select assignee'), selectAssignee: s__('BoardScope|Select assignee'),
noMatchingResults: s__('BoardScope|No matching results'),
errorSearchingUsers: s__( errorSearchingUsers: s__(
'BoardScope|An error occurred while searching for users, please try again.', 'BoardScope|An error occurred while searching for users, please try again.',
), ),
...@@ -171,7 +157,7 @@ export default { ...@@ -171,7 +157,7 @@ export default {
</gl-button> </gl-button>
</div> </div>
<div v-if="!isEditing" data-testid="selected-assignee"> <div v-if="!isEditing" data-testid="selected-assignee">
<div v-if="!selectedIsEmpty" class="gl-display-flex gl-align-items-center"> <div v-if="!anyAssignee" class="gl-display-flex gl-align-items-center">
<user-avatar-image :img-src="selected.avatarUrl || selected.avatar_url" :size="32" /> <user-avatar-image :img-src="selected.avatarUrl || selected.avatar_url" :size="32" />
<div> <div>
<div class="gl-font-weight-bold">{{ selected.name }}</div> <div class="gl-font-weight-bold">{{ selected.name }}</div>
...@@ -181,58 +167,25 @@ export default { ...@@ -181,58 +167,25 @@ export default {
<div v-else class="gl-text-gray-500">{{ $options.i18n.anyAssignee }}</div> <div v-else class="gl-text-gray-500">{{ $options.i18n.anyAssignee }}</div>
</div> </div>
<gl-dropdown <dropdown-widget
v-show="isEditing" v-show="isEditing"
ref="editDropdown" ref="editDropdown"
:text="$options.i18n.selectAssignee" :select-text="$options.i18n.selectAssignee"
lazy :preset-options="$options.AssigneesPreset"
menu-class="gl-w-full!" :options="users"
class="gl-w-full" :is-loading="isLoading"
@shown="setFocus" :selected="selected"
:search-term="search"
@hide="hideDropdown" @hide="hideDropdown"
@set-option="selectAssignee"
@set-search="setSearch"
> >
<template #header> <template #preset-item="{ item }">
<gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" /> {{ item.name }}
</template> </template>
<gl-dropdown-form class="gl-relative gl-min-h-7"> <template #item="{ item }">
<gl-loading-icon {{ item.name }}
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> </template>
</gl-dropdown> </dropdown-widget>
</div> </div>
</template> </template>
...@@ -79,6 +79,12 @@ export const MilestonesPreset = [ ...@@ -79,6 +79,12 @@ export const MilestonesPreset = [
STARTED_MILESTONE, STARTED_MILESTONE,
]; ];
export const ANY_ASSIGNEE = {
id: 'gid://gitlab/User/-1',
name: s__('BoardScope|Any assignee'),
};
export const AssigneesPreset = [ANY_ASSIGNEE];
export const WeightFilterType = { export const WeightFilterType = {
none: 'None', none: 'None',
}; };
......
import { GlButton, GlDropdown } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -15,6 +15,7 @@ import defaultStore from '~/boards/stores'; ...@@ -15,6 +15,7 @@ import defaultStore from '~/boards/stores';
import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; import searchProjectUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -26,7 +27,7 @@ describe('Assignee select component', () => { ...@@ -26,7 +27,7 @@ describe('Assignee select component', () => {
const selectedText = () => wrapper.find('[data-testid="selected-assignee"]').text(); const selectedText = () => wrapper.find('[data-testid="selected-assignee"]').text();
const findEditButton = () => wrapper.findComponent(GlButton); const findEditButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(DropdownWidget);
const usersQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse); const usersQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse);
const groupUsersQueryHandlerSuccess = jest.fn().mockResolvedValue(groupMembersResponse); const groupUsersQueryHandlerSuccess = jest.fn().mockResolvedValue(groupMembersResponse);
...@@ -85,33 +86,28 @@ describe('Assignee select component', () => { ...@@ -85,33 +86,28 @@ describe('Assignee select component', () => {
expect(usersQueryHandlerSuccess).not.toHaveBeenCalled(); expect(usersQueryHandlerSuccess).not.toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(false); expect(findDropdown().isVisible()).toBe(false);
}); });
});
describe('when editing', () => { it('renders selected assignee', async () => {
it('trigger query and renders dropdown with returned users', async () => {
findEditButton().vm.$emit('click'); findEditButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); findDropdown().vm.$emit('set-option', mockUser2);
await nextTick();
expect(usersQueryHandlerSuccess).toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(true); await nextTick();
expect(wrapper.findAll('[data-testid="unselected-user"]')).toHaveLength(3); // 2 users + Any assignee item expect(selectedText()).toContain(mockUser2.username);
}); });
});
it('renders selected assignee', async () => { describe('when editing', () => {
it('trigger query and renders dropdown with returned users', async () => {
findEditButton().vm.$emit('click'); findEditButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick(); await nextTick();
expect(usersQueryHandlerSuccess).toHaveBeenCalled();
wrapper expect(findDropdown().isVisible()).toBe(true);
.findAll('[data-testid="unselected-user"]') expect(findDropdown().props('options')).toHaveLength(3);
.at(1) expect(findDropdown().props('presetOptions')).toHaveLength(1);
.vm.$emit('click', new Event('click'));
await waitForPromises();
expect(selectedText()).toContain(mockUser2.username);
}); });
}); });
......
...@@ -9,7 +9,11 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -9,7 +9,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { boardObj } from 'jest/boards/mock_data'; import { boardObj } from 'jest/boards/mock_data';
import { mockProjectMilestonesResponse, mockGroupMilestonesResponse } from 'jest/sidebar/mock_data'; import {
mockProjectMilestonesResponse,
mockGroupMilestonesResponse,
mockMilestone1,
} from 'jest/sidebar/mock_data';
import defaultStore from '~/boards/stores'; import defaultStore from '~/boards/stores';
import groupMilestonesQuery from '~/sidebar/queries/group_milestones.query.graphql'; import groupMilestonesQuery from '~/sidebar/queries/group_milestones.query.graphql';
...@@ -92,6 +96,15 @@ describe('Milestone select component', () => { ...@@ -92,6 +96,15 @@ describe('Milestone select component', () => {
expect(milestonesQueryHandlerSuccess).not.toHaveBeenCalled(); expect(milestonesQueryHandlerSuccess).not.toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(false); expect(findDropdown().isVisible()).toBe(false);
}); });
it('renders selected milestone', async () => {
findEditButton().vm.$emit('click');
await waitForPromises();
findDropdown().vm.$emit('set-option', mockMilestone1);
await waitForPromises();
expect(selectedText()).toContain(mockMilestone1.title);
});
}); });
describe('when editing', () => { describe('when editing', () => {
......
...@@ -5425,9 +5425,6 @@ msgstr "" ...@@ -5425,9 +5425,6 @@ msgstr ""
msgid "BoardScope|Milestone" msgid "BoardScope|Milestone"
msgstr "" msgstr ""
msgid "BoardScope|No matching results"
msgstr ""
msgid "BoardScope|No milestone" msgid "BoardScope|No milestone"
msgstr "" msgstr ""
......
...@@ -13,7 +13,6 @@ describe('DropdownWidget component', () => { ...@@ -13,7 +13,6 @@ describe('DropdownWidget component', () => {
const createComponent = ({ props = {} } = {}) => { const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(DropdownWidget, { wrapper = shallowMount(DropdownWidget, {
propsData: { propsData: {
...props,
options: [ options: [
{ {
id: '1', id: '1',
...@@ -24,6 +23,7 @@ describe('DropdownWidget component', () => { ...@@ -24,6 +23,7 @@ describe('DropdownWidget component', () => {
title: 'Option 2', title: 'Option 2',
}, },
], ],
...props,
}, },
stubs: { stubs: {
GlDropdown, GlDropdown,
...@@ -76,4 +76,22 @@ describe('DropdownWidget component', () => { ...@@ -76,4 +76,22 @@ describe('DropdownWidget component', () => {
expect(wrapper.emitted('set-option')).toEqual([[wrapper.props().options[1]]]); expect(wrapper.emitted('set-option')).toEqual([[wrapper.props().options[1]]]);
}); });
}); });
describe('when options are users', () => {
const mockUser = {
id: 1,
name: 'User name',
username: 'username',
avatarUrl: 'foo/bar',
};
beforeEach(() => {
createComponent({ props: { options: [mockUser] } });
});
it('passes user related props to dropdown item', () => {
expect(findDropdownItems().at(0).props('avatarUrl')).toBe(mockUser.avatarUrl);
expect(findDropdownItems().at(0).props('secondaryText')).toBe(mockUser.username);
});
});
}); });
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