diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-23 18:08:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-23 18:08:42 +0300 |
commit | 9086e66ee72527839053ec6db19ed321a3b3a61b (patch) | |
tree | f2904493d8539228823f15cf4126eb8c4ffa79e3 /app/assets/javascripts/milestones | |
parent | b17c74a7e2cf516ed189e525291cb096411b7ac5 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/milestones')
7 files changed, 322 insertions, 256 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..fad61b95124 --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -0,0 +1,216 @@ +<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, + }, + 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'), + searhErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'), + projectMilestones: s__('MilestoneCombobox|Project milestones'), + }, + computed: { + ...mapState(['matches', 'selectedMilestones']), + ...mapGetters(['isLoading']), + 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, + ); + }, + showNoResults() { + return !this.showProjectMilestoneSection; + }, + }, + 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.fetchMilestones(); + }, + methods: { + ...mapActions([ + 'setProjectId', + '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 + :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.searhErrorMessage" + data-testid="project-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> diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue deleted file mode 100644 index 0fa5585e858..00000000000 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ /dev/null @@ -1,249 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, -} from '@gitlab/ui'; -import { intersection, debounce } from 'lodash'; -import { __, sprintf } from '~/locale'; -import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; - -const SEARCH_DEBOUNCE_MS = 250; - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, - }, - model: { - prop: 'preselectedMilestones', - event: 'change', - }, - props: { - projectId: { - type: String, - required: true, - }, - preselectedMilestones: { - type: Array, - default: () => [], - required: false, - }, - extraLinks: { - type: Array, - default: () => [], - required: false, - }, - }, - data() { - return { - searchQuery: '', - projectMilestones: [], - searchResults: [], - selectedMilestones: [], - requestCount: 0, - }; - }, - translations: { - milestone: __('Milestone'), - selectMilestone: __('Select milestone'), - noMilestone: __('No milestone'), - noResultsLabel: __('No matching results'), - searchMilestones: __('Search Milestones'), - }, - computed: { - selectedMilestonesLabel() { - if (this.milestoneTitles.length === 1) { - return this.milestoneTitles[0]; - } - - if (this.milestoneTitles.length > 1) { - const firstMilestoneName = this.milestoneTitles[0]; - const numberOfOtherMilestones = this.milestoneTitles.length - 1; - return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { - firstMilestoneName, - numberOfOtherMilestones, - }); - } - - return this.$options.translations.noMilestone; - }, - milestoneTitles() { - return this.preselectedMilestones.map(milestone => milestone.title); - }, - dropdownItems() { - return this.searchResults.length ? this.searchResults : this.projectMilestones; - }, - noResults() { - return this.searchQuery.length > 2 && this.searchResults.length === 0; - }, - isLoading() { - return this.requestCount !== 0; - }, - }, - 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.debouncedSearchMilestones = debounce(this.searchMilestones, SEARCH_DEBOUNCE_MS); - }, - mounted() { - this.fetchMilestones(); - }, - methods: { - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - fetchMilestones() { - this.requestCount += 1; - - Api.projectMilestones(this.projectId) - .then(({ data }) => { - this.projectMilestones = this.getTitles(data); - this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles); - }) - .catch(() => { - createFlash(__('An error occurred while loading milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - searchMilestones() { - this.requestCount += 1; - const options = { - search: this.searchQuery, - scope: 'milestones', - }; - - if (this.searchQuery.length < 3) { - this.requestCount -= 1; - this.searchResults = []; - return; - } - - Api.projectSearch(this.projectId, options) - .then(({ data }) => { - const searchResults = this.getTitles(data); - - this.searchResults = searchResults.length ? searchResults : []; - }) - .catch(() => { - createFlash(__('An error occurred while searching for milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - onSearchBoxInput() { - this.debouncedSearchMilestones(); - }, - onSearchBoxEnter() { - this.debouncedSearchMilestones.cancel(); - this.searchMilestones(); - }, - toggleMilestoneSelection(clickedMilestone) { - if (!clickedMilestone) return []; - - let milestones = [...this.preselectedMilestones]; - const hasMilestone = this.milestoneTitles.includes(clickedMilestone); - - if (hasMilestone) { - milestones = milestones.filter(({ title }) => title !== clickedMilestone); - } else { - milestones.push({ title: clickedMilestone }); - } - - return milestones; - }, - onMilestoneClicked(clickedMilestone) { - const milestones = this.toggleMilestoneSelection(clickedMilestone); - this.$emit('change', milestones); - - this.selectedMilestones = intersection( - this.projectMilestones, - milestones.map(milestone => milestone.title), - ); - }, - isSelectedMilestone(milestoneTitle) { - return this.selectedMilestones.includes(milestoneTitle); - }, - getTitles(milestones) { - return milestones.filter(({ state }) => state === 'active').map(({ title }) => title); - }, - }, -}; -</script> - -<template> - <gl-dropdown v-bind="$attrs" class="project-milestone-combobox" @shown="focusSearchBox"> - <template slot="button-content"> - <span ref="buttonText" class="flex-grow-1 ml-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" - :placeholder="this.$options.translations.searchMilestones" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> - - <gl-dropdown-item @click="onMilestoneClicked(null)"> - <span :class="{ 'pl-4': 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="noResults"> - <div class="dropdown-item-space"> - <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span> - </div> - <gl-dropdown-divider /> - </template> - <template v-else-if="dropdownItems.length"> - <gl-dropdown-item - v-for="item in dropdownItems" - :key="item" - role="milestone option" - @click="onMilestoneClicked(item)" - > - <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }"> - {{ item }} - </span> - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - - <gl-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url"> - <span class="pl-4">{{ item.text }}</span> - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js index 3859771aeba..56a07562f62 100644 --- a/app/assets/javascripts/milestones/stores/actions.js +++ b/app/assets/javascripts/milestones/stores/actions.js @@ -6,6 +6,8 @@ export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ export const setSelectedMilestones = ({ commit }, selectedMilestones) => commit(types.SET_SELECTED_MILESTONES, selectedMilestones); +export const clearSelectedMilestones = ({ commit }) => commit(types.CLEAR_SELECTED_MILESTONES); + export const toggleMilestones = ({ commit, state }, selectedMilestone) => { const removeMilestone = state.selectedMilestones.includes(selectedMilestone); @@ -16,8 +18,8 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => { } }; -export const search = ({ dispatch, commit }, query) => { - commit(types.SET_QUERY, query); +export const search = ({ dispatch, commit }, searchQuery) => { + commit(types.SET_SEARCH_QUERY, searchQuery); dispatch('searchMilestones'); }; @@ -41,7 +43,7 @@ export const searchMilestones = ({ commit, state }) => { commit(types.REQUEST_START); const options = { - search: state.query, + search: state.searchQuery, scope: 'milestones', }; diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js index 370d386dba2..6c58fca9dca 100644 --- a/app/assets/javascripts/milestones/stores/mutation_types.js +++ b/app/assets/javascripts/milestones/stores/mutation_types.js @@ -1,10 +1,11 @@ export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES'; +export const CLEAR_SELECTED_MILESTONES = 'CLEAR_SELECTED_MILESTONES'; export const ADD_SELECTED_MILESTONE = 'ADD_SELECTED_MILESTONE'; export const REMOVE_SELECTED_MILESTONE = 'REMOVE_SELECTED_MILESTONE'; -export const SET_QUERY = 'SET_QUERY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const REQUEST_START = 'REQUEST_START'; export const REQUEST_FINISH = 'REQUEST_FINISH'; diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js index 7c75d09766c..71331965d2a 100644 --- a/app/assets/javascripts/milestones/stores/mutations.js +++ b/app/assets/javascripts/milestones/stores/mutations.js @@ -9,6 +9,9 @@ export default { [types.SET_SELECTED_MILESTONES](state, selectedMilestones) { Vue.set(state, 'selectedMilestones', selectedMilestones); }, + [types.CLEAR_SELECTED_MILESTONES](state) { + Vue.set(state, 'selectedMilestones', []); + }, [types.ADD_SELECTED_MILESTONE](state, selectedMilestone) { state.selectedMilestones.push(selectedMilestone); }, @@ -18,8 +21,8 @@ export default { ); Vue.set(state, 'selectedMilestones', filteredMilestones); }, - [types.SET_QUERY](state, query) { - state.query = query; + [types.SET_SEARCH_QUERY](state, searchQuery) { + state.searchQuery = searchQuery; }, [types.REQUEST_START](state) { state.requestCount += 1; diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js index 0944539f367..8466228dc17 100644 --- a/app/assets/javascripts/milestones/stores/state.js +++ b/app/assets/javascripts/milestones/stores/state.js @@ -1,7 +1,7 @@ export default () => ({ projectId: null, groupId: null, - query: '', + searchQuery: '', matches: { projectMilestones: { list: [], |