gitlab-org--gitlab-foss/app/assets/javascripts/work_items/components/work_item_assignees.vue

392 lines
11 KiB
Vue

<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 userSearchQuery 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()],
props: {
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,
},
fullPath: {
type: String,
required: true,
},
},
data() {
return {
isEditing: false,
searchStarted: false,
localAssignees: this.assignees.map(addClass),
searchKey: '',
users: {
nodes: [],
},
currentUser: null,
isLoadingMore: false,
};
},
apollo: {
users: {
query() {
return userSearchQuery;
},
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-');
},
searchUsers() {
return this.users.nodes.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!' : '';
},
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;
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 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
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 }"
:dropdown-items="dropdownItems"
:loading="isLoadingUsers && !isLoadingMore"
:view-only="!canUpdate"
:allow-clear-all="isEditing"
class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
@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 myself') }}</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>