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 = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
......@@ -248,6 +250,23 @@ const Api = {
.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 = {}) {
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';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
export default {
name: 'ReleaseEditApp',
......@@ -18,6 +19,7 @@ export default {
GlButton,
MarkdownField,
AssetLinksForm,
MilestoneCombobox,
},
directives: {
autofocusonshow,
......@@ -32,6 +34,10 @@ export default {
'markdownPreviewPath',
'releasesPagePath',
'updateReleaseApiDocsPath',
'release',
'newMilestonePath',
'manageMilestonesPath',
'projectId',
]),
...mapGetters('detail', ['isValid']),
showForm() {
......@@ -82,6 +88,14 @@ export default {
this.updateReleaseNotes(notes);
},
},
releaseMilestones: {
get() {
return this.$store.state.detail.release.milestones;
},
set(milestones) {
this.updateReleaseMilestones(milestones);
},
},
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
......@@ -91,6 +105,18 @@ export default {
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
milestoneComboboxExtraLinks() {
return [
{
text: __('Create new'),
url: this.newMilestonePath,
},
{
text: __('Manage milestones'),
url: this.manageMilestonesPath,
},
];
},
},
created() {
this.fetchRelease();
......@@ -101,6 +127,7 @@ export default {
'updateRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
]),
},
};
......@@ -137,6 +164,16 @@ export default {
class="form-control"
/>
</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>
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
......@@ -158,8 +195,7 @@ export default {
:placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()"
>
</textarea>
></textarea>
</markdown-field>
</div>
</gl-form-group>
......@@ -174,12 +210,8 @@ export default {
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
>
{{ __('Save changes') }}
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">
{{ __('Cancel') }}
</gl-button>
>{{ __('Save changes') }}</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
</div>
......
......@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => {
return api
.release(state.projectId, state.tagName)
.then(({ data: release }) => {
.then(({ data }) => {
const release = {
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
})
.catch(error => {
......@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => {
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
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 receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
......@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return (
api
.updateRelease(state.projectId, state.tagName, {
name: release.name,
description: release.description,
milestones,
})
/**
......
......@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
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 RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
......
......@@ -28,6 +28,10 @@ export default {
state.release.description = notes;
},
[types.UPDATE_RELEASE_MILESTONES](state, milestones) {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) {
state.isUpdatingRelease = true;
},
......
......@@ -6,6 +6,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
}) => ({
projectId,
tagName,
......@@ -14,6 +16,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
/** The Release object */
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
markdown_docs_path: help_page_path('user/markdown'),
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'),
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
---
title: 'Allow to assign milestones to a release on the "Edit Release page"'
merge_request: 28583
author:
type: added
......@@ -313,6 +313,9 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more"
msgstr ""
msgid "%{firstMilestoneName} + %{numberOfOtherMilestones} more"
msgstr ""
msgid "%{global_id} is not a valid id for %{expected_type}."
msgstr ""
......@@ -2082,6 +2085,9 @@ msgstr ""
msgid "An error occurred while loading merge requests."
msgstr ""
msgid "An error occurred while loading milestones"
msgstr ""
msgid "An error occurred while loading terraform report"
msgstr ""
......@@ -2157,6 +2163,9 @@ msgstr ""
msgid "An error occurred while saving the template. Please check if the template exists."
msgstr ""
msgid "An error occurred while searching for milestones"
msgstr ""
msgid "An error occurred while subscribing to notifications."
msgstr ""
......@@ -6127,6 +6136,9 @@ msgstr ""
msgid "Create milestone"
msgstr ""
msgid "Create new"
msgstr ""
msgid "Create new board"
msgstr ""
......@@ -12545,6 +12557,9 @@ msgstr ""
msgid "Manage labels"
msgstr ""
msgid "Manage milestones"
msgstr ""
msgid "Manage project labels"
msgstr ""
......@@ -13799,6 +13814,9 @@ msgstr ""
msgid "No messages were logged"
msgstr ""
msgid "No milestone"
msgstr ""
msgid "No milestones to show"
msgstr ""
......@@ -17897,6 +17915,9 @@ msgstr ""
msgid "Search Button"
msgstr ""
msgid "Search Milestones"
msgstr ""
msgid "Search an environment spec"
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 { mount } from '@vue/test-utils';
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 { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { merge } from 'lodash';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
describe('Release edit component', () => {
let wrapper;
......@@ -13,6 +15,7 @@ describe('Release edit component', () => {
let actions;
let getters;
let state;
let mock;
const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = {
......@@ -20,6 +23,7 @@ describe('Release edit component', () => {
markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
projectId: '8',
};
actions = {
......@@ -62,8 +66,11 @@ describe('Release edit component', () => {
};
beforeEach(() => {
mock = new MockAdapter(axios);
gon.api_version = 'v4';
mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones);
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
......
......@@ -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', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
......@@ -248,6 +257,7 @@ describe('Release detail actions', () => {
{
name: state.release.name,
description: state.release.description,
milestones: state.release.milestones.map(milestone => milestone.title),
},
],
]);
......
......@@ -54,7 +54,9 @@ describe ReleasesHelper do
markdown_docs_path
releases_page_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)
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