gitlab-org--gitlab-foss/app/assets/javascripts/vue_shared/components/user_select/user_select.vue

371 lines
10 KiB
Vue

<script>
import { debounce } from 'lodash';
import {
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { IssuableType } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
export default {
i18n: {
unassigned: __('Unassigned'),
},
components: {
GlDropdownForm,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
SidebarParticipant,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
headerText: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
value: {
type: Array,
required: true,
},
allowMultipleAssignees: {
type: Boolean,
required: false,
default: false,
},
currentUser: {
type: Object,
required: true,
},
issuableType: {
type: String,
required: false,
default: IssuableType.Issue,
},
isEditing: {
type: Boolean,
required: false,
default: true,
},
issuableId: {
type: Number,
required: false,
default: null,
},
},
data() {
return {
search: '',
participants: [],
searchUsers: [],
isSearching: false,
};
},
apollo: {
participants: {
query() {
return participantsQueries[this.issuableType].query;
},
skip() {
return Boolean(participantsQueries[this.issuableType].skipQuery) || !this.isEditing;
},
variables() {
return {
iid: this.iid,
fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.issuable?.participants.nodes.map((node) => ({
...node,
canMerge: false,
}));
},
error() {
this.$emit('error');
},
},
searchUsers: {
query() {
return userSearchQueries[this.issuableType].query;
},
variables() {
return this.searchUsersVariables;
},
skip() {
return !this.isEditing;
},
update(data) {
return (
data.workspace?.users?.nodes
.filter((x) => x?.user)
.map((node) => ({
...node.user,
canMerge: node.mergeRequestInteraction?.canMerge || false,
})) || []
);
},
error() {
this.$emit('error');
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
},
computed: {
isMergeRequest() {
return this.issuableType === IssuableType.MergeRequest;
},
searchUsersVariables() {
const variables = {
fullPath: this.fullPath,
search: this.search,
first: 20,
};
if (!this.isMergeRequest) {
return variables;
}
return {
...variables,
mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId),
};
},
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
users() {
if (!this.participants) {
return [];
}
const filteredParticipants = this.participants.filter(
(user) => user.name.includes(this.search) || user.username.includes(this.search),
);
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
const mergedSearchResults = this.searchUsers
.concat(filteredParticipants)
.reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[],
);
return this.moveCurrentUserToStart(mergedSearchResults);
},
isSearchEmpty() {
return this.search === '';
},
shouldShowParticipants() {
return this.isSearchEmpty || this.isSearching;
},
isCurrentUserInList() {
const isCurrentUser = (user) => user.username === this.currentUser.username;
return this.users.some(isCurrentUser);
},
noUsersFound() {
return !this.isSearchEmpty && this.users.length === 0;
},
showCurrentUser() {
return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
},
selectedFiltered() {
if (this.shouldShowParticipants) {
return this.moveCurrentUserToStart(this.value);
}
const foundUsernames = this.users.map(({ username }) => username);
const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
return this.moveCurrentUserToStart(filtered);
},
selectedUserNames() {
return this.value.map(({ username }) => username);
},
unselectedFiltered() {
return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || [];
},
selectedIsEmpty() {
return this.selectedFiltered.length === 0;
},
},
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() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
this.$emit('input', selected);
this.$refs.dropdown.hide();
this.$emit('toggle');
} else {
selected.push(user);
this.$emit('input', selected);
}
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
this.$emit('input', selected);
},
focusSearch() {
this.$refs.search.focusInput();
},
showDropdown() {
this.$refs.dropdown.show();
},
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
moveCurrentUserToStart(users) {
if (!users) {
return [];
}
const usersCopy = [...users];
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
currentUser.canMerge = this.currentUser.canMerge;
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
setSearchKey(value) {
this.search = value.trim();
},
tooltipText(user) {
if (!this.isMergeRequest) {
return '';
}
return user.canMerge ? '' : __('Cannot merge');
},
},
};
</script>
<template>
<gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
<gl-search-box-by-type
ref="search"
:value="search"
class="js-dropdown-input-field"
@input="debouncedSearchKeyUpdate"
/>
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
v-if="isLoading"
data-testid="loading-participants"
size="lg"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
<template v-if="shouldShowParticipants">
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click.native.capture.stop="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
}}</span></gl-dropdown-item
>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
v-gl-tooltip.left.viewport
:title="tooltipText(item)"
boundary="viewport"
is-checked
is-check-centered
data-testid="selected-participant"
@click.native.capture.stop="unselect(item.username)"
>
<sidebar-participant :user="item" :issuable-type="issuableType" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item
data-testid="current-user"
@click.native.capture.stop="selectAssignee(currentUser)"
>
<sidebar-participant
:user="currentUser"
:issuable-type="issuableType"
class="gl-pl-6!"
/>
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
v-gl-tooltip.left.viewport
:title="tooltipText(unselectedUser)"
boundary="viewport"
data-testid="unselected-participant"
@click.native.capture.stop="selectAssignee(unselectedUser)"
>
<sidebar-participant
:user="unselectedUser"
:issuable-type="issuableType"
class="gl-pl-6!"
/>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown>
</template>