Commit 859681c6 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'nfriend-dedicated-release-page' into 'master'

Create dedicated release page for each Release

See merge request gitlab-org/gitlab!24006
parents fa534442 953027b6
import initShowRelease from '~/releases/mount_show';
document.addEventListener('DOMContentLoaded', initShowRelease);
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { GlButton, GlLink, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { escape as esc } from 'lodash'; import { escape as esc } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
export default { export default {
name: 'ReleaseEditApp', name: 'ReleaseEditApp',
...@@ -12,6 +14,7 @@ export default { ...@@ -12,6 +14,7 @@ export default {
GlFormInput, GlFormInput,
GlFormGroup, GlFormGroup,
GlButton, GlButton,
GlLink,
MarkdownField, MarkdownField,
}, },
directives: { directives: {
...@@ -74,6 +77,9 @@ export default { ...@@ -74,6 +77,9 @@ export default {
this.updateReleaseNotes(notes); this.updateReleaseNotes(notes);
}, },
}, },
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
}, },
created() { created() {
this.fetchRelease(); this.fetchRelease();
...@@ -84,7 +90,6 @@ export default { ...@@ -84,7 +90,6 @@ export default {
'updateRelease', 'updateRelease',
'updateReleaseTitle', 'updateReleaseTitle',
'updateReleaseNotes', 'updateReleaseNotes',
'navigateToReleasesPage',
]), ]),
}, },
}; };
...@@ -157,15 +162,9 @@ export default { ...@@ -157,15 +162,9 @@ export default {
> >
{{ __('Save changes') }} {{ __('Save changes') }}
</gl-button> </gl-button>
<gl-button <gl-link :href="cancelPath" class="js-cancel-button btn btn-default">
class="js-cancel-button"
variant="default"
type="button"
:aria-label="__('Cancel')"
@click="navigateToReleasesPage()"
>
{{ __('Cancel') }} {{ __('Cancel') }}
</gl-button> </gl-link>
</div> </div>
</form> </form>
</div> </div>
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import ReleaseBlock from './release_block.vue';
export default {
name: 'ReleaseShowApp',
components: {
GlSkeletonLoading,
ReleaseBlock,
},
computed: {
...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']),
},
created() {
this.fetchRelease();
},
methods: {
...mapActions('detail', ['fetchRelease']),
},
};
</script>
<template>
<div class="prepend-top-default">
<gl-skeleton-loading v-if="isFetchingRelease" />
<release-block v-else-if="!fetchError" :release="release" />
</div>
</template>
<script> <script>
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
export default { export default {
name: 'ReleaseBlockHeader', name: 'ReleaseBlockHeader',
...@@ -20,7 +22,15 @@ export default { ...@@ -20,7 +22,15 @@ export default {
}, },
computed: { computed: {
editLink() { editLink() {
return this.release._links?.editUrl; if (this.release._links?.editUrl) {
const queryParams = {
[BACK_URL_PARAM]: window.location.href,
};
return setUrlParams(queryParams, this.release._links.editUrl);
}
return undefined;
}, },
selfLink() { selfLink() {
return this.release._links?.self; return this.release._links?.self;
......
/* eslint-disable import/prefer-default-export */
// This eslint-disable ^^^ can be removed when at least
// one more constant is added to this file. Currently
// constants.js files with only a single constant
// are flagged by this rule.
export const MAX_MILESTONES_TO_DISPLAY = 5; export const MAX_MILESTONES_TO_DISPLAY = 5;
export const BACK_URL_PARAM = 'back_url';
...@@ -6,7 +6,15 @@ import detailModule from './stores/modules/detail'; ...@@ -6,7 +6,15 @@ import detailModule from './stores/modules/detail';
export default () => { export default () => {
const el = document.getElementById('js-edit-release-page'); const el = document.getElementById('js-edit-release-page');
const store = createStore({ detail: detailModule }); const store = createStore({
modules: {
detail: detailModule,
},
featureFlags: {
releaseShowPage: Boolean(gon.features?.releaseShowPage),
},
});
store.dispatch('detail/setInitialState', el.dataset); store.dispatch('detail/setInitialState', el.dataset);
return new Vue({ return new Vue({
......
...@@ -8,7 +8,11 @@ export default () => { ...@@ -8,7 +8,11 @@ export default () => {
return new Vue({ return new Vue({
el, el,
store: createStore({ list: listModule }), store: createStore({
modules: {
list: listModule,
},
}),
render: h => render: h =>
h(ReleaseListApp, { h(ReleaseListApp, {
props: { props: {
......
import Vue from 'vue';
import ReleaseShowApp from './components/app_show.vue';
import createStore from './stores';
import detailModule from './stores/modules/detail';
export default () => {
const el = document.getElementById('js-show-release-page');
const store = createStore({
modules: {
detail: detailModule,
},
});
store.dispatch('detail/setInitialState', el.dataset);
return new Vue({
el,
store,
render: h => h(ReleaseShowApp),
});
};
...@@ -3,4 +3,8 @@ import Vuex from 'vuex'; ...@@ -3,4 +3,8 @@ import Vuex from 'vuex';
Vue.use(Vuex); Vue.use(Vuex);
export default modules => new Vuex.Store({ modules }); export default ({ modules, featureFlags }) =>
new Vuex.Store({
modules,
state: { featureFlags },
});
...@@ -33,9 +33,11 @@ export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_REL ...@@ -33,9 +33,11 @@ export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_REL
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, dispatch }) => { export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS); commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
dispatch('navigateToReleasesPage'); redirectTo(
rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath,
);
}; };
export const receiveUpdateReleaseError = ({ commit }, error) => { export const receiveUpdateReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
......
...@@ -6,22 +6,27 @@ describe 'User edits Release', :js do ...@@ -6,22 +6,27 @@ describe 'User edits Release', :js do
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:release) { create(:release, project: project, name: 'The first release' ) } let_it_be(:release) { create(:release, project: project, name: 'The first release' ) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:show_feature_flag) { true }
before do before do
stub_feature_flags(release_show_page: show_feature_flag)
project.add_developer(user) project.add_developer(user)
gitlab_sign_in(user) gitlab_sign_in(user)
visit edit_project_release_path(project, release) visit edit_project_release_path(project, release)
wait_for_requests
end end
def fill_out_form_and_click(button_to_click) def fill_out_form_and_click(button_to_click)
fill_in 'Release title', with: 'Updated Release title' fill_in 'Release title', with: 'Updated Release title'
fill_in 'Release notes', with: 'Updated Release notes' fill_in 'Release notes', with: 'Updated Release notes'
click_button button_to_click click_link_or_button button_to_click
wait_for_requests wait_for_all_requests
end end
it 'renders the breadcrumbs' do it 'renders the breadcrumbs' do
...@@ -42,31 +47,66 @@ describe 'User edits Release', :js do ...@@ -42,31 +47,66 @@ describe 'User edits Release', :js do
expect(find_field('Release notes').value).to eq(release.description) expect(find_field('Release notes').value).to eq(release.description)
expect(page).to have_button('Save changes') expect(page).to have_button('Save changes')
expect(page).to have_button('Cancel') expect(page).to have_link('Cancel')
end end
it 'redirects to the main Releases page without updating the Release when "Cancel" is clicked' do it 'does not update the Release when "Cancel" is clicked' do
original_name = release.name original_name = release.name
original_description = release.description original_description = release.description
fill_out_form_and_click 'Cancel' fill_out_form_and_click 'Cancel'
expect(current_path).to eq(project_releases_path(project))
release.reload release.reload
expect(release.name).to eq(original_name) expect(release.name).to eq(original_name)
expect(release.description).to eq(original_description) expect(release.description).to eq(original_description)
end end
it 'updates the Release and redirects to the main Releases page when "Save changes" is clicked' do it 'updates the Release when "Save changes" is clicked' do
fill_out_form_and_click 'Save changes' fill_out_form_and_click 'Save changes'
expect(current_path).to eq(project_releases_path(project))
release.reload release.reload
expect(release.name).to eq('Updated Release title') expect(release.name).to eq('Updated Release title')
expect(release.description).to eq('Updated Release notes') expect(release.description).to eq('Updated Release notes')
end end
context 'when the release_show_page feature flag is disabled' do
let(:show_feature_flag) { false }
it 'redirects to the main Releases page when "Cancel" is clicked' do
fill_out_form_and_click 'Cancel'
expect(page).to have_current_path(project_releases_path(project))
end
it 'redirects to the main Releases page when "Save changes" is clicked' do
fill_out_form_and_click 'Save changes'
expect(page).to have_current_path(project_releases_path(project))
end
end
context 'when the release_show_page feature flag is enabled' do
it 'redirects to the previous page when "Cancel" is clicked when the url includes a back_url query parameter' do
back_path = project_releases_path(project, params: { page: 2 })
visit edit_project_release_path(project, release, params: { back_url: back_path })
fill_out_form_and_click 'Cancel'
expect(page).to have_current_path(back_path)
end
it 'redirects to the main Releases page when "Cancel" is clicked when the url does not include a back_url query parameter' do
fill_out_form_and_click 'Cancel'
expect(page).to have_current_path(project_releases_path(project))
end
it 'redirects to the dedicated Release page when "Save changes" is clicked' do
fill_out_form_and_click 'Save changes'
expect(page).to have_current_path(project_release_path(project, release))
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe 'User views Release', :js do
let(:project) { create(:project, :repository) }
let(:release) { create(:release, project: project, name: 'The first release' ) }
let(:user) { create(:user) }
before do
project.add_developer(user)
gitlab_sign_in(user)
visit project_release_path(project, release)
end
it 'renders the breadcrumbs' do
within('.breadcrumbs') do
expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}")
expect(page).to have_link(project.creator.name, href: user_path(project.creator))
expect(page).to have_link(project.name, href: project_path(project))
expect(page).to have_link('Releases', href: project_releases_path(project))
expect(page).to have_link(release.name, href: project_release_path(project, release))
end
end
it 'renders the release details' do
within('.release-block') do
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
expect(page).to have_content(release.commit.short_id)
expect(page).to have_content(release.description)
end
end
end
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 } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
describe('Release edit component', () => { describe('Release edit component', () => {
let wrapper; let wrapper;
let releaseClone; let release;
let actions; let actions;
let state; let state;
beforeEach(() => { const factory = () => {
gon.api_version = 'v4';
releaseClone = convertObjectPropsToCamelCase(release, { deep: true });
state = { state = {
release: releaseClone, release,
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',
}; };
actions = { actions = {
fetchRelease: jest.fn(), fetchRelease: jest.fn(),
updateRelease: jest.fn(), updateRelease: jest.fn(),
navigateToReleasesPage: jest.fn(),
}; };
const store = new Vuex.Store({ const store = new Vuex.Store({
...@@ -40,58 +37,99 @@ describe('Release edit component', () => { ...@@ -40,58 +37,99 @@ describe('Release edit component', () => {
wrapper = mount(ReleaseEditApp, { wrapper = mount(ReleaseEditApp, {
store, store,
}); });
};
return wrapper.vm.$nextTick(); beforeEach(() => {
}); gon.api_version = 'v4';
it('calls fetchRelease when the component is created', () => { release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
}); });
it('renders the description text at the top of the page', () => { afterEach(() => {
expect(wrapper.find('.js-subtitle-text').text()).toBe( wrapper.destroy();
'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example v1.0, v2.0-pre.', wrapper = null;
);
}); });
it('renders the correct tag name in the "Tag name" field', () => { describe(`basic functionality tests: all tests unrelated to the "${BACK_URL_PARAM}" parameter`, () => {
expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName); beforeEach(() => {
}); factory();
});
it('renders the correct help text under the "Tag name" field', () => { it('calls fetchRelease when the component is created', () => {
const helperText = wrapper.find('#tag-name-help'); expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
const helperTextLink = helperText.find('a'); });
const helperTextLinkAttrs = helperTextLink.attributes();
expect(helperText.text()).toBe(
'Changing a Release tag is only supported via Releases API. More information',
);
expect(helperTextLink.text()).toBe('More information');
expect(helperTextLinkAttrs.href).toBe(state.updateReleaseApiDocsPath);
expect(helperTextLinkAttrs.rel).toContain('noopener');
expect(helperTextLinkAttrs.rel).toContain('noreferrer');
expect(helperTextLinkAttrs.target).toBe('_blank');
});
it('renders the correct release title in the "Release title" field', () => { it('renders the description text at the top of the page', () => {
expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name); expect(wrapper.find('.js-subtitle-text').text()).toBe(
}); 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example v1.0, v2.0-pre.',
);
});
it('renders the release notes in the "Release notes" textarea', () => { it('renders the correct tag name in the "Tag name" field', () => {
expect(wrapper.find('#release-notes').element.value).toBe(releaseClone.description); expect(wrapper.find('#git-ref').element.value).toBe(release.tagName);
}); });
it('renders the correct help text under the "Tag name" field', () => {
const helperText = wrapper.find('#tag-name-help');
const helperTextLink = helperText.find('a');
const helperTextLinkAttrs = helperTextLink.attributes();
expect(helperText.text()).toBe(
'Changing a Release tag is only supported via Releases API. More information',
);
expect(helperTextLink.text()).toBe('More information');
expect(helperTextLinkAttrs).toEqual(
expect.objectContaining({
href: state.updateReleaseApiDocsPath,
rel: 'noopener noreferrer',
target: '_blank',
}),
);
});
it('renders the correct release title in the "Release title" field', () => {
expect(wrapper.find('#release-title').element.value).toBe(release.name);
});
it('renders the release notes in the "Release notes" textarea', () => {
expect(wrapper.find('#release-notes').element.value).toBe(release.description);
});
it('renders the "Save changes" button as type="submit"', () => {
expect(wrapper.find('.js-submit-button').attributes('type')).toBe('submit');
});
it('renders the "Save changes" button as type="submit"', () => { it('calls updateRelease when the form is submitted', () => {
expect(wrapper.find('.js-submit-button').attributes('type')).toBe('submit'); wrapper.find('form').trigger('submit');
expect(actions.updateRelease).toHaveBeenCalledTimes(1);
});
}); });
it('calls updateRelease when the form is submitted', () => { describe(`when the URL does not contain a "${BACK_URL_PARAM}" parameter`, () => {
wrapper.find('form').trigger('submit'); beforeEach(() => {
expect(actions.updateRelease).toHaveBeenCalledTimes(1); factory();
});
it(`renders a "Cancel" button with an href pointing to "${BACK_URL_PARAM}"`, () => {
const cancelButton = wrapper.find('.js-cancel-button');
expect(cancelButton.attributes().href).toBe(state.releasesPagePath);
});
}); });
it('calls navigateToReleasesPage when the "Cancel" button is clicked', () => { describe(`when the URL contains a "${BACK_URL_PARAM}" parameter`, () => {
wrapper.find('.js-cancel-button').vm.$emit('click'); const backUrl = 'https://example.gitlab.com/back/url';
expect(actions.navigateToReleasesPage).toHaveBeenCalledTimes(1);
beforeEach(() => {
commonUtils.getParameterByName = jest
.fn()
.mockImplementation(paramToGet => ({ [BACK_URL_PARAM]: backUrl }[paramToGet]));
factory();
});
it('renders a "Cancel" button with an href pointing to the main Releases page', () => {
const cancelButton = wrapper.find('.js-cancel-button');
expect(cancelButton.attributes().href).toBe(backUrl);
});
}); });
}); });
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import ReleaseShowApp from '~/releases/components/app_show.vue';
import { release as originalRelease } from '../mock_data';
import { GlSkeletonLoading } from '@gitlab/ui';
import ReleaseBlock from '~/releases/components/release_block.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('Release show component', () => {
let wrapper;
let release;
let actions;
beforeEach(() => {
release = convertObjectPropsToCamelCase(originalRelease);
});
const factory = state => {
actions = {
fetchRelease: jest.fn(),
};
const store = new Vuex.Store({
modules: {
detail: {
namespaced: true,
actions,
state,
},
},
});
wrapper = shallowMount(ReleaseShowApp, { store });
};
const findLoadingSkeleton = () => wrapper.find(GlSkeletonLoading);
const findReleaseBlock = () => wrapper.find(ReleaseBlock);
it('calls fetchRelease when the component is created', () => {
factory({ release });
expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
});
it('shows a loading skeleton and hides the release block while the API call is in progress', () => {
factory({ isFetchingRelease: true });
expect(findLoadingSkeleton().exists()).toBe(true);
expect(findReleaseBlock().exists()).toBe(false);
});
it('hides the loading skeleton and shows the release block when the API call finishes successfully', () => {
factory({ isFetchingRelease: false });
expect(findLoadingSkeleton().exists()).toBe(false);
expect(findReleaseBlock().exists()).toBe(true);
});
it('hides both the loading skeleton and the release block when the API call fails', () => {
factory({ fetchError: new Error('Uh oh') });
expect(findLoadingSkeleton().exists()).toBe(false);
expect(findReleaseBlock().exists()).toBe(false);
});
});
...@@ -4,6 +4,7 @@ import { GlLink } from '@gitlab/ui'; ...@@ -4,6 +4,7 @@ import { GlLink } from '@gitlab/ui';
import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { release as originalRelease } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import { BACK_URL_PARAM } from '~/releases/constants';
describe('Release block header', () => { describe('Release block header', () => {
let wrapper; let wrapper;
...@@ -27,6 +28,7 @@ describe('Release block header', () => { ...@@ -27,6 +28,7 @@ describe('Release block header', () => {
const findHeader = () => wrapper.find('h2'); const findHeader = () => wrapper.find('h2');
const findHeaderLink = () => findHeader().find(GlLink); const findHeaderLink = () => findHeader().find(GlLink);
const findEditButton = () => wrapper.find('.js-edit-button');
describe('when _links.self is provided', () => { describe('when _links.self is provided', () => {
beforeEach(() => { beforeEach(() => {
...@@ -51,4 +53,39 @@ describe('Release block header', () => { ...@@ -51,4 +53,39 @@ describe('Release block header', () => {
expect(findHeaderLink().exists()).toBe(false); expect(findHeaderLink().exists()).toBe(false);
}); });
}); });
describe('when _links.edit_url is provided', () => {
const currentUrl = 'https://example.gitlab.com/path';
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: {
href: currentUrl,
},
});
factory();
});
it('renders an edit button', () => {
expect(findEditButton().exists()).toBe(true);
});
it('renders the edit button with the correct href', () => {
const expectedQueryParam = `${BACK_URL_PARAM}=${encodeURIComponent(currentUrl)}`;
const expectedUrl = `${release._links.editUrl}?${expectedQueryParam}`;
expect(findEditButton().attributes().href).toBe(expectedUrl);
});
});
describe('when _links.edit is missing', () => {
beforeEach(() => {
factory({ _links: { editUrl: null } });
});
it('does not render an edit button', () => {
expect(findEditButton().exists()).toBe(false);
});
});
}); });
...@@ -7,20 +7,9 @@ import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; ...@@ -7,20 +7,9 @@ import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { release as originalRelease } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { scrollToElement } from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
const { convertObjectPropsToCamelCase } = jest.requireActual('~/lib/utils/common_utils'); import * as urlUtility from '~/lib/utils/url_utility';
let mockLocationHash;
jest.mock('~/lib/utils/url_utility', () => ({
__esModule: true,
getLocationHash: jest.fn().mockImplementation(() => mockLocationHash),
}));
jest.mock('~/lib/utils/common_utils', () => ({
__esModule: true,
scrollToElement: jest.fn(),
}));
describe('Release block', () => { describe('Release block', () => {
let wrapper; let wrapper;
...@@ -47,7 +36,7 @@ describe('Release block', () => { ...@@ -47,7 +36,7 @@ describe('Release block', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn($.fn, 'renderGFM'); jest.spyOn($.fn, 'renderGFM');
release = convertObjectPropsToCamelCase(originalRelease, { deep: true }); release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
}); });
afterEach(() => { afterEach(() => {
...@@ -61,9 +50,11 @@ describe('Release block', () => { ...@@ -61,9 +50,11 @@ describe('Release block', () => {
expect(wrapper.attributes().id).toBe('v0.3'); expect(wrapper.attributes().id).toBe('v0.3');
}); });
it('renders an edit button that links to the "Edit release" page', () => { it(`renders an edit button that links to the "Edit release" page with a "${BACK_URL_PARAM}" parameter`, () => {
expect(editButton().exists()).toBe(true); expect(editButton().exists()).toBe(true);
expect(editButton().attributes('href')).toBe(release._links.editUrl); expect(editButton().attributes('href')).toBe(
`${release._links.editUrl}?${BACK_URL_PARAM}=${encodeURIComponent(window.location.href)}`,
);
}); });
it('renders release name', () => { it('renders release name', () => {
...@@ -150,14 +141,6 @@ describe('Release block', () => { ...@@ -150,14 +141,6 @@ describe('Release block', () => {
}); });
}); });
it("does not render an edit button if release._links.editUrl isn't a string", () => {
delete release._links;
return factory(release).then(() => {
expect(editButton().exists()).toBe(false);
});
});
it('does not render the milestone list if no milestones are associated to the release', () => { it('does not render the milestone list if no milestones are associated to the release', () => {
delete release.milestones; delete release.milestones;
...@@ -203,37 +186,40 @@ describe('Release block', () => { ...@@ -203,37 +186,40 @@ describe('Release block', () => {
}); });
describe('anchor scrolling', () => { describe('anchor scrolling', () => {
let locationHash;
beforeEach(() => { beforeEach(() => {
scrollToElement.mockClear(); commonUtils.scrollToElement = jest.fn();
urlUtility.getLocationHash = jest.fn().mockImplementation(() => locationHash);
}); });
const hasTargetBlueBackground = () => wrapper.classes('bg-line-target-blue'); const hasTargetBlueBackground = () => wrapper.classes('bg-line-target-blue');
it('does not attempt to scroll the page if no anchor tag is included in the URL', () => { it('does not attempt to scroll the page if no anchor tag is included in the URL', () => {
mockLocationHash = ''; locationHash = '';
return factory(release).then(() => { return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled(); expect(commonUtils.scrollToElement).not.toHaveBeenCalled();
}); });
}); });
it("does not attempt to scroll the page if the anchor tag doesn't match the release's tag name", () => { it("does not attempt to scroll the page if the anchor tag doesn't match the release's tag name", () => {
mockLocationHash = 'v0.4'; locationHash = 'v0.4';
return factory(release).then(() => { return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled(); expect(commonUtils.scrollToElement).not.toHaveBeenCalled();
}); });
}); });
it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => { it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
mockLocationHash = release.tagName; locationHash = release.tagName;
return factory(release).then(() => { return factory(release).then(() => {
expect(scrollToElement).toHaveBeenCalledTimes(1); expect(commonUtils.scrollToElement).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledWith(wrapper.element); expect(commonUtils.scrollToElement).toHaveBeenCalledWith(wrapper.element);
}); });
}); });
it('renders with a light blue background if it is the target of the anchor', () => { it('renders with a light blue background if it is the target of the anchor', () => {
mockLocationHash = release.tagName; locationHash = release.tagName;
return factory(release).then(() => { return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(true); expect(hasTargetBlueBackground()).toBe(true);
...@@ -241,7 +227,7 @@ describe('Release block', () => { ...@@ -241,7 +227,7 @@ describe('Release block', () => {
}); });
it('does not render with a light blue background if it is not the target of the anchor', () => { it('does not render with a light blue background if it is not the target of the anchor', () => {
mockLocationHash = ''; locationHash = '';
return factory(release).then(() => { return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(false); expect(hasTargetBlueBackground()).toBe(false);
......
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { cloneDeep, merge } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions'; import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types'; import * as types from '~/releases/stores/modules/detail/mutation_types';
import { release } from '../../../mock_data'; import { release as originalRelease } from '../../../mock_data';
import state from '~/releases/stores/modules/detail/state'; import createState from '~/releases/stores/modules/detail/state';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/flash', () => jest.fn()); jest.mock('~/flash', () => jest.fn());
...@@ -17,14 +18,14 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -17,14 +18,14 @@ jest.mock('~/lib/utils/url_utility', () => ({
})); }));
describe('Release detail actions', () => { describe('Release detail actions', () => {
let stateClone; let state;
let releaseClone; let release;
let mock; let mock;
let error; let error;
beforeEach(() => { beforeEach(() => {
stateClone = state(); state = createState();
releaseClone = JSON.parse(JSON.stringify(release)); release = cloneDeep(originalRelease);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
gon.api_version = 'v4'; gon.api_version = 'v4';
error = { message: 'An error occurred' }; error = { message: 'An error occurred' };
...@@ -39,7 +40,7 @@ describe('Release detail actions', () => { ...@@ -39,7 +40,7 @@ describe('Release detail actions', () => {
it(`commits ${types.SET_INITIAL_STATE} with the provided object`, () => { it(`commits ${types.SET_INITIAL_STATE} with the provided object`, () => {
const initialState = {}; const initialState = {};
return testAction(actions.setInitialState, initialState, stateClone, [ return testAction(actions.setInitialState, initialState, state, [
{ type: types.SET_INITIAL_STATE, payload: initialState }, { type: types.SET_INITIAL_STATE, payload: initialState },
]); ]);
}); });
...@@ -47,19 +48,19 @@ describe('Release detail actions', () => { ...@@ -47,19 +48,19 @@ describe('Release detail actions', () => {
describe('requestRelease', () => { describe('requestRelease', () => {
it(`commits ${types.REQUEST_RELEASE}`, () => it(`commits ${types.REQUEST_RELEASE}`, () =>
testAction(actions.requestRelease, undefined, stateClone, [{ type: types.REQUEST_RELEASE }])); testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }]));
}); });
describe('receiveReleaseSuccess', () => { describe('receiveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () => it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveReleaseSuccess, releaseClone, stateClone, [ testAction(actions.receiveReleaseSuccess, release, state, [
{ type: types.RECEIVE_RELEASE_SUCCESS, payload: releaseClone }, { type: types.RECEIVE_RELEASE_SUCCESS, payload: release },
])); ]));
}); });
describe('receiveReleaseError', () => { describe('receiveReleaseError', () => {
it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () => it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
testAction(actions.receiveReleaseError, error, stateClone, [ testAction(actions.receiveReleaseError, error, state, [
{ type: types.RECEIVE_RELEASE_ERROR, payload: error }, { type: types.RECEIVE_RELEASE_ERROR, payload: error },
])); ]));
...@@ -77,24 +78,24 @@ describe('Release detail actions', () => { ...@@ -77,24 +78,24 @@ describe('Release detail actions', () => {
let getReleaseUrl; let getReleaseUrl;
beforeEach(() => { beforeEach(() => {
stateClone.projectId = '18'; state.projectId = '18';
stateClone.tagName = 'v1.3'; state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`; getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
}); });
it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => { it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
mock.onGet(getReleaseUrl).replyOnce(200, releaseClone); mock.onGet(getReleaseUrl).replyOnce(200, release);
return testAction( return testAction(
actions.fetchRelease, actions.fetchRelease,
undefined, undefined,
stateClone, state,
[], [],
[ [
{ type: 'requestRelease' }, { type: 'requestRelease' },
{ {
type: 'receiveReleaseSuccess', type: 'receiveReleaseSuccess',
payload: convertObjectPropsToCamelCase(releaseClone, { deep: true }), payload: convertObjectPropsToCamelCase(release, { deep: true }),
}, },
], ],
); );
...@@ -106,7 +107,7 @@ describe('Release detail actions', () => { ...@@ -106,7 +107,7 @@ describe('Release detail actions', () => {
return testAction( return testAction(
actions.fetchRelease, actions.fetchRelease,
undefined, undefined,
stateClone, state,
[], [],
[{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }], [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
); );
...@@ -116,7 +117,7 @@ describe('Release detail actions', () => { ...@@ -116,7 +117,7 @@ describe('Release detail actions', () => {
describe('updateReleaseTitle', () => { describe('updateReleaseTitle', () => {
it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
const newTitle = 'The new release title'; const newTitle = 'The new release title';
return testAction(actions.updateReleaseTitle, newTitle, stateClone, [ return testAction(actions.updateReleaseTitle, newTitle, state, [
{ type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, { type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
]); ]);
}); });
...@@ -125,7 +126,7 @@ describe('Release detail actions', () => { ...@@ -125,7 +126,7 @@ describe('Release detail actions', () => {
describe('updateReleaseNotes', () => { describe('updateReleaseNotes', () => {
it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
const newReleaseNotes = 'The new release notes'; const newReleaseNotes = 'The new release notes';
return testAction(actions.updateReleaseNotes, newReleaseNotes, stateClone, [ return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [
{ type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
]); ]);
}); });
...@@ -133,25 +134,40 @@ describe('Release detail actions', () => { ...@@ -133,25 +134,40 @@ describe('Release detail actions', () => {
describe('requestUpdateRelease', () => { describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, stateClone, [ testAction(actions.requestUpdateRelease, undefined, state, [
{ type: types.REQUEST_UPDATE_RELEASE }, { type: types.REQUEST_UPDATE_RELEASE },
])); ]));
}); });
describe('receiveUpdateReleaseSuccess', () => { describe('receiveUpdateReleaseSuccess', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
testAction( testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
actions.receiveUpdateReleaseSuccess, { type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
undefined, ]));
stateClone,
[{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }], describe('when the releaseShowPage feature flag is enabled', () => {
[{ type: 'navigateToReleasesPage' }], const rootState = { featureFlags: { releaseShowPage: true } };
)); const updatedState = merge({}, state, {
releasesPagePath: 'path/to/releases/page',
release: {
_links: {
self: 'path/to/self',
},
},
});
actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
});
describe('when the releaseShowPage feature flag is disabled', () => {});
}); });
describe('receiveUpdateReleaseError', () => { describe('receiveUpdateReleaseError', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
testAction(actions.receiveUpdateReleaseError, error, stateClone, [ testAction(actions.receiveUpdateReleaseError, error, state, [
{ type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error }, { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
])); ]));
...@@ -169,10 +185,10 @@ describe('Release detail actions', () => { ...@@ -169,10 +185,10 @@ describe('Release detail actions', () => {
let getReleaseUrl; let getReleaseUrl;
beforeEach(() => { beforeEach(() => {
stateClone.release = releaseClone; state.release = release;
stateClone.projectId = '18'; state.projectId = '18';
stateClone.tagName = 'v1.3'; state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`; getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
}); });
it(`dispatches requestUpdateRelease and receiveUpdateReleaseSuccess`, () => { it(`dispatches requestUpdateRelease and receiveUpdateReleaseSuccess`, () => {
...@@ -181,7 +197,7 @@ describe('Release detail actions', () => { ...@@ -181,7 +197,7 @@ describe('Release detail actions', () => {
return testAction( return testAction(
actions.updateRelease, actions.updateRelease,
undefined, undefined,
stateClone, state,
[], [],
[{ type: 'requestUpdateRelease' }, { type: 'receiveUpdateReleaseSuccess' }], [{ type: 'requestUpdateRelease' }, { type: 'receiveUpdateReleaseSuccess' }],
); );
...@@ -193,7 +209,7 @@ describe('Release detail actions', () => { ...@@ -193,7 +209,7 @@ describe('Release detail actions', () => {
return testAction( return testAction(
actions.updateRelease, actions.updateRelease,
undefined, undefined,
stateClone, state,
[], [],
[ [
{ type: 'requestUpdateRelease' }, { type: 'requestUpdateRelease' },
...@@ -202,16 +218,4 @@ describe('Release detail actions', () => { ...@@ -202,16 +218,4 @@ describe('Release detail actions', () => {
); );
}); });
}); });
describe('navigateToReleasesPage', () => {
it(`calls redirectTo() with the URL to the releases page`, () => {
const releasesPagePath = 'path/to/releases/page';
stateClone.releasesPagePath = releasesPagePath;
actions.navigateToReleasesPage({ state: stateClone });
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(releasesPagePath);
});
});
}); });
...@@ -27,7 +27,7 @@ describe('Releases App ', () => { ...@@ -27,7 +27,7 @@ describe('Releases App ', () => {
}; };
beforeEach(() => { beforeEach(() => {
store = createStore({ list: listModule }); store = createStore({ modules: { list: listModule } });
releasesPagination = _.range(21).map(index => ({ releasesPagination = _.range(21).map(index => ({
...convertObjectPropsToCamelCase(release, { deep: true }), ...convertObjectPropsToCamelCase(release, { deep: true }),
tagName: `${index}.00`, tagName: `${index}.00`,
......
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