summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-02-17 09:09:36 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-02-17 09:09:36 +0000
commit839e879bcf197a283da8481ddcb15b177172784d (patch)
treebf2b1e0b27c98340d194469a4b3a5e02d4a2acb8 /app/assets/javascripts
parent3c97422b098235bca250f738922dab9c861f0ee7 (diff)
downloadgitlab-ce-839e879bcf197a283da8481ddcb15b177172784d.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue202
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js48
-rw-r--r--app/assets/javascripts/boards/index.js2
-rw-r--r--app/assets/javascripts/boards/models/issue.js4
-rw-r--r--app/assets/javascripts/boards/stores/actions.js37
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue13
-rw-r--r--app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue15
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue403
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue95
-rw-r--r--app/assets/javascripts/sidebar/constants.js16
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js24
-rw-r--r--app/assets/javascripts/users_select/index.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql (renamed from app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql)18
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql21
23 files changed, 667 insertions, 322 deletions
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
deleted file mode 100644
index 59d91f4ae72..00000000000
--- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
+++ /dev/null
@@ -1,202 +0,0 @@
-<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>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index f303c45a40e..6d5a13be3ac 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -7,7 +7,6 @@ import { GlLabel } from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
import DueDateSelectors from '~/due_date_select';
-import { deprecatedCreateFlash as Flash } from '~/flash';
import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -16,6 +15,7 @@ import MilestoneSelect from '~/milestone_select';
import Sidebar from '~/right_sidebar';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.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 TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub';
@@ -32,6 +32,7 @@ export default Vue.extend({
RemoveBtn,
Subscriptions,
TimeTracker,
+ SidebarAssigneesWidget,
},
props: {
currentUser: {
@@ -78,12 +79,6 @@ export default Vue.extend({
detail: {
handler() {
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) => {
$(el).data('deprecatedJQueryDropdown').clearMenu();
});
@@ -96,18 +91,9 @@ export default Vue.extend({
},
},
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);
},
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);
},
mounted() {
@@ -121,34 +107,8 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
- assignSelf() {
- // Notify gl dropdown that we are now assigning to current user
- 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'));
- });
+ setAssignees(data) {
+ boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 15f7e2191a2..859295318ed 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -86,7 +86,7 @@ export default () => {
groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null,
- canUpdate: $boardApp.dataset.canUpdate,
+ canUpdate: parseBoolean($boardApp.dataset.canUpdate),
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index bc23639df67..46d1239457d 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -53,6 +53,10 @@ class ListIssue {
return boardsStore.findIssueAssignee(this, findAssignee);
}
+ setAssignees(assignees) {
+ boardsStore.setIssueAssignees(this, assignees);
+ }
+
removeAssignee(removeAssignee) {
boardsStore.removeIssueAssignee(this, removeAssignee);
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index e2d07f9128c..a7cf1e9e647 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,13 +1,9 @@
import { pick } from 'lodash';
-
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
-import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
-import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import {
formatBoardLists,
formatListIssues,
@@ -333,34 +329,11 @@ export default {
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
- commit(types.SET_ASSIGNEE_LOADING, true);
-
- return gqlClient
- .mutate({
- 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);
- });
+ commit('UPDATE_ISSUE_BY_ID', {
+ issueId: getters.activeIssue.id,
+ prop: 'assignees',
+ value: assigneeUsernames,
+ });
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 39d95552084..fbff736c7e1 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -724,6 +724,10 @@ const boardsStore = {
}
},
+ setIssueAssignees(issue, assignees) {
+ issue.assignees = [...assignees];
+ },
+
removeIssueLabels(issue, labels) {
labels.forEach(issue.removeLabel.bind(issue));
},
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 0f2afb779fd..c73c8fb6ca4 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -347,15 +347,20 @@ export default {
>
<header
v-if="showToolbar"
- class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex"
+ class="row-content-block gl-border-t-0 gl-py-3 gl-display-flex"
data-testid="design-toolbar-wrapper"
>
- <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full">
- <div>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-my-2">
<span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
<design-version-dropdown />
</div>
- <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex gl-align-items-center">
+ <div
+ v-show="hasDesigns"
+ class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
+ >
<gl-button
v-if="isLatestVersion"
variant="link"
diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
index fe01d2c2e78..42e646391a8 100644
--- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
+++ b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
@@ -3,6 +3,7 @@
query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
+ id
assignees {
nodes {
...Author
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 5dc1ab90e00..c3c009e680a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -11,10 +11,6 @@ export default {
UncollapsedAssigneeList,
},
props: {
- rootPath: {
- type: String,
- required: true,
- },
users: {
type: Array,
required: true,
@@ -51,9 +47,9 @@ export default {
<div>
<collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
- <div class="value hide-collapsed">
+ <div data-testid="expanded-assignee" class="value hide-collapsed">
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value qa-assign-yourself">
+ <span class="assign-yourself no-value">
{{ __('None') }}
<template v-if="editable">
-
@@ -64,12 +60,7 @@ export default {
</span>
</template>
- <uncollapsed-assignee-list
- v-else
- :users="sortedAssigness"
- :root-path="rootPath"
- :issuable-type="issuableType"
- />
+ <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 3c1b3afe889..e2dc37a0ac2 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -8,12 +8,16 @@ export default {
GlButton,
UncollapsedAssigneeList,
},
- inject: ['rootPath'],
props: {
users: {
type: Array,
required: true,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
},
computed: {
assigneesText() {
@@ -36,9 +40,9 @@ export default {
variant="link"
@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>
</div>
- <uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" />
+ <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 9b0d2228395..6595debf9a5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -44,6 +44,11 @@ export default {
type: String,
required: true,
},
+ assigneeAvailabilityStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -101,6 +106,13 @@ export default {
return new Flash(__('Error occurred when saving assignees'));
});
},
+ exposeAvailabilityStatus(users) {
+ return users.map(({ username, ...rest }) => ({
+ ...rest,
+ username,
+ availability: this.assigneeAvailabilityStatus[username] || '',
+ }));
+ },
},
};
</script>
@@ -123,7 +135,7 @@ export default {
<assignees
v-if="!store.isFetching.assignees"
:root-path="relativeUrlRoot"
- :users="store.assignees"
+ :users="exposeAvailabilityStatus(store.assignees)"
:editable="store.editable"
:issuable-type="issuableType"
class="value"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
new file mode 100644
index 00000000000..8f3f77cb5f0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -0,0 +1,403 @@
+<script>
+import {
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import Vue from 'vue';
+import createFlash from '~/flash';
+import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
+import { IssuableType } from '~/issue_show/constants';
+import { __, n__ } from '~/locale';
+import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { assigneesQueries } from '~/sidebar/constants';
+import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+
+export const assigneesWidget = Vue.observable({
+ updateAssignees: null,
+});
+
+export default {
+ i18n: {
+ unassigned: __('Unassigned'),
+ assignee: __('Assignee'),
+ assignees: __('Assignees'),
+ assignTo: __('Assign to'),
+ },
+ assigneesQueries,
+ components: {
+ SidebarEditableItem,
+ IssuableAssignees,
+ MultiSelectDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ },
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ initialAssignees: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: IssuableType.Issue,
+ validator(value) {
+ return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ },
+ },
+ multipleAssignees: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ search: '',
+ issuable: {},
+ searchUsers: [],
+ selected: [],
+ isSettingAssignees: false,
+ isSearching: false,
+ };
+ },
+ apollo: {
+ issuable: {
+ query() {
+ return this.$options.assigneesQueries[this.issuableType].query;
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.issuable || data.project?.issuable;
+ },
+ result({ data }) {
+ const issuable = data.issuable || data.project?.issuable;
+ if (issuable) {
+ this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
+ }
+ },
+ error() {
+ createFlash({ message: __('An error occurred while fetching participants.') });
+ },
+ },
+ searchUsers: {
+ query: searchUsers,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.users?.nodes || [];
+ },
+ debounce: 250,
+ skip() {
+ return this.isSearchEmpty;
+ },
+ error() {
+ createFlash({ message: __('An error occurred while searching users.') });
+ this.isSearching = false;
+ },
+ result() {
+ this.isSearching = false;
+ },
+ },
+ },
+ computed: {
+ queryVariables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ assignees() {
+ const currentAssignees = this.$apollo.queries.issuable.loading
+ ? this.initialAssignees
+ : this.issuable?.assignees?.nodes;
+ return currentAssignees || [];
+ },
+ participants() {
+ const users =
+ this.isSearchEmpty || this.isSearching
+ ? this.issuable?.participants?.nodes
+ : this.searchUsers;
+ return this.moveCurrentUserToStart(users);
+ },
+ assigneeText() {
+ const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
+ return n__('Assignee', '%d Assignees', items.length);
+ },
+ selectedFiltered() {
+ if (this.isSearchEmpty || this.isSearching) {
+ return this.selected;
+ }
+
+ const foundUsernames = this.searchUsers.map(({ username }) => username);
+ return this.selected.filter(({ username }) => foundUsernames.includes(username));
+ },
+ unselectedFiltered() {
+ return (
+ this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) ||
+ []
+ );
+ },
+ selectedIsEmpty() {
+ return this.selectedFiltered.length === 0;
+ },
+ selectedUserNames() {
+ return this.selected.map(({ username }) => username);
+ },
+ isSearchEmpty() {
+ return this.search === '';
+ },
+ currentUser() {
+ return {
+ username: gon?.current_username,
+ name: gon?.current_user_fullname,
+ avatarUrl: gon?.current_user_avatar_url,
+ };
+ },
+ isAssigneesLoading() {
+ return !this.initialAssignees && this.$apollo.queries.issuable.loading;
+ },
+ isCurrentUserInParticipants() {
+ const isCurrentUser = (user) => user.username === this.currentUser.username;
+ return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
+ },
+ noUsersFound() {
+ return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
+ },
+ showCurrentUser() {
+ return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
+ },
+ },
+ watch: {
+ // We need to add this watcher to track the moment when user is alredy typing
+ // but query is still not started due to debounce
+ search(newVal) {
+ if (newVal) {
+ this.isSearching = true;
+ }
+ },
+ },
+ created() {
+ assigneesWidget.updateAssignees = this.updateAssignees;
+ },
+ destroyed() {
+ assigneesWidget.updateAssignees = null;
+ },
+ methods: {
+ updateAssignees(assigneeUsernames) {
+ this.isSettingAssignees = true;
+ return this.$apollo
+ .mutate({
+ mutation: this.$options.assigneesQueries[this.issuableType].mutation,
+ variables: {
+ ...this.queryVariables,
+ assigneeUsernames,
+ },
+ })
+ .then(({ data }) => {
+ this.$emit('assignees-updated', data);
+ return data;
+ })
+ .catch(() => {
+ createFlash({ message: __('An error occurred while updating assignees.') });
+ })
+ .finally(() => {
+ this.isSettingAssignees = false;
+ });
+ },
+ selectAssignee(name) {
+ if (name === undefined) {
+ this.clearSelected();
+ return;
+ }
+
+ if (!this.multipleAssignees) {
+ this.selected = [name];
+ this.collapseWidget();
+ } else {
+ this.selected = this.selected.concat(name);
+ }
+ },
+ unselect(name) {
+ this.selected = this.selected.filter((user) => user.username !== name);
+
+ if (!this.multipleAssignees) {
+ this.collapseWidget();
+ }
+ },
+ assignSelf() {
+ this.updateAssignees(this.currentUser.username);
+ },
+ clearSelected() {
+ this.selected = [];
+ },
+ saveAssignees() {
+ this.updateAssignees(this.selectedUserNames);
+ },
+ isChecked(id) {
+ return this.selectedUserNames.includes(id);
+ },
+ async focusSearch() {
+ await this.$nextTick();
+ this.$refs.search.focusInput();
+ },
+ moveCurrentUserToStart(users) {
+ if (!users) {
+ return [];
+ }
+ const usersCopy = [...users];
+ const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ if (currentUser) {
+ const index = usersCopy.indexOf(currentUser);
+ usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ }
+
+ return usersCopy;
+ },
+ collapseWidget() {
+ this.$refs.toggle.collapse();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="isAssigneesLoading"
+ class="gl-display-flex gl-align-items-center assignee"
+ data-testid="loading-assignees"
+ >
+ {{ __('Assignee') }}
+ <gl-loading-icon size="sm" class="gl-ml-2" />
+ </div>
+ <sidebar-editable-item
+ v-else
+ ref="toggle"
+ :loading="isSettingAssignees"
+ :title="assigneeText"
+ @open="focusSearch"
+ @close="saveAssignees"
+ >
+ <template #collapsed>
+ <issuable-assignees
+ :users="assignees"
+ :issuable-type="issuableType"
+ @assign-self="assignSelf"
+ />
+ </template>
+
+ <template #default>
+ <multi-select-dropdown
+ class="gl-w-full dropdown-menu-user"
+ :text="$options.i18n.assignees"
+ :header-text="$options.i18n.assignTo"
+ @toggle="collapseWidget"
+ >
+ <template #search>
+ <gl-search-box-by-type ref="search" v-model.trim="search" />
+ </template>
+ <template #items>
+ <gl-loading-icon
+ v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
+ data-testid="loading-participants"
+ size="lg"
+ />
+ <template v-else>
+ <template v-if="isSearchEmpty || isSearching">
+ <gl-dropdown-item
+ :is-checked="selectedIsEmpty"
+ :is-check-centered="true"
+ data-testid="unassign"
+ @click="selectAssignee()"
+ >
+ <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{
+ $options.i18n.unassigned
+ }}</span></gl-dropdown-item
+ >
+ <gl-dropdown-divider data-testid="unassign-divider" />
+ </template>
+ <gl-dropdown-item
+ v-for="item in selectedFiltered"
+ :key="item.id"
+ :is-checked="isChecked(item.username)"
+ :is-check-centered="true"
+ data-testid="selected-participant"
+ @click.stop="unselect(item.username)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="item.name"
+ :sub-label="item.username"
+ :src="item.avatarUrl || item.avatar || item.avatar_url"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
+ <template v-if="showCurrentUser">
+ <gl-dropdown-item
+ data-testid="unselected-participant"
+ @click.stop="selectAssignee(currentUser)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="currentUser.name"
+ :sub-label="currentUser.username"
+ :src="currentUser.avatarUrl"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-item
+ v-for="unselectedUser in unselectedFiltered"
+ :key="unselectedUser.id"
+ data-testid="unselected-participant"
+ @click="selectAssignee(unselectedUser)"
+ >
+ <gl-avatar-link class="gl-pl-6!">
+ <gl-avatar-labeled
+ :size="32"
+ :label="unselectedUser.name"
+ :sub-label="unselectedUser.username"
+ :src="unselectedUser.avatarUrl || unselectedUser.avatar"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="noUsersFound && !isSearching">
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </template>
+ </multi-select-dropdown>
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index 00a2456c6b6..2c52d7142f7 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -59,7 +59,7 @@ export default {
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value qa-assign-yourself">
+ <span class="assign-yourself no-value">
{{ __('None') }}
</span>
</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
new file mode 100644
index 00000000000..9da839cd133
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -0,0 +1,95 @@
+<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>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
new file mode 100644
index 00000000000..274aa237aea
--- /dev/null
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -0,0 +1,16 @@
+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,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index bc019f09c85..662edbc4f8d 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -30,6 +30,28 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
+/**
+ * Extracts the list of assignees with availability information from a hidden input
+ * field and converts to a key:value pair for use in the sidebar assignees component.
+ * The assignee username is used as the key and their busy status is the value
+ *
+ * e.g { root: 'busy', admin: '' }
+ *
+ * @returns {Object}
+ */
+function getSidebarAssigneeAvailabilityData() {
+ const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
+ return Array.from(sidebarAssigneeEl)
+ .map((el) => el.dataset)
+ .reduce(
+ (acc, { username, availability = '' }) => ({
+ ...acc,
+ [username]: availability,
+ }),
+ {},
+ );
+}
+
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
@@ -39,6 +61,7 @@ function mountAssigneesComponent(mediator) {
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
+ const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
el,
@@ -56,6 +79,7 @@ function mountAssigneesComponent(mediator) {
signedIn: el.hasAttribute('data-signed-in'),
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
+ assigneeAvailabilityStatus,
},
}),
});
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 951f512b784..e1a4a74b982 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -9,6 +9,7 @@ import {
AJAX_USERS_SELECT_PARAMS_MAP,
} from 'ee_else_ce/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
import ModalStore from '../boards/stores/modal_store';
import axios from '../lib/utils/axios_utils';
@@ -795,13 +796,17 @@ UsersSelect.prototype.renderRow = function (
? `data-container="body" data-placement="left" data-title="${tooltip}"`
: '';
+ const name =
+ user?.availability && isUserBusy(user.availability)
+ ? sprintf(__('%{name} (Busy)'), { name: user.name })
+ : user.name;
return `
<li data-user-id=${user.id}>
<a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
- ${escape(user.name)}
+ ${escape(name)}
</strong>
${
username
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index c5bbe1b33fb..132abcab82b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -20,7 +20,7 @@ export default {
</script>
<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>
<gl-dropdown-form>
<slot name="items"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
deleted file mode 100644
index 612a0c02e82..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-query issueParticipants($id: IssueID!) {
- issue(id: $id) {
- participants {
- nodes {
- username
- name
- webUrl
- avatarUrl
- id
- }
- }
- }
-}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
new file mode 100644
index 00000000000..62c0b05426b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -0,0 +1,19 @@
+#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
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
new file mode 100644
index 00000000000..a75ce85a1dc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -0,0 +1,19 @@
+#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
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 9ead95a3801..2eb9bb4b07b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,15 +1,19 @@
-mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees(
- input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
issue {
+ id
assignees {
nodes {
- username
- id
- name
- webUrl
- avatarUrl
+ ...User
+ }
+ }
+ participants {
+ nodes {
+ ...User
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
new file mode 100644
index 00000000000..a0f15a07692
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -0,0 +1,21 @@
+#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
+ }
+ }
+ }
+ }
+}