diff options
Diffstat (limited to 'app/assets/javascripts/milestones/components')
-rw-r--r-- | app/assets/javascripts/milestones/components/milestone_combobox.vue | 250 | ||||
-rw-r--r-- | app/assets/javascripts/milestones/components/milestone_results_section.vue | 93 |
2 files changed, 343 insertions, 0 deletions
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue new file mode 100644 index 00000000000..08fd5a5994f --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -0,0 +1,250 @@ +<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> diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue new file mode 100644 index 00000000000..d53a59e58d4 --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue @@ -0,0 +1,93 @@ +<script> +import { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'MilestoneResultsSection', + components: { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + totalCount: { + type: Number, + required: true, + }, + items: { + type: Array, + required: true, + }, + selectedMilestones: { + type: Array, + required: true, + default: () => [], + }, + error: { + type: Error, + required: false, + default: null, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalMilestonesIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + isSelectedMilestone(item) { + return this.selectedMilestones.includes(item); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown-section-header> + <div + class="gl-display-flex gl-align-items-center gl-pl-6" + data-testid="milestone-results-section-header" + > + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-dropdown-section-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start gl-text-red-500 gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-dropdown-item + v-for="{ title } in items" + :key="title" + role="milestone option" + @click="$emit('selected', title)" + > + <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }"> + {{ title }} + </span> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + </div> +</template> |