diff options
Diffstat (limited to 'app/assets/javascripts/work_items/components/work_item_assignees_inline.vue')
-rw-r--r-- | app/assets/javascripts/work_items/components/work_item_assignees_inline.vue | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees_inline.vue b/app/assets/javascripts/work_items/components/work_item_assignees_inline.vue new file mode 100644 index 00000000000..a9aafbb3d84 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_assignees_inline.vue @@ -0,0 +1,404 @@ +<script> +import { + GlTokenSelector, + GlIcon, + GlAvatar, + GlLink, + GlSkeletonLoader, + GlButton, + GlDropdownItem, + GlDropdownDivider, + GlIntersectionObserver, +} from '@gitlab/ui'; +import { debounce, uniqueId } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; +import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; +import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { n__, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants'; + +function isTokenSelectorElement(el) { + return ( + el?.classList.contains('gl-token-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; +} + +export default { + components: { + GlTokenSelector, + GlIcon, + GlAvatar, + GlLink, + GlSkeletonLoader, + GlButton, + SidebarParticipant, + InviteMembersTrigger, + GlDropdownItem, + GlDropdownDivider, + GlIntersectionObserver, + }, + mixins: [Tracking.mixin()], + inject: ['isGroup'], + props: { + fullPath: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + assignees: { + type: Array, + required: true, + }, + allowsMultipleAssignees: { + type: Boolean, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + canInviteMembers: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isEditing: false, + searchStarted: false, + localAssignees: this.assignees.map(addClass), + searchKey: '', + users: { + nodes: [], + }, + currentUser: null, + isLoadingMore: false, + }; + }, + apollo: { + users: { + query() { + return this.isGroup ? groupUsersSearchQuery : usersSearchQuery; + }, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.users; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + currentUser: { + query: currentUserQuery, + }, + }, + computed: { + assigneesTitleId() { + return uniqueId('assignees-title-'); + }, + deduplicatedUsers() { + return this.users.nodes.reduce((acc, current) => { + if (!acc.find((node) => node.user.id === current.user.id)) { + acc.push(current); + } + return acc; + }, []); + }, + searchUsers() { + return this.deduplicatedUsers.map((node) => addClass({ ...node, ...node.user })); + }, + pageInfo() { + return this.users.pageInfo; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_assignees', + property: `type_${this.workItemType}`, + }; + }, + containerClass() { + return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : ''; + }, + isLoadingUsers() { + return this.$apollo.queries.users.loading; + }, + assigneeText() { + return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); + }, + dropdownItems() { + if (this.currentUser && this.searchEmpty) { + if (this.searchUsers.some((user) => user.username === this.currentUser.username)) { + return this.moveCurrentUserToStart(this.searchUsers); + } + return [addClass(this.currentUser), ...this.searchUsers]; + } + return this.searchUsers; + }, + searchEmpty() { + return this.searchKey.length === 0; + }, + addAssigneesText() { + if (!this.canUpdate) { + return s__('WorkItem|None'); + } + return this.allowsMultipleAssignees + ? s__('WorkItem|Add assignees') + : s__('WorkItem|Add assignee'); + }, + assigneeIds() { + return this.localAssignees.map(({ id }) => id); + }, + hasNextPage() { + return this.pageInfo?.hasNextPage; + }, + showIntersectionSkeletonLoader() { + return this.isLoadingMore && this.dropdownItems.length; + }, + }, + watch: { + assignees: { + handler(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + deep: true, + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getUserId(id) { + return getIdFromGraphQLId(id); + }, + handleAssigneesInput(assignees) { + if (!this.allowsMultipleAssignees) { + this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; + this.isEditing = false; + this.setAssignees(this.assigneeIds); + return; + } + this.localAssignees = assignees; + this.focusTokenSelector(); + }, + handleBlur(e) { + if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; + this.isEditing = false; + this.setAssignees(this.assigneeIds); + }, + async setAssignees(assigneeIds) { + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneesWidget: { + assigneeIds, + }, + }, + }, + }); + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + this.track('updated_assignees'); + } catch { + this.throwUpdateError(); + } + }, + handleFocus() { + this.isEditing = true; + this.searchStarted = true; + }, + async fetchMoreAssignees() { + this.isLoadingMore = true; + await this.$apollo.queries.users.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + }, + }); + this.isLoadingMore = false; + }, + async focusTokenSelector() { + this.handleFocus(); + await this.$nextTick(); + this.$refs.tokenSelector.focusTextInput(); + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + moveCurrentUserToStart(users = []) { + if (this.currentUser) { + return [ + addClass(this.currentUser), + ...users.filter((user) => user.id !== this.currentUser.id), + ]; + } + return users; + }, + closeDropdown() { + this.$refs.tokenSelector.closeDropdown(); + }, + assignToCurrentUser() { + this.setAssignees([this.currentUser.id]); + this.localAssignees = [addClass(this.currentUser)]; + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + // If mutation is rejected, we're rolling back to initial state + this.localAssignees = this.assignees.map(addClass); + }, + }, +}; +</script> + +<template> + <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> + <span + :id="assigneesTitleId" + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break work-item-field-label" + data-testid="assignees-title" + >{{ assigneeText }}</span + > + <gl-token-selector + ref="tokenSelector" + :aria-labelledby="assigneesTitleId" + :selected-tokens="localAssignees" + :container-class="containerClass" + :class="{ 'gl-hover-border-gray-200': canUpdate }" + menu-class="token-selector-menu-class" + :dropdown-items="dropdownItems" + :loading="isLoadingUsers && !isLoadingMore" + :view-only="!canUpdate" + :allow-clear-all="isEditing" + class="assignees-selector hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" + data-testid="work-item-assignees-input" + @input="handleAssigneesInput" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="handleBlur" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #empty-placeholder> + <div + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-secondary gl-pr-4 gl-pl-2 gl-top-2" + data-testid="empty-state" + > + <gl-icon name="profile" /> + <span class="gl-ml-2 gl-mr-4">{{ addAssigneesText }}</span> + <gl-button + v-if="currentUser" + size="small" + class="assign-myself" + data-testid="assign-self" + @click.stop="assignToCurrentUser" + >{{ __('Assign yourself') }}</gl-button + > + </div> + </template> + <template #token-content="{ token }"> + <gl-link + :href="token.webUrl" + :title="token.name" + :data-user-id="getUserId(token.id)" + data-placement="top" + class="gl-ml-n2 gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" + > + <gl-avatar :size="24" :src="token.avatarUrl" /> + <span class="gl-pl-2">{{ token.name }}</span> + </gl-link> + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <sidebar-participant :user="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + <template #dropdown-footer> + <gl-intersection-observer + v-if="hasNextPage && !isLoadingUsers" + @appear="fetchMoreAssignees" + /> + <gl-skeleton-loader + v-if="showIntersectionSkeletonLoader" + :height="100" + data-testid="next-page-loading" + class="gl-text-center gl-py-3" + > + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + </gl-skeleton-loader> + <div v-if="canInviteMembers"> + <gl-dropdown-divider /> + <gl-dropdown-item @click="closeDropdown"> + <invite-members-trigger + :display-text="__('Invite members')" + trigger-element="side-nav" + icon="plus" + trigger-source="work_item_assignees_dropdown" + classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" + /> + </gl-dropdown-item> + </div> + </template> + </gl-token-selector> + </div> +</template> |