250 lines
7.3 KiB
Vue
250 lines
7.3 KiB
Vue
<script>
|
|
import {
|
|
GlDropdown,
|
|
GlDropdownDivider,
|
|
GlDropdownSectionHeader,
|
|
GlDropdownItem,
|
|
GlLoadingIcon,
|
|
GlSearchBoxByType,
|
|
GlIcon,
|
|
} from '@gitlab/ui';
|
|
import { debounce, isEqual } from 'lodash';
|
|
import { mapActions, mapGetters, mapState } from 'vuex';
|
|
import { s__, __, sprintf } from '~/locale';
|
|
import createStore from '../stores';
|
|
import MilestoneResultsSection from './milestone_results_section.vue';
|
|
|
|
const SEARCH_DEBOUNCE_MS = 250;
|
|
|
|
export default {
|
|
name: 'MilestoneCombobox',
|
|
store: createStore(),
|
|
components: {
|
|
GlDropdown,
|
|
GlDropdownDivider,
|
|
GlDropdownSectionHeader,
|
|
GlDropdownItem,
|
|
GlLoadingIcon,
|
|
GlSearchBoxByType,
|
|
GlIcon,
|
|
MilestoneResultsSection,
|
|
},
|
|
props: {
|
|
value: {
|
|
type: Array,
|
|
required: false,
|
|
default: () => [],
|
|
},
|
|
projectId: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
groupId: {
|
|
type: String,
|
|
required: false,
|
|
default: '',
|
|
},
|
|
groupMilestonesAvailable: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: false,
|
|
},
|
|
extraLinks: {
|
|
type: Array,
|
|
default: () => [],
|
|
required: false,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
searchQuery: '',
|
|
};
|
|
},
|
|
translations: {
|
|
milestone: s__('MilestoneCombobox|Milestone'),
|
|
selectMilestone: s__('MilestoneCombobox|Select milestone'),
|
|
noMilestone: s__('MilestoneCombobox|No milestone'),
|
|
noResultsLabel: s__('MilestoneCombobox|No matching results'),
|
|
searchMilestones: s__('MilestoneCombobox|Search Milestones'),
|
|
searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
|
|
projectMilestones: s__('MilestoneCombobox|Project milestones'),
|
|
groupMilestones: s__('MilestoneCombobox|Group milestones'),
|
|
},
|
|
computed: {
|
|
...mapState(['matches', 'selectedMilestones']),
|
|
...mapGetters(['isLoading', 'groupMilestonesEnabled']),
|
|
selectedMilestonesLabel() {
|
|
const { selectedMilestones } = this;
|
|
const firstMilestoneName = selectedMilestones[0];
|
|
|
|
if (selectedMilestones.length === 0) {
|
|
return this.$options.translations.noMilestone;
|
|
}
|
|
|
|
if (selectedMilestones.length === 1) {
|
|
return firstMilestoneName;
|
|
}
|
|
|
|
const numberOfOtherMilestones = selectedMilestones.length - 1;
|
|
return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
|
|
firstMilestoneName,
|
|
numberOfOtherMilestones,
|
|
});
|
|
},
|
|
showProjectMilestoneSection() {
|
|
return Boolean(
|
|
this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error,
|
|
);
|
|
},
|
|
showGroupMilestoneSection() {
|
|
return (
|
|
this.groupMilestonesEnabled &&
|
|
Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error)
|
|
);
|
|
},
|
|
showNoResults() {
|
|
return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection;
|
|
},
|
|
},
|
|
watch: {
|
|
// Keep the Vuex store synchronized if the parent
|
|
// component updates the selected milestones through v-model
|
|
value: {
|
|
immediate: true,
|
|
handler() {
|
|
const milestoneTitles = this.value.map(milestone =>
|
|
milestone.title ? milestone.title : milestone,
|
|
);
|
|
if (!isEqual(milestoneTitles, this.selectedMilestones)) {
|
|
this.setSelectedMilestones(milestoneTitles);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
created() {
|
|
// This method is defined here instead of in `methods`
|
|
// because we need to access the .cancel() method
|
|
// lodash attaches to the function, which is
|
|
// made inaccessible by Vue. More info:
|
|
// https://stackoverflow.com/a/52988020/1063392
|
|
this.debouncedSearch = debounce(function search() {
|
|
this.search(this.searchQuery);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
|
|
this.setProjectId(this.projectId);
|
|
this.setGroupId(this.groupId);
|
|
this.setGroupMilestonesAvailable(this.groupMilestonesAvailable);
|
|
this.fetchMilestones();
|
|
},
|
|
methods: {
|
|
...mapActions([
|
|
'setProjectId',
|
|
'setGroupId',
|
|
'setGroupMilestonesAvailable',
|
|
'setSelectedMilestones',
|
|
'clearSelectedMilestones',
|
|
'toggleMilestones',
|
|
'search',
|
|
'fetchMilestones',
|
|
]),
|
|
focusSearchBox() {
|
|
this.$refs.searchBox.$el.querySelector('input').focus();
|
|
},
|
|
onSearchBoxEnter() {
|
|
this.debouncedSearch.cancel();
|
|
this.search(this.searchQuery);
|
|
},
|
|
onSearchBoxInput() {
|
|
this.debouncedSearch();
|
|
},
|
|
selectMilestone(milestone) {
|
|
this.toggleMilestones(milestone);
|
|
this.$emit('input', this.selectedMilestones);
|
|
},
|
|
selectNoMilestone() {
|
|
this.clearSelectedMilestones();
|
|
this.$emit('input', this.selectedMilestones);
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox">
|
|
<template slot="button-content">
|
|
<span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{
|
|
selectedMilestonesLabel
|
|
}}</span>
|
|
<gl-icon name="chevron-down" />
|
|
</template>
|
|
|
|
<gl-dropdown-section-header>
|
|
<span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
|
|
</gl-dropdown-section-header>
|
|
|
|
<gl-dropdown-divider />
|
|
|
|
<gl-search-box-by-type
|
|
ref="searchBox"
|
|
v-model.trim="searchQuery"
|
|
class="gl-m-3"
|
|
:placeholder="this.$options.translations.searchMilestones"
|
|
@input="onSearchBoxInput"
|
|
@keydown.enter.prevent="onSearchBoxEnter"
|
|
/>
|
|
|
|
<gl-dropdown-item @click="selectNoMilestone()">
|
|
<span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }">
|
|
{{ $options.translations.noMilestone }}
|
|
</span>
|
|
</gl-dropdown-item>
|
|
|
|
<gl-dropdown-divider />
|
|
|
|
<template v-if="isLoading">
|
|
<gl-loading-icon />
|
|
<gl-dropdown-divider />
|
|
</template>
|
|
<template v-else-if="showNoResults">
|
|
<div class="dropdown-item-space">
|
|
<span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{
|
|
$options.translations.noResultsLabel
|
|
}}</span>
|
|
</div>
|
|
<gl-dropdown-divider />
|
|
</template>
|
|
<template v-else>
|
|
<milestone-results-section
|
|
v-if="showProjectMilestoneSection"
|
|
:section-title="$options.translations.projectMilestones"
|
|
:total-count="matches.projectMilestones.totalCount"
|
|
:items="matches.projectMilestones.list"
|
|
:selected-milestones="selectedMilestones"
|
|
:error="matches.projectMilestones.error"
|
|
:error-message="$options.translations.searchErrorMessage"
|
|
data-testid="project-milestones-section"
|
|
@selected="selectMilestone($event)"
|
|
/>
|
|
|
|
<milestone-results-section
|
|
v-if="showGroupMilestoneSection"
|
|
:section-title="$options.translations.groupMilestones"
|
|
:total-count="matches.groupMilestones.totalCount"
|
|
:items="matches.groupMilestones.list"
|
|
:selected-milestones="selectedMilestones"
|
|
:error="matches.groupMilestones.error"
|
|
:error-message="$options.translations.searchErrorMessage"
|
|
data-testid="group-milestones-section"
|
|
@selected="selectMilestone($event)"
|
|
/>
|
|
</template>
|
|
<gl-dropdown-item
|
|
v-for="(item, idx) in extraLinks"
|
|
:key="idx"
|
|
:href="item.url"
|
|
data-testid="milestone-combobox-extra-links"
|
|
>
|
|
<span class="gl-pl-6">{{ item.text }}</span>
|
|
</gl-dropdown-item>
|
|
</gl-dropdown>
|
|
</template>
|