Commit 6d2bc034 authored by Jake Burden's avatar Jake Burden Committed by Vitaly Slobodin

Enable seperate sections with badge counters

Add new milestone results section

Move milestone components into separate directory

Migrate state managment to Vuex

Add changelog entry
parent 2b8072a2
......@@ -8,14 +8,17 @@ import {
GlSearchBoxByType,
GlIcon,
} from '@gitlab/ui';
import { intersection, debounce } from 'lodash';
import { __, sprintf } from '~/locale';
import Api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
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,
......@@ -24,21 +27,18 @@ export default {
GlLoadingIcon,
GlSearchBoxByType,
GlIcon,
},
model: {
prop: 'preselectedMilestones',
event: 'change',
MilestoneResultsSection,
},
props: {
value: {
type: Array,
required: false,
default: () => [],
},
projectId: {
type: String,
required: true,
},
preselectedMilestones: {
type: Array,
default: () => [],
required: false,
},
extraLinks: {
type: Array,
default: () => [],
......@@ -48,47 +48,60 @@ export default {
data() {
return {
searchQuery: '',
projectMilestones: [],
searchResults: [],
selectedMilestones: [],
requestCount: 0,
};
},
translations: {
milestone: __('Milestone'),
selectMilestone: __('Select milestone'),
noMilestone: __('No milestone'),
noResultsLabel: __('No matching results'),
searchMilestones: __('Search Milestones'),
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() {
if (this.milestoneTitles.length === 1) {
return this.milestoneTitles[0];
const { selectedMilestones } = this;
const firstMilestoneName = selectedMilestones[0];
if (selectedMilestones.length === 0) {
return this.$options.translations.noMilestone;
}
if (selectedMilestones.length === 1) {
return firstMilestoneName;
}
if (this.milestoneTitles.length > 1) {
const firstMilestoneName = this.milestoneTitles[0];
const numberOfOtherMilestones = this.milestoneTitles.length - 1;
const numberOfOtherMilestones = selectedMilestones.length - 1;
return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
firstMilestoneName,
numberOfOtherMilestones,
});
}
return this.$options.translations.noMilestone;
},
milestoneTitles() {
return this.preselectedMilestones.map(milestone => milestone.title);
showProjectMilestoneSection() {
return Boolean(
this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error,
);
},
showNoResults() {
return !this.showProjectMilestoneSection;
},
dropdownItems() {
return this.searchResults.length ? this.searchResults : this.projectMilestones;
},
noResults() {
return this.searchQuery.length > 2 && this.searchResults.length === 0;
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);
}
},
isLoading() {
return this.requestCount !== 0;
},
},
created() {
......@@ -97,100 +110,48 @@ export default {
// 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.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();
},
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();
this.debouncedSearch.cancel();
this.search(this.searchQuery);
},
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),
);
onSearchBoxInput() {
this.debouncedSearch();
},
isSelectedMilestone(milestoneTitle) {
return this.selectedMilestones.includes(milestoneTitle);
selectMilestone(milestone) {
this.toggleMilestones(milestone);
this.$emit('input', this.selectedMilestones);
},
getTitles(milestones) {
return milestones.filter(({ state }) => state === 'active').map(({ title }) => title);
selectNoMilestone() {
this.clearSelectedMilestones();
this.$emit('input', this.selectedMilestones);
},
},
};
</script>
<template>
<gl-dropdown v-bind="$attrs" class="project-milestone-combobox" @shown="focusSearchBox">
<gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox">
<template slot="button-content">
<span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{
<span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{
selectedMilestonesLabel
}}</span>
<gl-icon name="chevron-down" />
......@@ -205,13 +166,14 @@ export default {
<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="onMilestoneClicked(null)">
<span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">
<gl-dropdown-item @click="selectNoMilestone()">
<span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }">
{{ $options.translations.noMilestone }}
</span>
</gl-dropdown-item>
......@@ -222,28 +184,33 @@ export default {
<gl-loading-icon />
<gl-dropdown-divider />
</template>
<template v-else-if="noResults">
<template v-else-if="showNoResults">
<div class="dropdown-item-space">
<span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span>
<span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{
$options.translations.noResultsLabel
}}</span>
</div>
<gl-dropdown-divider />
</template>
<template v-else-if="dropdownItems.length">
<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 in dropdownItems"
:key="item"
role="milestone option"
@click="onMilestoneClicked(item)"
v-for="(item, idx) in extraLinks"
:key="idx"
:href="item.url"
data-testid="milestone-combobox-extra-links"
>
<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>
<span class="gl-pl-6">{{ item.text }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
<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>
......@@ -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',
};
......
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';
......
......@@ -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;
......
export default () => ({
projectId: null,
groupId: null,
query: '',
searchQuery: '',
matches: {
projectMilestones: {
list: [],
......
......@@ -6,7 +6,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import TagField from './tag_field.vue';
export default {
......
......@@ -15,15 +15,13 @@ import {
export const releaseToApiJson = (release, createFrom = null) => {
const name = release.name?.trim().length > 0 ? release.name.trim() : null;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return convertObjectPropsToSnakeCase(
{
name,
tagName: release.tagName,
ref: createFrom,
description: release.description,
milestones,
milestones: release.milestones,
assets: release.assets,
},
{ deep: true },
......
---
title: Update the milestone dropdown combobox to display separated sections
and badge counters
merge_request: 43427
author:
type: added
......@@ -2994,9 +2994,6 @@ msgstr ""
msgid "An error occurred while loading merge requests."
msgstr ""
msgid "An error occurred while loading milestones"
msgstr ""
msgid "An error occurred while loading project creation UI"
msgstr ""
......@@ -3078,9 +3075,6 @@ msgstr ""
msgid "An error occurred while saving assignees"
msgstr ""
msgid "An error occurred while searching for milestones"
msgstr ""
msgid "An error occurred while subscribing to notifications."
msgstr ""
......@@ -16983,6 +16977,27 @@ msgstr ""
msgid "Milestone lists show all issues from the selected milestone."
msgstr ""
msgid "MilestoneCombobox|An error occurred while searching for milestones"
msgstr ""
msgid "MilestoneCombobox|Milestone"
msgstr ""
msgid "MilestoneCombobox|No matching results"
msgstr ""
msgid "MilestoneCombobox|No milestone"
msgstr ""
msgid "MilestoneCombobox|Project milestones"
msgstr ""
msgid "MilestoneCombobox|Search Milestones"
msgstr ""
msgid "MilestoneCombobox|Select milestone"
msgstr ""
msgid "MilestoneSidebar|Closed:"
msgstr ""
......@@ -23052,9 +23067,6 @@ msgstr ""
msgid "Search Jira issues"
msgstr ""
msgid "Search Milestones"
msgstr ""
msgid "Search an environment spec"
msgstr ""
......@@ -27861,6 +27873,9 @@ msgstr ""
msgid "Total: %{total}"
msgstr ""
msgid "TotalMilestonesIndicator|1000+"
msgstr ""
msgid "TotalRefCountIndicator|1000+"
msgstr ""
......
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data';
import createStore from '~/milestones/stores/';
const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
{ text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' },
];
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Milestone combobox component', () => {
const projectId = '8';
const X_TOTAL_HEADER = 'x-total';
let wrapper;
let projectMilestonesApiCallSpy;
let searchApiCallSpy;
const createComponent = (props = {}, attrs = {}) => {
wrapper = mount(MilestoneCombobox, {
propsData: {
projectId,
extraLinks,
value: [],
...props,
},
attrs,
listeners: {
// simulate a parent component v-model binding
input: selectedMilestone => {
wrapper.setProps({ value: selectedMilestone });
},
},
stubs: {
GlSearchBoxByType: true,
},
localVue,
store: createStore(),
});
};
beforeEach(() => {
const mock = new MockAdapter(axios);
gon.api_version = 'v4';
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
searchApiCallSpy = jest
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
mock
.onGet(`/api/v4/projects/${projectId}/milestones`)
.reply(config => projectMilestonesApiCallSpy(config));
mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config));
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
//
// Finders
//
const findButtonContent = () => wrapper.find('[data-testid="milestone-combobox-button-content"]');
const findNoResults = () => wrapper.find('[data-testid="milestone-combobox-no-results"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findProjectMilestonesSection = () =>
wrapper.find('[data-testid="project-milestones-section"]');
const findProjectMilestonesDropdownItems = () =>
findProjectMilestonesSection().findAll(GlDropdownItem);
const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0);
//
// Expecters
//
const projectMilestoneSectionContainsErrorMessage = () => {
const projectMilestoneSection = findProjectMilestonesSection();
return projectMilestoneSection
.text()
.includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
};
//
// Convenience methods
//
const updateQuery = newQuery => {
findSearchBox().vm.$emit('input', newQuery);
};
const selectFirstProjectMilestone = () => {
findFirstProjectMilestonesDropdownItem().vm.$emit('click');
};
const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) =>
axios.waitForAll().then(() => {
if (andClearMocks) {
projectMilestonesApiCallSpy.mockClear();
}
});
describe('initialization behavior', () => {
beforeEach(createComponent);
it('initializes the dropdown with project milestones when mounted', () => {
return waitForRequests().then(() => {
expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
});
});
it('shows a spinner while network requests are in progress', () => {
expect(findLoadingIcon().exists()).toBe(true);
return waitForRequests().then(() => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
it('shows additional links', () => {
const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]');
links.wrappers.forEach((item, idx) => {
expect(item.text()).toBe(extraLinks[idx].text);
expect(item.attributes('href')).toBe(extraLinks[idx].url);
});
});
});
describe('post-initialization behavior', () => {
describe('when the parent component provides an `id` binding', () => {
const id = '8';
beforeEach(() => {
createComponent({}, { id });
return waitForRequests();
});
it('adds the provided ID to the GlDropdown instance', () => {
expect(wrapper.attributes().id).toBe(id);
});
});
describe('when milestones are pre-selected', () => {
beforeEach(() => {
createComponent({ value: projectMilestones });
return waitForRequests();
});
it('renders the pre-selected project milestones', () => {
expect(findButtonContent().text()).toBe('v0.1 + 5 more');
});
});
describe('when the search query is updated', () => {
beforeEach(() => {
createComponent();
return waitForRequests({ andClearMocks: true });
});
it('requeries the search when the search query is updated', () => {
updateQuery('v1.2.3');
return waitForRequests().then(() => {
expect(searchApiCallSpy).toHaveBeenCalledTimes(1);
});
});
});
describe('when the Enter is pressed', () => {
beforeEach(() => {
createComponent();
return waitForRequests({ andClearMocks: true });
});
it('requeries the search when Enter is pressed', () => {
findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
return waitForRequests().then(() => {
expect(searchApiCallSpy).toHaveBeenCalledTimes(1);
});
});
});
describe('when no results are found', () => {
beforeEach(() => {
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
});
describe('when the search query is empty', () => {
it('renders a "no results" message', () => {
expect(findNoResults().text()).toBe(s__('MilestoneCombobox|No matching results'));
});
});
});
describe('project milestones', () => {
describe('when the project milestones search returns results', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
it('renders the project milestones section in the dropdown', () => {
expect(findProjectMilestonesSection().exists()).toBe(true);
});
it('renders the "Project milestones" heading with a total number indicator', () => {
expect(
findProjectMilestonesSection()
.find('[data-testid="milestone-results-section-header"]')
.text(),
).toBe('Project milestones 6');
});
it("does not render an error message in the project milestone section's body", () => {
expect(projectMilestoneSectionContainsErrorMessage()).toBe(false);
});
it('renders each project milestones as a selectable item', () => {
const dropdownItems = findProjectMilestonesDropdownItems();
projectMilestones.forEach((milestone, i) => {
expect(dropdownItems.at(i).text()).toBe(milestone.title);
});
});
});
describe('when the project milestones search returns no results', () => {
beforeEach(() => {
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
});
it('does not render the project milestones section in the dropdown', () => {
expect(findProjectMilestonesSection().exists()).toBe(false);
});
});
describe('when the project milestones search returns an error', () => {
beforeEach(() => {
projectMilestonesApiCallSpy = jest.fn().mockReturnValue([500]);
searchApiCallSpy = jest.fn().mockReturnValue([500]);
createComponent({ value: [] });
return waitForRequests();
});
it('renders the project milestones section in the dropdown', () => {
expect(findProjectMilestonesSection().exists()).toBe(true);
});
it("renders an error message in the project milestones section's body", () => {
expect(projectMilestoneSectionContainsErrorMessage()).toBe(true);
});
});
});
describe('selection', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
it('renders a checkmark by the selected item', async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
selectFirstProjectMilestone();
return localVue.nextTick().then(() => {
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(true);
});
});
describe('when a project milestones is selected', () => {
beforeEach(() => {
createComponent();
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
return waitForRequests();
});
it("displays the project milestones name in the dropdown's button", async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('v1.0');
});
it('updates the v-model binding with the project milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
selectFirstProjectMilestone();
expect(wrapper.vm.value).toEqual(['v1.0']);
});
});
});
});
});
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data';
const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
const TEST_SEARCH = 'TEST_SEARCH';
const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
{ text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' },
];
const preselectedMilestones = [];
const projectId = '8';
describe('Milestone selector', () => {
let wrapper;
let mock;
const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' });
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const factory = (options = {}) => {
wrapper = shallowMount(MilestoneCombobox, {
...options,
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
gon.api_version = 'v4';
mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones);
factory({
propsData: {
projectId,
preselectedMilestones,
extraLinks,
},
});
});
afterEach(() => {
mock.restore();
wrapper.destroy();
wrapper = null;
});
it('renders the dropdown', () => {
expect(wrapper.find(GlDropdown)).toExist();
});
it('renders additional links', () => {
const links = wrapper.findAll('[href]');
links.wrappers.forEach((item, idx) => {
expect(item.text()).toBe(extraLinks[idx].text);
expect(item.attributes('href')).toBe(extraLinks[idx].url);
});
});
describe('before results', () => {
it('should show a loading icon', () => {
const request = mock.onGet(TEST_SEARCH_ENDPOINT, {
params: { search: TEST_SEARCH, scope: 'milestones' },
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
return wrapper.vm.$nextTick().then(() => {
request.reply(200, []);
});
});
it('should not show any dropdown items', () => {
expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0);
});
it('should have "No milestone" as the button text', () => {
expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone');
});
});
describe('with empty results', () => {
beforeEach(() => {
mock
.onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } })
.reply(200, []);
findSearchBox().vm.$emit('input', TEST_SEARCH);
return axios.waitForAll();
});
it('should display that no matching items are found', () => {
expect(findNoResultsMessage().exists()).toBe(true);
});
});
describe('with results', () => {
let items;
beforeEach(() => {
mock
.onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } })
.reply(200, [
{
id: 41,
iid: 6,
project_id: 8,
title: 'v0.1',
description: '',
state: 'active',
created_at: '2020-04-04T01:30:40.051Z',
updated_at: '2020-04-04T01:30:40.051Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
},
]);
findSearchBox().vm.$emit('input', 'v0.1');
return axios.waitForAll().then(() => {
items = wrapper.findAll('[role="milestone option"]');
});
});
it('should display one item per result', () => {
expect(items).toHaveLength(1);
});
it('should emit a change if an item is clicked', () => {
items.at(0).vm.$emit('click');
expect(wrapper.emitted().change.length).toBe(1);
expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]);
});
it('should not have a selecton icon on any item', () => {
items.wrappers.forEach(item => {
expect(item.find('.selected-item').exists()).toBe(false);
});
});
it('should have a selecton icon if an item is clicked', () => {
items.at(0).vm.$emit('click');
expect(wrapper.find('.selected-item').exists()).toBe(true);
});
it('should not display a message about no results', () => {
expect(findNoResultsMessage().exists()).toBe(false);
});
});
describe('when Enter is pressed', () => {
beforeEach(() => {
factory({
propsData: {
projectId,
preselectedMilestones,
extraLinks,
},
data() {
return {
searchQuery: 'TEST_SEARCH',
};
},
});
mock
.onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
.reply(200, []);
});
it('should trigger a search', async () => {
mock.resetHistory();
findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await axios.waitForAll();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT);
});
});
});
......@@ -41,6 +41,14 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
describe('clearSelectedMilestones', () => {
it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => {
testAction(actions.clearSelectedMilestones, null, state, [
{ type: types.CLEAR_SELECTED_MILESTONES },
]);
});
});
describe('toggleMilestones', () => {
const selectedMilestone = 'v1.2.3';
it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => {
......@@ -58,13 +66,13 @@ describe('Milestone combobox Vuex store actions', () => {
});
describe('search', () => {
it(`commits ${types.SET_QUERY} with the new search query`, () => {
const query = 'v1.0';
it(`commits ${types.SET_SEARCH_QUERY} with the new search query`, () => {
const searchQuery = 'v1.0';
testAction(
actions.search,
query,
searchQuery,
state,
[{ type: types.SET_QUERY, payload: query }],
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchMilestones' }],
);
});
......
......@@ -14,7 +14,7 @@ describe('Milestones combobox Vuex store mutations', () => {
expect(state).toEqual({
projectId: null,
groupId: null,
query: '',
searchQuery: '',
matches: {
projectMilestones: {
list: [],
......@@ -46,6 +46,20 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.CLEAR_SELECTED_MILESTONES}`, () => {
it('clears the selected milestones', () => {
const selectedMilestones = ['v1.2.3'];
// Set state.selectedMilestones
mutations[types.SET_SELECTED_MILESTONES](state, selectedMilestones);
// Clear state.selectedMilestones
mutations[types.CLEAR_SELECTED_MILESTONES](state);
expect(state.selectedMilestones).toEqual([]);
});
});
describe(`${types.ADD_SELECTED_MILESTONESs}`, () => {
it('adds the selected milestones', () => {
const selectedMilestone = 'v1.2.3';
......@@ -67,12 +81,12 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.SET_QUERY}`, () => {
describe(`${types.SET_SEARCH_QUERY}`, () => {
it('updates the search query', () => {
const newQuery = 'hello';
mutations[types.SET_QUERY](state, newQuery);
mutations[types.SET_SEARCH_QUERY](state, newQuery);
expect(state.query).toBe(newQuery);
expect(state.searchQuery).toBe(newQuery);
});
});
......
......@@ -22,7 +22,7 @@ describe('releases/util.js', () => {
tagName: 'tag-name',
name: 'Release name',
description: 'Release description',
milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
milestones: ['13.2', '13.3'],
assets: {
links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
},
......@@ -73,18 +73,6 @@ describe('releases/util.js', () => {
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
describe('when release.milestones is falsy', () => {
it('includes a "milestone" property in the returned result as an empty array', () => {
const release = {};
const expectedJson = {
milestones: [],
};
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
});
describe('apiJsonToRelease', () => {
......
......@@ -66,7 +66,7 @@ module Spec
focused_element.send_keys(:enter)
# Wait for the dropdown to be rendered
page.find('.project-milestone-combobox .dropdown-menu')
page.find('.milestone-combobox .dropdown-menu')
# Clear any existing input
focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) }
......@@ -75,7 +75,7 @@ module Spec
focused_element.send_keys(milestone_title, :enter)
# Wait for the search to return
page.find('.project-milestone-combobox .dropdown-item', text: milestone_title, match: :first)
page.find('.milestone-combobox .dropdown-item', text: milestone_title, match: :first)
focused_element.send_keys(:arrow_down, :arrow_down, :enter)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment