diff options
Diffstat (limited to 'app/assets/javascripts/sidebar/components/assignees')
10 files changed, 556 insertions, 45 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index fbbe2e341a7..d0a65b48522 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,8 +1,46 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; +const I18N = { + BUSY: __('Busy'), + CANNOT_MERGE: __('Cannot merge'), + LC_CANNOT_MERGE: __('cannot merge'), +}; + +const paranthesize = (str) => `(${str})`; + +const generateAssigneeTooltip = ({ + name, + availability, + cannotMerge = true, + tooltipHasName = false, +}) => { + if (!tooltipHasName) { + return cannotMerge ? I18N.CANNOT_MERGE : ''; + } + + const statusInformation = []; + if (availability && isUserBusy(availability)) { + statusInformation.push(I18N.BUSY); + } + + if (cannotMerge) { + statusInformation.push(I18N.LC_CANNOT_MERGE); + } + + if (tooltipHasName && statusInformation.length) { + return sprintf(__('%{name} %{status}'), { + name, + status: statusInformation.map(paranthesize).join(' '), + }); + } + + return name; +}; + export default { components: { AssigneeAvatar, @@ -37,15 +75,13 @@ export default { return this.issuableType === 'merge_request' && !this.user.can_merge; }, tooltipTitle() { - if (this.cannotMerge && this.tooltipHasName) { - return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); - } else if (this.cannotMerge) { - return __('Cannot merge'); - } else if (this.tooltipHasName) { - return this.user.name; - } - - return ''; + const { name = '', availability = '' } = this.user; + return generateAssigneeTooltip({ + name, + availability, + cannotMerge: this.cannotMerge, + tooltipHasName: this.tooltipHasName, + }); }, tooltipOption() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 84e7110e2b2..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, @@ -36,7 +32,6 @@ export default { sortedAssigness() { const canMergeUsers = this.users.filter((user) => user.can_merge); const canNotMergeUsers = this.users.filter((user) => !user.can_merge); - return [...canMergeUsers, ...canNotMergeUsers]; }, }, @@ -52,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"> - @@ -65,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/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index 0eee287e0c2..ca86d6c6c3e 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> +import actionCable from '~/actioncable_consumer'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; -import actionCable from '~/actioncable_consumer'; export default { subscription: null, diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue index 2f654409561..af4227fa48d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -1,9 +1,11 @@ <script> import AssigneeAvatar from './assignee_avatar.vue'; +import UserNameWithStatus from './user_name_with_status.vue'; export default { components: { AssigneeAvatar, + UserNameWithStatus, }, props: { user: { @@ -16,12 +18,20 @@ export default { default: 'issue', }, }, + computed: { + availability() { + return this.user?.availability || ''; + }, + }, }; </script> - <template> <button type="button" class="btn-link"> <assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> - <span class="author"> {{ user.name }} </span> + <user-name-with-status + :name="user.name" + :availability="availability" + container-classes="author" + /> </button> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index b713b0f960c..20667e695ce 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -1,11 +1,30 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import { isUserBusy } from '~/set_status_modal/utils'; import CollapsedAssignee from './collapsed_assignee.vue'; const DEFAULT_MAX_COUNTER = 99; const DEFAULT_RENDER_COUNT = 5; +const generateCollapsedAssigneeTooltip = ({ renderUsers, allUsers, tooltipTitleMergeStatus }) => { + const names = renderUsers.map(({ name, availability }) => { + if (availability && isUserBusy(availability)) { + return sprintf(__('%{name} (Busy)'), { name }); + } + return name; + }); + + if (!allUsers.length) { + return __('Assignee(s)'); + } + if (allUsers.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: allUsers.length - names.length })); + } + const text = names.join(', '); + return tooltipTitleMergeStatus ? `${text} (${tooltipTitleMergeStatus})` : text; +}; + export default { directives: { GlTooltip: GlTooltipDirective, @@ -74,19 +93,11 @@ export default { tooltipTitle() { const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map((u) => u.name); - - if (!this.users.length) { - return __('Assignee(s)'); - } - - if (this.users.length > names.length) { - names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); - } - - const text = names.join(', '); - - return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + return generateCollapsedAssigneeTooltip({ + renderUsers, + allUsers: this.users, + tooltipTitleMergeStatus: this.tooltipTitleMergeStatus, + }); }, tooltipOptions() { 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 b9f268629fb..6595debf9a5 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,13 +1,13 @@ <script> +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; import AssigneesRealtime from './assignees_realtime.vue'; -import { __ } from '~/locale'; export default { name: 'SidebarAssignees', @@ -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/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 31d5d7c0077..36775648809 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,12 +1,14 @@ <script> import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; +import UserNameWithStatus from './user_name_with_status.vue'; const DEFAULT_RENDER_COUNT = 5; export default { components: { AssigneeAvatarLink, + UserNameWithStatus, }, props: { users: { @@ -55,6 +57,9 @@ export default { toggleShowLess() { this.showLess = !this.showLess; }, + userAvailability(u) { + return u?.availability || ''; + }, }, }; </script> @@ -68,7 +73,7 @@ export default { :issuable-type="issuableType" > <div class="ml-2 gl-line-height-normal"> - <div>{{ firstUser.name }}</div> + <user-name-with-status :name="firstUser.name" :availability="userAvailability(firstUser)" /> <div>{{ username }}</div> </div> </assignee-avatar-link> diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue new file mode 100644 index 00000000000..41b3b6c9a45 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue @@ -0,0 +1,40 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { isUserBusy } from '~/set_status_modal/utils'; + +export default { + name: 'UserNameWithStatus', + components: { + GlSprintf, + }, + props: { + name: { + type: String, + required: true, + }, + containerClasses: { + type: String, + required: false, + default: '', + }, + availability: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isBusy() { + return isUserBusy(this.availability); + }, + }, +}; +</script> +<template> + <span :class="containerClasses"> + <gl-sprintf v-if="isBusy" :message="s__('UserAvailability|%{author} (Busy)')"> + <template #author>{{ name }}</template> + </gl-sprintf> + <template v-else>{{ name }}</template> + </span> +</template> |