Commit 33c12b9a authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Nicolò Maria Mezzopera

Created the assignees widget

Revert "Created the first exposed method"

This reverts commit 097a584f99be3aad4c793133684183c43a029a3d.
Replaced search query with shared one

Abstracted parent entity assignees

Abstracted setting assignees

Abstracted widget in shared component

- reverted changes to BoardAssigneesDropDown
- fixed a few styles
Added hardcoded widget to issue

Passed correct injections

Added a watcher to hande initial state

Fixed participants loading state

Finished issue sidebar

Renamed issue assignees queries

- changed issue and mutation name to snake_case
Connected a correct query for MR

Added MR assignees mutation

Fixed update for MRs

Fixed MR avatarUrl casing

Added an exposed method and demo

Changed assignees to participants

Removed rebase artifacts

Apply 1 suggestion(s) to 1 file(s)
Started widget refactoring

Fixed sidebar issue mutation

Added ID to queries

Added fixes for MR assignees

Adjusted the code to swimlanes

Exposed assignees state

Nullified observable on destroyed

Fixed a race condition

Added a fallback with initial assignees

Reverted changes with assinees state

Silenced loading state with initial assignees

Splited widget for CE and EE

Fixed emitted event parameter

Passed issuable type to assignees component

Uncommented fetch policies
Added default slot directive
Removed board_assignees_dropdown
Fixed actions spec
Added temporary translation

Fixed sidebar assignees spec
Fixed an import

Removed doubled imports

Refactored a component to have a mixin

Removed changed to Noteable note

Removed sidebar widget for issues and MRs

Fixed linter errors

Reverted changes to sidebar assignees spec

Refactored widget to use issuable types

- moved issuable types queries to constants
- changed query variables
Added a widget to board sidebar

Fixed project path to be calculated from issue

Fixed board to use widget

Fixed assignees text

Fixed board sidebar spec

Fixed translations file

Fixed unassigning and assign self

Fixed feature spec for boards

Fixed ee board spec

Fixed ee sidebar spec
Created tests for widget

Added tests for initial prop

Fixed initial assignees tests

Added spec for fetching assignees

Added todos for tests

Added error handling and test

Added a test for mutation

Fixed loading participants test

Added tests for selected and unselected

Added tests for selecting

Tested a mutation error

Fixed finder helpers

Finished test abstraction

Regenerated a translation file
Added participants to mutation responses

Updated a test with successful mutation

Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Added issuable type import

Fixed computed property and validator

Removed unnecessary root path injection

Added changelog entry

Fixed a spec

Apply 1 suggestion(s) to 1 file(s)
Added searchUsers to data

Apply 1 suggestion(s) to 1 file(s)
Renamed assigneesWidget exposed state

Removed an unused method from boards helper

Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Fixed a default value for createComponent

Removed jquery code for dropdown

Added a logic for escape button

Added focusing a search
parent 64b3494c
<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 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') }}
<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