Commit e3c41933 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '292035-convert-the-assignees-feature-into-a-widget' into 'master'

[RUN-AS-IF-FOSS] POC: Sidebar Assignees Widget

See merge request gitlab-org/gitlab!50054
parents d6bc39c2 33c12b9a
<script>
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import searchUsers from '~/boards/graphql/users_search.query.graphql';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
export default {
noSearchDelay: 0,
searchDelay: 250,
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
components: {
BoardEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
},
data() {
return {
search: '',
issueParticipants: [],
selected: [],
};
},
apollo: {
issueParticipants: {
query: getIssueParticipants,
variables() {
return {
id: `gid://gitlab/Issue/${this.activeIssue.iid}`,
};
},
update(data) {
return data.issue?.participants?.nodes || [];
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
search: this.search,
};
},
update: (data) => data.users?.nodes || [],
skip() {
return this.isSearchEmpty;
},
debounce: 250,
},
},
computed: {
...mapGetters(['activeIssue']),
...mapState(['isSettingAssignees']),
participants() {
return this.isSearchEmpty ? this.issueParticipants : this.searchUsers;
},
assigneeText() {
return n__('Assignee', '%d Assignees', this.selected.length);
},
unSelectedFiltered() {
return (
this.participants?.filter(({ username }) => {
return !this.selectedUserNames.includes(username);
}) || []
);
},
selectedIsEmpty() {
return this.selected.length === 0;
},
selectedUserNames() {
return this.selected.map(({ username }) => username);
},
isSearchEmpty() {
return this.search === '';
},
currentUser() {
return gon?.current_username;
},
isLoading() {
return (
this.$apollo.queries.issueParticipants?.loading || this.$apollo.queries.searchUsers?.loading
);
},
},
created() {
this.selected = cloneDeep(this.activeIssue.assignees);
},
methods: {
...mapActions(['setAssignees']),
async assignSelf() {
const [currentUserObject] = await this.setAssignees(this.currentUser);
this.selectAssignee(currentUserObject);
},
clearSelected() {
this.selected = [];
},
selectAssignee(name) {
if (name === undefined) {
this.clearSelected();
return;
}
this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
},
saveAssignees() {
this.setAssignees(this.selectedUserNames);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
},
},
};
</script>
<template>
<board-editable-item :loading="isSettingAssignees" :title="assigneeText" @close="saveAssignees">
<template #collapsed>
<issuable-assignees :users="activeIssue.assignees" @assign-self="assignSelf" />
</template>
<template #default>
<multi-select-dropdown
class="w-100"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
>
<template #search>
<gl-search-box-by-type v-model.trim="search" />
</template>
<template #items>
<gl-loading-icon v-if="isLoading" size="lg" />
<template v-else>
<gl-dropdown-item
:is-checked="selectedIsEmpty"
data-testid="unassign"
class="mt-2"
@click="selectAssignee()"
>{{ $options.i18n.unassigned }}</gl-dropdown-item
>
<gl-dropdown-divider data-testid="unassign-divider" />
<gl-dropdown-item
v-for="item in selected"
:key="item.id"
:is-checked="isChecked(item.username)"
@click="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<gl-dropdown-item
v-for="unselectedUser in unSelectedFiltered"
:key="unselectedUser.id"
:data-testid="`item_${unselectedUser.name}`"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
</template>
</template>
</multi-select-dropdown>
</template>
</board-editable-item>
</template>
...@@ -7,7 +7,6 @@ import { GlLabel } from '@gitlab/ui'; ...@@ -7,7 +7,6 @@ import { GlLabel } from '@gitlab/ui';
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import DueDateSelectors from '~/due_date_select'; import DueDateSelectors from '~/due_date_select';
import { deprecatedCreateFlash as Flash } from '~/flash';
import IssuableContext from '~/issuable_context'; import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select'; import LabelsSelect from '~/labels_select';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel } from '~/lib/utils/common_utils';
...@@ -16,6 +15,7 @@ import MilestoneSelect from '~/milestone_select'; ...@@ -16,6 +15,7 @@ import MilestoneSelect from '~/milestone_select';
import Sidebar from '~/right_sidebar'; import Sidebar from '~/right_sidebar';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
import Assignees from '~/sidebar/components/assignees/assignees.vue'; import Assignees from '~/sidebar/components/assignees/assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
...@@ -32,6 +32,7 @@ export default Vue.extend({ ...@@ -32,6 +32,7 @@ export default Vue.extend({
RemoveBtn, RemoveBtn,
Subscriptions, Subscriptions,
TimeTracker, TimeTracker,
SidebarAssigneesWidget,
}, },
props: { props: {
currentUser: { currentUser: {
...@@ -78,12 +79,6 @@ export default Vue.extend({ ...@@ -78,12 +79,6 @@ export default Vue.extend({
detail: { detail: {
handler() { handler() {
if (this.issue.id !== this.detail.issue.id) { if (this.issue.id !== this.detail.issue.id) {
$('.block.assignee')
.find('input:not(.js-vue)[name="issue[assignee_ids][]"]')
.each((i, el) => {
$(el).remove();
});
$('.js-issue-board-sidebar', this.$el).each((i, el) => { $('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('deprecatedJQueryDropdown').clearMenu(); $(el).data('deprecatedJQueryDropdown').clearMenu();
}); });
...@@ -96,18 +91,9 @@ export default Vue.extend({ ...@@ -96,18 +91,9 @@ export default Vue.extend({
}, },
}, },
created() { created() {
// Get events from deprecatedJQueryDropdown
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
eventHub.$on('sidebar.addAssignee', this.addAssignee);
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
eventHub.$on('sidebar.closeAll', this.closeSidebar); eventHub.$on('sidebar.closeAll', this.closeSidebar);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
eventHub.$off('sidebar.addAssignee', this.addAssignee);
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
eventHub.$off('sidebar.closeAll', this.closeSidebar); eventHub.$off('sidebar.closeAll', this.closeSidebar);
}, },
mounted() { mounted() {
...@@ -121,34 +107,8 @@ export default Vue.extend({ ...@@ -121,34 +107,8 @@ export default Vue.extend({
closeSidebar() { closeSidebar() {
this.detail.issue = {}; this.detail.issue = {};
}, },
assignSelf() { setAssignees(data) {
// Notify gl dropdown that we are now assigning to current user boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
this.addAssignee(this.currentUser);
this.saveAssignees();
},
removeAssignee(a) {
boardsStore.detail.issue.removeAssignee(a);
},
addAssignee(a) {
boardsStore.detail.issue.addAssignee(a);
},
removeAllAssignees() {
boardsStore.detail.issue.removeAllAssignees();
},
saveAssignees() {
this.loadingAssignees = true;
boardsStore.detail.issue
.update()
.then(() => {
this.loadingAssignees = false;
})
.catch(() => {
this.loadingAssignees = false;
Flash(__('An error occurred while saving assignees'));
});
}, },
showScopedLabels(label) { showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label); return boardsStore.scopedLabels.enabled && isScopedLabel(label);
......
...@@ -86,7 +86,7 @@ export default () => { ...@@ -86,7 +86,7 @@ export default () => {
groupId: Number($boardApp.dataset.groupId), groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath, rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null, currentUserId: gon.current_user_id || null,
canUpdate: $boardApp.dataset.canUpdate, canUpdate: parseBoolean($boardApp.dataset.canUpdate),
labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath, labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
......
...@@ -53,6 +53,10 @@ class ListIssue { ...@@ -53,6 +53,10 @@ class ListIssue {
return boardsStore.findIssueAssignee(this, findAssignee); return boardsStore.findIssueAssignee(this, findAssignee);
} }
setAssignees(assignees) {
boardsStore.setIssueAssignees(this, assignees);
}
removeAssignee(removeAssignee) { removeAssignee(removeAssignee) {
boardsStore.removeIssueAssignee(this, removeAssignee); boardsStore.removeIssueAssignee(this, removeAssignee);
} }
......
import { pick } from 'lodash'; import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants'; import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import { import {
formatBoardLists, formatBoardLists,
formatListIssues, formatListIssues,
...@@ -333,34 +329,11 @@ export default { ...@@ -333,34 +329,11 @@ export default {
}, },
setAssignees: ({ commit, getters }, assigneeUsernames) => { setAssignees: ({ commit, getters }, assigneeUsernames) => {
commit(types.SET_ASSIGNEE_LOADING, true); commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
return gqlClient prop: 'assignees',
.mutate({ value: assigneeUsernames,
mutation: updateAssigneesMutation, });
variables: {
iid: getters.activeIssue.iid,
projectPath: getters.activeIssue.referencePath.split('#')[0],
assigneeUsernames,
},
})
.then(({ data }) => {
const { nodes } = data.issueSetAssignees?.issue?.assignees || [];
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
value: nodes,
});
return nodes;
})
.catch(() => {
createFlash({ message: __('An error occurred while updating assignees.') });
})
.finally(() => {
commit(types.SET_ASSIGNEE_LOADING, false);
});
}, },
setActiveIssueMilestone: async ({ commit, getters }, input) => { setActiveIssueMilestone: async ({ commit, getters }, input) => {
......
...@@ -724,6 +724,10 @@ const boardsStore = { ...@@ -724,6 +724,10 @@ const boardsStore = {
} }
}, },
setIssueAssignees(issue, assignees) {
issue.assignees = [...assignees];
},
removeIssueLabels(issue, labels) { removeIssueLabels(issue, labels) {
labels.forEach(issue.removeLabel.bind(issue)); labels.forEach(issue.removeLabel.bind(issue));
}, },
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
query getProjectIssue($iid: String!, $fullPath: ID!) { query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
issue(iid: $iid) { issue(iid: $iid) {
id
assignees { assignees {
nodes { nodes {
...Author ...Author
......
...@@ -11,10 +11,6 @@ export default { ...@@ -11,10 +11,6 @@ export default {
UncollapsedAssigneeList, UncollapsedAssigneeList,
}, },
props: { props: {
rootPath: {
type: String,
required: true,
},
users: { users: {
type: Array, type: Array,
required: true, required: true,
...@@ -53,7 +49,7 @@ export default { ...@@ -53,7 +49,7 @@ export default {
<div data-testid="expanded-assignee" class="value hide-collapsed"> <div data-testid="expanded-assignee" class="value hide-collapsed">
<template v-if="hasNoUsers"> <template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself"> <span class="assign-yourself no-value">
{{ __('None') }} {{ __('None') }}
<template v-if="editable"> <template v-if="editable">
- -
...@@ -64,12 +60,7 @@ export default { ...@@ -64,12 +60,7 @@ export default {
</span> </span>
</template> </template>
<uncollapsed-assignee-list <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
v-else
:users="sortedAssigness"
:root-path="rootPath"
:issuable-type="issuableType"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -8,12 +8,16 @@ export default { ...@@ -8,12 +8,16 @@ export default {
GlButton, GlButton,
UncollapsedAssigneeList, UncollapsedAssigneeList,
}, },
inject: ['rootPath'],
props: { props: {
users: { users: {
type: Array, type: Array,
required: true, required: true,
}, },
issuableType: {
type: String,
required: false,
default: 'issue',
},
}, },
computed: { computed: {
assigneesText() { assigneesText() {
...@@ -36,9 +40,9 @@ export default { ...@@ -36,9 +40,9 @@ export default {
variant="link" variant="link"
@click="$emit('assign-self')" @click="$emit('assign-self')"
> >
<span class="gl-text-gray-400">{{ __('assign yourself') }}</span> <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button> </gl-button>
</div> </div>
<uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" /> <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
</div> </div>
</template> </template>
...@@ -59,7 +59,7 @@ export default { ...@@ -59,7 +59,7 @@ export default {
<div class="value hide-collapsed"> <div class="value hide-collapsed">
<template v-if="hasNoUsers"> <template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself"> <span class="assign-yourself no-value">
{{ __('None') }} {{ __('None') }}
</span> </span>
</template> </template>
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
inject: ['canUpdate'],
props: {
title: {
type: String,
required: false,
default: '',
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
edit: false,
};
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
methods: {
collapseWhenOffClick({ target }) {
if (!this.$el.contains(target)) {
this.collapse();
}
},
collapseOnEscape({ key }) {
if (key === 'Escape') {
this.collapse();
}
},
expand() {
if (this.edit) {
return;
}
this.edit = true;
this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick);
window.addEventListener('keyup', this.collapseOnEscape);
},
collapse({ emitEvent = true } = {}) {
if (!this.edit) {
return;
}
this.edit = false;
if (emitEvent) {
this.$emit('close');
}
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
toggle({ emitEvent = true } = {}) {
if (this.edit) {
this.collapse({ emitEvent });
} else {
this.expand();
}
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
<span data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
data-testid="edit-button"
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
<slot :edit="edit"></slot>
</div>
</div>
</template>
import { IssuableType } from '~/issue_show/constants';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
mutation: updateAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
},
};
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
</script> </script>
<template> <template>
<gl-dropdown class="show" :text="text" :header-text="headerText"> <gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')">
<slot name="search"></slot> <slot name="search"></slot>
<gl-dropdown-form> <gl-dropdown-form>
<slot name="items"></slot> <slot name="items"></slot>
......
query issueParticipants($id: IssueID!) {
issue(id: $id) {
participants {
nodes {
username
name
webUrl
avatarUrl
id
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: issue(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) { #import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees( issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) { ) {
issue { issue {
id
assignees { assignees {
nodes { nodes {
username ...User
id }
name }
webUrl participants {
avatarUrl nodes {
...User
} }
} }
} }
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
mergeRequest {
id
assignees {
nodes {
...User
}
}
participants {
nodes {
...User
}
}
}
}
}
...@@ -100,23 +100,6 @@ module BoardsHelper ...@@ -100,23 +100,6 @@ module BoardsHelper
} }
end end
def board_sidebar_user_data
dropdown_options = assignees_dropdown_options('issue')
{
toggle: 'dropdown',
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
project_id: @project&.id,
group_id: @group&.id,
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
'max-select': dropdown_options[:data][:'max-select']
}
end
def boards_link_text def boards_link_text
if current_board_parent.multiple_issue_boards_available? if current_board_parent.multiple_issue_boards_available?
s_("IssueBoards|Boards") s_("IssueBoards|Boards")
......
- dropdown_options = assignees_dropdown_options('issue')
.block.assignee{ ref: "assigneeBlock" } .block.assignee{ ref: "assigneeBlock" }
%template{ "v-if" => "issue.assignees" } %template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length", %sidebar-assignees-widget{ ":iid" => "String(issue.iid)",
":loading" => "loadingAssignees", ":full-path" => "issue.path.split('/-/')[0].substring(1)",
":editable" => can_admin_issue? } ":initial-assignees" => "issue.assignees",
%assignees.value{ "root-path" => "#{root_url}", ":multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})",
":users" => "issue.assignees", "@assignees-updated" => "setAssignees" }
":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" }
- if can_admin_issue?
.selectbox.hide-collapsed
%input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]",
":value" => "assignee.id",
"v-if" => "issue.assignees",
"v-for" => "assignee in issue.assignees",
":data-avatar_url" => "assignee.avatar",
":data-name" => "assignee.name",
":data-username" => "assignee.username" }
.dropdown
- dropdown_options = assignees_dropdown_options('issue')
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
= sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
= dropdown_loading
---
title: Create new assignees widget for boards
merge_request: 50054
author:
type: changed
<script> <script>
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
...@@ -9,6 +8,7 @@ import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sideb ...@@ -9,6 +8,7 @@ import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sideb
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils'; import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue'; import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
GlDrawer, GlDrawer,
BoardSidebarIssueTitle, BoardSidebarIssueTitle,
BoardSidebarEpicSelect, BoardSidebarEpicSelect,
BoardAssigneeDropdown, SidebarAssigneesWidget,
BoardSidebarTimeTracker, BoardSidebarTimeTracker,
BoardSidebarWeightInput, BoardSidebarWeightInput,
BoardSidebarLabelsSelect, BoardSidebarLabelsSelect,
...@@ -37,7 +37,11 @@ export default { ...@@ -37,7 +37,11 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['unsetActiveId']), ...mapActions(['unsetActiveId', 'setAssignees']),
updateAssignees(data) {
const assignees = data.issueSetAssignees?.issue?.assignees?.nodes || [];
this.setAssignees(assignees);
},
}, },
}; };
</script> </script>
...@@ -51,14 +55,21 @@ export default { ...@@ -51,14 +55,21 @@ export default {
> >
<template #header>{{ __('Issue details') }}</template> <template #header>{{ __('Issue details') }}</template>
<board-sidebar-issue-title /> <template #default>
<board-assignee-dropdown /> <board-sidebar-issue-title />
<board-sidebar-epic-select /> <sidebar-assignees-widget
<board-sidebar-milestone-select /> :iid="activeIssue.iid"
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> :full-path="activeIssue.referencePath.split('#')[0]"
<board-sidebar-due-date /> :initial-assignees="activeIssue.assignees"
<board-sidebar-labels-select /> @assignees-updated="updateAssignees"
<board-sidebar-weight-input v-if="glFeatures.issueWeights" /> />
<board-sidebar-subscription /> <board-sidebar-epic-select />
<board-sidebar-milestone-select />
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date />
<board-sidebar-labels-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-subscription />
</template>
</gl-drawer> </gl-drawer>
</template> </template>
...@@ -42,63 +42,68 @@ RSpec.describe 'Issue Boards', :js do ...@@ -42,63 +42,68 @@ RSpec.describe 'Issue Boards', :js do
click_card(card2) click_card(card2)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
click_link user.name
wait_for_requests page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end end
expect(page).to have_content(user.name) click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end end
expect(card2).to have_selector('.avatar') expect(card2).to have_selector('.avatar')
end end
it 'adds multiple assignees' do it 'adds multiple assignees' do
click_card(card2) click_card(card1)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
assignee = all('.gl-avatar-labeled')[1].find('.gl-avatar-labeled-label').text
page.within('.dropdown-menu-user') do page.within('.dropdown-menu-user') do
click_link user.name find('[data-testid="unassign"]').click
click_link user2.name
all('.gl-avatar-labeled')[0].click
all('.gl-avatar-labeled')[1].click
end end
expect(page).to have_content(user.name) click_button('Edit')
expect(page).to have_content(user2.name) wait_for_requests
expect(page).to have_link(nil, title: user.name)
expect(page).to have_link(nil, title: assignee)
end end
expect(card2.all('.avatar').length).to eq(2) expect(card1.all('.avatar').length).to eq(2)
end end
it 'removes the assignee' do it 'removes the assignee' do
card_two = find('.board:nth-child(2)').find('.board-card:nth-child(2)') click_card(card1)
click_card(card_two)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do page.within('.dropdown-menu-user') do
click_link 'Unassigned' find('[data-testid="unassign"]').click
end end
find('.dropdown-menu-toggle').click click_button('Edit')
wait_for_requests wait_for_requests
expect(find('.qa-assign-yourself')).to have_content('None') expect(page).to have_content('None')
end end
expect(card_two).not_to have_selector('.avatar') expect(card1).not_to have_selector('.avatar')
end end
it 'assignees to current user' do it 'assignees to current user' do
...@@ -107,7 +112,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -107,7 +112,7 @@ RSpec.describe 'Issue Boards', :js do
wait_for_requests wait_for_requests
page.within(find('.assignee')) do page.within(find('.assignee')) do
expect(find('.qa-assign-yourself')).to have_content('None') expect(page).to have_content('None')
click_button 'assign yourself' click_button 'assign yourself'
...@@ -123,17 +128,19 @@ RSpec.describe 'Issue Boards', :js do ...@@ -123,17 +128,19 @@ RSpec.describe 'Issue Boards', :js do
click_card(card2) click_card(card2)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
click_link user.name
wait_for_requests page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end end
expect(page).to have_content(user.name) click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end end
page.within(find('.board:nth-child(2)')) do page.within(find('.board:nth-child(2)')) do
...@@ -141,9 +148,9 @@ RSpec.describe 'Issue Boards', :js do ...@@ -141,9 +148,9 @@ RSpec.describe 'Issue Boards', :js do
end end
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
expect(find('.dropdown-menu')).to have_selector('.is-active') expect(find('.dropdown-menu')).to have_selector('.gl-new-dropdown-item-check-icon')
end end
end end
end end
......
...@@ -3,7 +3,6 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,7 +3,6 @@ import { shallowMount } from '@vue/test-utils';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue'; import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
...@@ -44,7 +43,8 @@ describe('ee/BoardContentSidebar', () => { ...@@ -44,7 +43,8 @@ describe('ee/BoardContentSidebar', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
store.state.sidebarType = ISSUABLE; store.state.sidebarType = ISSUABLE;
store.state.issues = { 1: { title: 'One', referencePath: 'path', assignees: [] } }; store.state.issues = { 1: { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' } };
store.state.activeIssue = { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' };
store.state.activeId = '1'; store.state.activeId = '1';
createComponent(); createComponent();
...@@ -63,10 +63,6 @@ describe('ee/BoardContentSidebar', () => { ...@@ -63,10 +63,6 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true); expect(wrapper.find(GlDrawer).props('open')).toBe(true);
}); });
it('renders BoardAssigneeDropdown', () => {
expect(wrapper.find(BoardAssigneeDropdown).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => { it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true); expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
}); });
......
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import {
issuableQueryResponse,
searchQueryResponse,
updateIssueAssigneesMutationResponse,
} from '../../mock_data';
jest.mock('~/flash');
const updateIssueAssigneesMutationSuccess = jest
.fn()
.mockResolvedValue(updateIssueAssigneesMutationResponse);
const mockError = jest.fn().mockRejectedValue('Error!');
const localVue = createLocalVue();
localVue.use(VueApollo);
const initialAssignees = [
{
id: 'some-user',
avatarUrl: 'some-user-avatar',
name: 'test',
username: 'test',
webUrl: '/test',
},
];
describe('BoardCardAssigneeDropdown', () => {
let wrapper;
let fakeApollo;
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]');
const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({
search = '',
issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse),
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {},
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[searchUsersQuery, searchQueryHandler],
[updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler],
]);
wrapper = shallowMount(SidebarAssigneesWidget, {
localVue,
apolloProvider: fakeApollo,
propsData: {
iid: '1',
fullPath: '/mygroup/myProject',
...props,
},
data() {
return {
search,
selected: [],
};
},
provide: {
canUpdate: true,
rootPath: '/',
},
stubs: {
SidebarEditableItem,
MultiSelectDropdown,
GlSearchBoxByType,
},
});
};
beforeEach(() => {
window.gon = window.gon || {};
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
window.gon.current_user_avatar_url = '/root';
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
delete window.gon.current_username;
});
describe('with passed initial assignees', () => {
it('does not show loading state when query is loading', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssigneesLoading().exists()).toBe(false);
});
it('renders an initial assignees list with initialAssignees prop', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssignees().props('users')).toEqual(initialAssignees);
});
it('renders a collapsible item title calculated with initial assignees length', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findEditableItem().props('title')).toBe('Assignee');
});
describe('when expanded', () => {
it('renders a loading spinner if participants are loading', () => {
createComponent({
props: {
initialAssignees,
},
});
expandDropdown();
expect(findParticipantsLoading().exists()).toBe(true);
});
});
});
describe('without passed initial assignees', () => {
it('shows loading state when query is loading', () => {
createComponent();
expect(findAssigneesLoading().exists()).toBe(true);
});
it('renders assignees list from API response when resolved', async () => {
createComponent();
await waitForPromises();
expect(findAssignees().props('users')).toEqual(
issuableQueryResponse.data.project.issuable.assignees.nodes,
);
});
it('renders an error when issuable query is rejected', async () => {
createComponent({
issuableQueryHandler: mockError,
});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while fetching participants.',
});
});
it('assigns current user when clicking `Assign self`', async () => {
createComponent();
await waitForPromises();
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
fullPath: '/mygroup/myProject',
iid: '1',
});
await waitForPromises();
expect(
findAssignees()
.props('users')
.some((user) => user.username === 'root'),
).toBe(true);
});
describe('when expanded', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
expandDropdown();
});
it('renders participants list with correct amount of selected and unselected', async () => {
expect(findSelectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(1);
});
it('adds an assignee when clicking on unselected user', () => {
findUnselectedParticipants().at(0).vm.$emit('click');
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: expect.arrayContaining(['root', 'francina.skiles']),
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('removes an assignee when clicking on selected user', () => {
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
});
it('shows an error if update assignees mutation is rejected', async () => {
createComponent({ updateIssueAssigneesMutationHandler: mockError });
await waitForPromises();
expandDropdown();
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
describe('when searching', () => {
it('shows loading spinner when searching for users', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('renders a list of found users', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('shows an error if search query was rejected', async () => {
createComponent({ search: 'roo', searchQueryHandler: mockError });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while searching users.',
});
});
});
});
});
...@@ -73,3 +73,120 @@ export const mockMutationResponse = { ...@@ -73,3 +73,120 @@ export const mockMutationResponse = {
}, },
}, },
}; };
export const issuableQueryResponse = {
data: {
project: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
},
assignees: {
nodes: [
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
},
},
},
},
};
export const searchQueryResponse = {
data: {
users: {
nodes: [
{
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
},
{
id: '3',
avatarUrl: '/avatar',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
},
],
},
},
};
export const updateIssueAssigneesMutationResponse = {
data: {
issueSetAssignees: {
issue: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
],
__typename: 'UserConnection',
},
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
__typename: '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',
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
__typename: 'IssueSetAssigneesPayload',
},
},
};
...@@ -3295,6 +3295,9 @@ msgstr "" ...@@ -3295,6 +3295,9 @@ msgstr ""
msgid "An error occurred while fetching markdown preview" msgid "An error occurred while fetching markdown preview"
msgstr "" msgstr ""
msgid "An error occurred while fetching participants."
msgstr ""
msgid "An error occurred while fetching pending comments" msgid "An error occurred while fetching pending comments"
msgstr "" msgstr ""
...@@ -3463,7 +3466,7 @@ msgstr "" ...@@ -3463,7 +3466,7 @@ msgstr ""
msgid "An error occurred while retrieving projects." msgid "An error occurred while retrieving projects."
msgstr "" msgstr ""
msgid "An error occurred while saving assignees" msgid "An error occurred while searching users."
msgstr "" msgstr ""
msgid "An error occurred while subscribing to notifications." msgid "An error occurred while subscribing to notifications."
......
...@@ -107,17 +107,20 @@ RSpec.describe 'Issue Boards', :js do ...@@ -107,17 +107,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card) click_card(card)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
click_link user.name
wait_for_requests page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end end
expect(page).to have_content(user.name) click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end end
expect(card).to have_selector('.avatar') expect(card).to have_selector('.avatar')
...@@ -128,15 +131,15 @@ RSpec.describe 'Issue Boards', :js do ...@@ -128,15 +131,15 @@ RSpec.describe 'Issue Boards', :js do
click_card(card_two) click_card(card_two)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do page.within('.dropdown-menu-user') do
click_link 'Unassigned' find('[data-testid="unassign"]').click
end end
close_dropdown_menu_if_visible click_button('Edit')
wait_for_requests wait_for_requests
expect(page).to have_content('None') expect(page).to have_content('None')
...@@ -165,17 +168,20 @@ RSpec.describe 'Issue Boards', :js do ...@@ -165,17 +168,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card) click_card(card)
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
wait_for_requests wait_for_requests
page.within('.dropdown-menu-user') do assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
click_link user.name
wait_for_requests page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end end
expect(page).to have_content(user.name) click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end end
page.within(find('.board:nth-child(2)')) do page.within(find('.board:nth-child(2)')) do
...@@ -183,9 +189,9 @@ RSpec.describe 'Issue Boards', :js do ...@@ -183,9 +189,9 @@ RSpec.describe 'Issue Boards', :js do
end end
page.within('.assignee') do page.within('.assignee') do
click_link 'Edit' click_button('Edit')
expect(find('.dropdown-menu')).to have_selector('.is-active') expect(find('.dropdown-menu')).to have_selector('.gl-new-dropdown-item-check-icon')
end end
end end
end end
......
...@@ -11,8 +11,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql' ...@@ -11,8 +11,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
import actions, { gqlClient } from '~/boards/stores/actions'; import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types'; import * as types from '~/boards/stores/mutation_types';
import createFlash from '~/flash';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import { import {
mockLists, mockLists,
mockListsById, mockListsById,
...@@ -726,65 +724,27 @@ describe('moveIssue', () => { ...@@ -726,65 +724,27 @@ describe('moveIssue', () => {
describe('setAssignees', () => { describe('setAssignees', () => {
const node = { username: 'name' }; const node = { username: 'name' };
const name = 'username';
const projectPath = 'h/h'; const projectPath = 'h/h';
const refPath = `${projectPath}#3`; const refPath = `${projectPath}#3`;
const iid = '1'; const iid = '1';
describe('when succeeds', () => { describe('when succeeds', () => {
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } },
});
});
it('calls mutate with the correct values', async () => {
await actions.setAssignees(
{ commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } },
[name],
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
mutation: updateAssignees,
variables: { iid, assigneeUsernames: [name], projectPath },
});
});
it('calls the correct mutation with the correct values', (done) => { it('calls the correct mutation with the correct values', (done) => {
testAction( testAction(
actions.setAssignees, actions.setAssignees,
{}, [node],
{ activeIssue: { iid, referencePath: refPath }, commit: () => {} }, { activeIssue: { iid, referencePath: refPath }, commit: () => {} },
[ [
{ type: types.SET_ASSIGNEE_LOADING, payload: true },
{ {
type: 'UPDATE_ISSUE_BY_ID', type: 'UPDATE_ISSUE_BY_ID',
payload: { prop: 'assignees', issueId: undefined, value: [node] }, payload: { prop: 'assignees', issueId: undefined, value: [node] },
}, },
{ type: types.SET_ASSIGNEE_LOADING, payload: false },
], ],
[], [],
done, done,
); );
}); });
}); });
describe('when fails', () => {
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockRejectedValue();
});
it('calls createFlash', async () => {
await actions.setAssignees({
commit: () => {},
getters: { activeIssue: { iid, referencePath: refPath } },
});
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
});
}); });
describe('createNewIssue', () => { describe('createNewIssue', () => {
......
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
describe('boards sidebar remove issue', () => {
let wrapper;
const findLoader = () => wrapper.find(GlLoadingIcon);
const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findTitle = () => wrapper.find('[data-testid="title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findExpanded = () => wrapper.find('[data-testid="expanded-content"]');
const createComponent = ({ props = {}, slots = {}, canUpdate = false } = {}) => {
wrapper = shallowMount(SidebarEditableItem, {
attachTo: document.body,
provide: { canUpdate },
propsData: props,
slots,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('template', () => {
it('renders title', () => {
const title = 'Sidebar item title';
createComponent({ props: { title } });
expect(findTitle().text()).toBe(title);
});
it('hides edit button, loader and expanded content by default', () => {
createComponent();
expect(findEditButton().exists()).toBe(false);
expect(findLoader().exists()).toBe(false);
expect(findExpanded().isVisible()).toBe(false);
});
it('shows "None" if empty collapsed slot', () => {
createComponent();
expect(findCollapsed().text()).toBe('None');
});
it('renders collapsed content by default', () => {
const slots = { collapsed: '<div>Collapsed content</div>' };
createComponent({ slots });
expect(findCollapsed().text()).toBe('Collapsed content');
});
it('shows edit button if can update', () => {
createComponent({ canUpdate: true });
expect(findEditButton().exists()).toBe(true);
});
it('shows loading icon if loading', () => {
createComponent({ props: { loading: true } });
expect(findLoader().exists()).toBe(true);
});
it('shows expanded content and hides collapsed content when clicking edit button', async () => {
const slots = { default: '<div>Select item</div>' };
createComponent({ canUpdate: true, slots });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick;
expect(findCollapsed().isVisible()).toBe(false);
expect(findExpanded().isVisible()).toBe(true);
});
});
describe('collapsing an item by offclicking', () => {
beforeEach(async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('hides expanded section and displays collapsed section', async () => {
expect(findExpanded().isVisible()).toBe(true);
document.body.click();
await wrapper.vm.$nextTick();
expect(findCollapsed().isVisible()).toBe(true);
expect(findExpanded().isVisible()).toBe(false);
});
});
it('emits open when edit button is clicked and edit is initailized to false', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted().open.length).toBe(1);
});
it('does not emits events when collapsing with false `emitEvent`', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
wrapper.vm.collapse({ emitEvent: false });
expect(wrapper.emitted().close).toBeUndefined();
});
});
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