Commit c88b5e3b authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch...

Merge branch '232569-update-the-milestone-dropdown-combobox-to-display-separated-sections-and-badge-counters' into 'master'

Display sections and badge counters in milestone dropdown combobox

See merge request gitlab-org/gitlab!43427
parents 07999dca 6d2bc034
<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 ""
......@@ -16992,6 +16986,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 ""
......@@ -23061,9 +23076,6 @@ msgstr ""
msgid "Search Jira issues"
msgstr ""
msgid "Search Milestones"
msgstr ""
msgid "Search an environment spec"
msgstr ""
......@@ -27873,6 +27885,9 @@ msgstr ""
msgid "Total: %{total}"
msgstr ""
msgid "TotalMilestonesIndicator|1000+"
msgstr ""
msgid "TotalRefCountIndicator|1000+"
msgstr ""
......
This diff is collapsed.
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