diff options
| author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-17 09:09:36 +0000 |
|---|---|---|
| committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-17 09:09:36 +0000 |
| commit | 839e879bcf197a283da8481ddcb15b177172784d (patch) | |
| tree | bf2b1e0b27c98340d194469a4b3a5e02d4a2acb8 /app/assets/javascripts | |
| parent | 3c97422b098235bca250f738922dab9c861f0ee7 (diff) | |
| download | gitlab-ce-839e879bcf197a283da8481ddcb15b177172784d.tar.gz | |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
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 + } + } + } + } +} |
