Commit 8eff7e11 authored by jakeburden's avatar jakeburden

Create release milestone combobox component

Adds a vue combobox to add/remove milestones
Alters the state of the release edit app
parent aaca948a
...@@ -23,6 +23,8 @@ const Api = { ...@@ -23,6 +23,8 @@ const Api = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners', projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
mergeRequestsPath: '/api/:version/merge_requests', mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels', groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
...@@ -248,6 +250,23 @@ const Api = { ...@@ -248,6 +250,23 @@ const Api = {
.then(({ data }) => data); .then(({ data }) => data);
}, },
projectSearch(id, options = {}) {
const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: {
search: options.search,
scope: options.scope,
},
});
},
projectMilestones(id) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
},
mergeRequests(params = {}) { mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath); const url = Api.buildUrl(Api.mergeRequestsPath);
......
<script>
import {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownHeader,
GlNewDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIcon,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Api from '~/api';
import createFlash from '~/flash';
import { intersection, debounce } from 'lodash';
export default {
components: {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownHeader,
GlNewDropdownItem,
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;
},
},
mounted() {
this.fetchMilestones();
},
methods: {
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: debounce(function 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;
});
}, 100),
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-new-dropdown>
<template slot="button-content">
<span ref="buttonText" class="flex-grow-1 ml-1 text-muted">
{{ selectedMilestonesLabel }}
</span>
<gl-icon name="chevron-down" />
</template>
<gl-new-dropdown-header>
<span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
</gl-new-dropdown-header>
<gl-new-dropdown-divider />
<gl-search-box-by-type
v-model.trim="searchQuery"
class="m-2"
:placeholder="this.$options.translations.searchMilestones"
@input="searchMilestones"
/>
<gl-new-dropdown-item @click="onMilestoneClicked(null)">
<span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">{{
$options.translations.noMilestone
}}</span>
</gl-new-dropdown-item>
<gl-new-dropdown-divider />
<template v-if="isLoading">
<gl-loading-icon />
<gl-new-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-new-dropdown-divider />
</template>
<template v-else-if="dropdownItems.length">
<gl-new-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-new-dropdown-item>
<gl-new-dropdown-divider />
</template>
<gl-new-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url">
<span class="pl-4">{{ item.text }}</span>
</gl-new-dropdown-item>
</gl-new-dropdown>
</template>
...@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants'; ...@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue'; import AssetLinksForm from './asset_links_form.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
export default { export default {
name: 'ReleaseEditApp', name: 'ReleaseEditApp',
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
GlButton, GlButton,
MarkdownField, MarkdownField,
AssetLinksForm, AssetLinksForm,
MilestoneCombobox,
}, },
directives: { directives: {
autofocusonshow, autofocusonshow,
...@@ -32,6 +34,10 @@ export default { ...@@ -32,6 +34,10 @@ export default {
'markdownPreviewPath', 'markdownPreviewPath',
'releasesPagePath', 'releasesPagePath',
'updateReleaseApiDocsPath', 'updateReleaseApiDocsPath',
'release',
'newMilestonePath',
'manageMilestonesPath',
'projectId',
]), ]),
...mapGetters('detail', ['isValid']), ...mapGetters('detail', ['isValid']),
showForm() { showForm() {
...@@ -82,6 +88,14 @@ export default { ...@@ -82,6 +88,14 @@ export default {
this.updateReleaseNotes(notes); this.updateReleaseNotes(notes);
}, },
}, },
releaseMilestones: {
get() {
return this.$store.state.detail.release.milestones;
},
set(milestones) {
this.updateReleaseMilestones(milestones);
},
},
cancelPath() { cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath; return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
}, },
...@@ -91,6 +105,18 @@ export default { ...@@ -91,6 +105,18 @@ export default {
isSaveChangesDisabled() { isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid; return this.isUpdatingRelease || !this.isValid;
}, },
milestoneComboboxExtraLinks() {
return [
{
text: __('Create new'),
url: this.newMilestonePath,
},
{
text: __('Manage milestones'),
url: this.manageMilestonesPath,
},
];
},
}, },
created() { created() {
this.fetchRelease(); this.fetchRelease();
...@@ -101,6 +127,7 @@ export default { ...@@ -101,6 +127,7 @@ export default {
'updateRelease', 'updateRelease',
'updateReleaseTitle', 'updateReleaseTitle',
'updateReleaseNotes', 'updateReleaseNotes',
'updateReleaseMilestones',
]), ]),
}, },
}; };
...@@ -137,6 +164,16 @@ export default { ...@@ -137,6 +164,16 @@ export default {
class="form-control" class="form-control"
/> />
</gl-form-group> </gl-form-group>
<gl-form-group class="w-50">
<label>{{ __('Milestones') }}</label>
<div class="d-flex flex-column col-md-6 col-sm-10 pl-0">
<milestone-combobox
v-model="releaseMilestones"
:project-id="projectId"
:extra-links="milestoneComboboxExtraLinks"
/>
</div>
</gl-form-group>
<gl-form-group> <gl-form-group>
<label for="release-notes">{{ __('Release notes') }}</label> <label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3"> <div class="bordered-box pr-3 pl-3">
...@@ -158,8 +195,7 @@ export default { ...@@ -158,8 +195,7 @@ export default {
:placeholder="__('Write your release notes or drag your files here…')" :placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()" @keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()" @keydown.ctrl.enter="updateRelease()"
> ></textarea>
</textarea>
</markdown-field> </markdown-field>
</div> </div>
</gl-form-group> </gl-form-group>
...@@ -174,12 +210,8 @@ export default { ...@@ -174,12 +210,8 @@ export default {
type="submit" type="submit"
:aria-label="__('Save changes')" :aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled" :disabled="isSaveChangesDisabled"
> >{{ __('Save changes') }}</gl-button>
{{ __('Save changes') }} <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">
{{ __('Cancel') }}
</gl-button>
</div> </div>
</form> </form>
</div> </div>
......
...@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => { ...@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => {
return api return api
.release(state.projectId, state.tagName) .release(state.projectId, state.tagName)
.then(({ data: release }) => { .then(({ data }) => {
const release = {
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true })); dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
}) })
.catch(error => { .catch(error => {
...@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => { ...@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => {
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
...@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => { ...@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease'); dispatch('requestUpdateRelease');
const { release } = state; const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return ( return (
api api
.updateRelease(state.projectId, state.tagName, { .updateRelease(state.projectId, state.tagName, {
name: release.name, name: release.name,
description: release.description, description: release.description,
milestones,
}) })
/** /**
......
...@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; ...@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
......
...@@ -28,6 +28,10 @@ export default { ...@@ -28,6 +28,10 @@ export default {
state.release.description = notes; state.release.description = notes;
}, },
[types.UPDATE_RELEASE_MILESTONES](state, milestones) {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) { [types.REQUEST_UPDATE_RELEASE](state) {
state.isUpdatingRelease = true; state.isUpdatingRelease = true;
}, },
......
...@@ -6,6 +6,8 @@ export default ({ ...@@ -6,6 +6,8 @@ export default ({
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath, updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
}) => ({ }) => ({
projectId, projectId,
tagName, tagName,
...@@ -14,6 +16,8 @@ export default ({ ...@@ -14,6 +16,8 @@ export default ({
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath, updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
/** The Release object */ /** The Release object */
release: null, release: null,
......
.selected-item::before {
content: '\f00c';
color: $green-500;
position: absolute;
left: 16px;
top: 16px;
transform: translateY(-50%);
font: 14px FontAwesome;
}
.dropdown-item-space {
padding: 8px 12px;
}
...@@ -30,7 +30,9 @@ module ReleasesHelper ...@@ -30,7 +30,9 @@ module ReleasesHelper
markdown_docs_path: help_page_path('user/markdown'), markdown_docs_path: help_page_path('user/markdown'),
releases_page_path: project_releases_path(@project, anchor: @release.tag), releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
release_assets_docs_path: help_page(anchor: 'release-assets') release_assets_docs_path: help_page(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project),
new_milestone_path: new_project_milestone_url(@project)
} }
end end
end end
---
title: 'Allow to assign milestones to a release on the "Edit Release page"'
merge_request: 28583
author:
type: added
...@@ -313,6 +313,9 @@ msgstr "" ...@@ -313,6 +313,9 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more" msgid "%{firstLabel} +%{labelCount} more"
msgstr "" msgstr ""
msgid "%{firstMilestoneName} + %{numberOfOtherMilestones} more"
msgstr ""
msgid "%{global_id} is not a valid id for %{expected_type}." msgid "%{global_id} is not a valid id for %{expected_type}."
msgstr "" msgstr ""
...@@ -2082,6 +2085,9 @@ msgstr "" ...@@ -2082,6 +2085,9 @@ msgstr ""
msgid "An error occurred while loading merge requests." msgid "An error occurred while loading merge requests."
msgstr "" msgstr ""
msgid "An error occurred while loading milestones"
msgstr ""
msgid "An error occurred while loading terraform report" msgid "An error occurred while loading terraform report"
msgstr "" msgstr ""
...@@ -2157,6 +2163,9 @@ msgstr "" ...@@ -2157,6 +2163,9 @@ msgstr ""
msgid "An error occurred while saving the template. Please check if the template exists." msgid "An error occurred while saving the template. Please check if the template exists."
msgstr "" msgstr ""
msgid "An error occurred while searching for milestones"
msgstr ""
msgid "An error occurred while subscribing to notifications." msgid "An error occurred while subscribing to notifications."
msgstr "" msgstr ""
...@@ -6127,6 +6136,9 @@ msgstr "" ...@@ -6127,6 +6136,9 @@ msgstr ""
msgid "Create milestone" msgid "Create milestone"
msgstr "" msgstr ""
msgid "Create new"
msgstr ""
msgid "Create new board" msgid "Create new board"
msgstr "" msgstr ""
...@@ -12545,6 +12557,9 @@ msgstr "" ...@@ -12545,6 +12557,9 @@ msgstr ""
msgid "Manage labels" msgid "Manage labels"
msgstr "" msgstr ""
msgid "Manage milestones"
msgstr ""
msgid "Manage project labels" msgid "Manage project labels"
msgstr "" msgstr ""
...@@ -13799,6 +13814,9 @@ msgstr "" ...@@ -13799,6 +13814,9 @@ msgstr ""
msgid "No messages were logged" msgid "No messages were logged"
msgstr "" msgstr ""
msgid "No milestone"
msgstr ""
msgid "No milestones to show" msgid "No milestones to show"
msgstr "" msgstr ""
...@@ -17897,6 +17915,9 @@ msgstr "" ...@@ -17897,6 +17915,9 @@ msgstr ""
msgid "Search Button" msgid "Search Button"
msgstr "" msgstr ""
msgid "Search Milestones"
msgstr ""
msgid "Search an environment spec" msgid "Search an environment spec"
msgstr "" msgstr ""
......
export const milestones = [
{
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',
},
{
id: 40,
iid: 5,
project_id: 8,
title: 'v4.0',
description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.',
state: 'closed',
created_at: '2020-01-13T19:39:15.191Z',
updated_at: '2020-01-13T19:39:15.191Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5',
},
{
id: 39,
iid: 4,
project_id: 8,
title: 'v3.0',
description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.',
state: 'closed',
created_at: '2020-01-13T19:39:15.176Z',
updated_at: '2020-01-13T19:39:15.176Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4',
},
{
id: 38,
iid: 3,
project_id: 8,
title: 'v2.0',
description: 'Doloribus qui repudiandae iste sit.',
state: 'closed',
created_at: '2020-01-13T19:39:15.161Z',
updated_at: '2020-01-13T19:39:15.161Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3',
},
{
id: 37,
iid: 2,
project_id: 8,
title: 'v1.0',
description: 'Illo sint odio officia ea.',
state: 'closed',
created_at: '2020-01-13T19:39:15.146Z',
updated_at: '2020-01-13T19:39:15.146Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2',
},
{
id: 36,
iid: 1,
project_id: 8,
title: 'v0.0',
description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.',
state: 'active',
created_at: '2020-01-13T19:39:15.127Z',
updated_at: '2020-01-13T19:39:15.127Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1',
},
];
export default milestones;
import { milestones as projectMilestones } from './mock_data';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/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 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(GlNewDropdown)).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, []);
wrapper.find(GlSearchBoxByType).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',
},
]);
wrapper.find(GlSearchBoxByType).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);
});
});
});
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ReleaseEditApp from '~/releases/components/app_edit.vue'; import ReleaseEditApp from '~/releases/components/app_edit.vue';
import { release as originalRelease } from '../mock_data'; import { release as originalRelease, milestones as originalMilestones } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants'; import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { merge } from 'lodash'; import { merge } from 'lodash';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
describe('Release edit component', () => { describe('Release edit component', () => {
let wrapper; let wrapper;
...@@ -13,6 +15,7 @@ describe('Release edit component', () => { ...@@ -13,6 +15,7 @@ describe('Release edit component', () => {
let actions; let actions;
let getters; let getters;
let state; let state;
let mock;
const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = { state = {
...@@ -20,6 +23,7 @@ describe('Release edit component', () => { ...@@ -20,6 +23,7 @@ describe('Release edit component', () => {
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs', updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page', releasesPagePath: 'path/to/releases/page',
projectId: '8',
}; };
actions = { actions = {
...@@ -62,8 +66,11 @@ describe('Release edit component', () => { ...@@ -62,8 +66,11 @@ describe('Release edit component', () => {
}; };
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios);
gon.api_version = 'v4'; gon.api_version = 'v4';
mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones);
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true }); release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
}); });
......
...@@ -130,6 +130,15 @@ describe('Release detail actions', () => { ...@@ -130,6 +130,15 @@ describe('Release detail actions', () => {
}); });
}); });
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
});
});
describe('requestUpdateRelease', () => { describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [ testAction(actions.requestUpdateRelease, undefined, state, [
...@@ -248,6 +257,7 @@ describe('Release detail actions', () => { ...@@ -248,6 +257,7 @@ describe('Release detail actions', () => {
{ {
name: state.release.name, name: state.release.name,
description: state.release.description, description: state.release.description,
milestones: state.release.milestones.map(milestone => milestone.title),
}, },
], ],
]); ]);
......
...@@ -54,7 +54,9 @@ describe ReleasesHelper do ...@@ -54,7 +54,9 @@ describe ReleasesHelper do
markdown_docs_path markdown_docs_path
releases_page_path releases_page_path
update_release_api_docs_path update_release_api_docs_path
release_assets_docs_path) release_assets_docs_path
manage_milestones_path
new_milestone_path)
expect(helper.data_for_edit_release_page.keys).to eq(keys) expect(helper.data_for_edit_release_page.keys).to eq(keys)
end end
end end
......
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