Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-10-23 18:08:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-23 18:08:42 +0300
commit9086e66ee72527839053ec6db19ed321a3b3a61b (patch)
treef2904493d8539228823f15cf4126eb8c4ffa79e3 /app/assets/javascripts/milestones
parentb17c74a7e2cf516ed189e525291cb096411b7ac5 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/milestones')
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue216
-rw-r--r--app/assets/javascripts/milestones/components/milestone_results_section.vue93
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue249
-rw-r--r--app/assets/javascripts/milestones/stores/actions.js8
-rw-r--r--app/assets/javascripts/milestones/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js7
-rw-r--r--app/assets/javascripts/milestones/stores/state.js2
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: [],