Commit 217a7417 authored by Fatih Acet's avatar Fatih Acet

Rewrite related MRs widget with Vue

This MR rewrites existing Related Merge Requests widget
with Vue with reusing shared Related Issues components.
parent fbaf9a3a
......@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
export default function() {
initIssueableApp();
initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
export default {
name: 'RelatedMergeRequests',
components: {
Icon,
GlLoadingIcon,
RelatedIssuableItem,
},
props: {
endpoint: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['isFetchingMergeRequests', 'mergeRequests']),
},
mounted() {
this.setInitialState({ apiEndpoint: this.endpoint });
this.fetchMergeRequests();
},
methods: {
...mapActions(['setInitialState', 'fetchMergeRequests']),
getAssignees(mr) {
if (mr.assignees) {
return mr.assignees;
}
return mr.assignee ? [mr.assignee] : [];
},
},
};
</script>
<template>
<div class="card-slim mt-3">
<div class="card-header">
<div class="card-title mt-0 mb-0 h5 merge-requests-title">
<span class="mr-1">
{{ __('Related merge requests') }}
</span>
<div class="d-inline-flex lh-100 align-middle">
<div class="mr-count-badge">
<div class="mr-count-badge-count">
<svg class="s16 mr-1 text-secondary">
<icon name="merge-request" class="mr-1 text-secondary" />
</svg>
<span class="js-items-count">{{ mergeRequests.length }}</span>
</div>
</div>
</div>
</div>
</div>
<div>
<div
v-if="isFetchingMergeRequests"
class="related-related-merge-requests-icon qa-related-merge-requests-loading-icon"
>
<gl-loading-icon label="Fetching related merge requests" class="py-2" />
</div>
<ul v-else class="content-list related-items-list">
<li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
<related-issuable-item
:id-key="mr.id"
:display-reference="mr.reference"
:title="mr.title"
:milestone="mr.milestone"
:assignees="getAssignees(mr)"
:created-at="mr.created_at"
:closed-at="mr.closed_at"
:path="mr.web_url"
:state="mr.state"
:is-merge-request="true"
:pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
path-id-separator="!"
/>
</li>
</ul>
</div>
</div>
</template>
import Vue from 'vue';
import RelatedMergeRequests from './components/related_merge_requests.vue';
import createStore from './store';
export default function initRelatedMergeRequests() {
const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests');
if (relatedMergeRequestsElement) {
const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset;
// eslint-disable-next-line no-new
new Vue({
el: relatedMergeRequestsElement,
components: {
RelatedMergeRequests,
},
store: createStore(),
render: createElement =>
createElement('related-merge-requests', {
props: { endpoint, projectNamespace, projectPath },
}),
});
}
}
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, props) => {
commit(types.SET_INITIAL_STATE, props);
};
export const requestData = ({ commit }) => commit(types.REQUEST_DATA);
export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data);
export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR);
export const fetchMergeRequests = ({ state, dispatch }) => {
dispatch('requestData');
return axios
.get(state.apiEndpoint)
.then(res => {
dispatch('receiveDataSuccess', res.data);
})
.catch(() => {
dispatch('receiveDataError');
createFlash(s__('Something went wrong while fetching related merge requests.'));
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import * as actions from './actions';
import mutations from './mutations';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
state: createState(),
actions,
mutations,
});
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_DATA = 'REQUEST_DATA';
export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS';
export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, { apiEndpoint }) {
state.apiEndpoint = apiEndpoint;
},
[types.REQUEST_DATA](state) {
state.isFetchingMergeRequests = true;
},
[types.RECEIVE_DATA_SUCCESS](state, mrs) {
state.isFetchingMergeRequests = false;
state.mergeRequests = mrs;
},
[types.RECEIVE_DATA_ERROR](state) {
state.isFetchingMergeRequests = false;
state.hasErrorFetchingMergeRequests = true;
},
};
export default () => ({
apiEndpoint: '',
isFetchingMergeRequests: false,
hasErrorFetchingMergeRequests: false,
mergeRequests: [],
});
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin';
import IssueMilestone from '../../components/issue/issue_milestone.vue';
import IssueAssignees from '../../components/issue/issue_assignees.vue';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
import CiIcon from '../ci_icon.vue';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueAssignees,
CiIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -84,6 +86,11 @@ export default {
{{ pathIdSeparator }}{{ itemId }}
</div>
<div class="item-meta-child d-flex align-items-center">
<span v-if="hasPipeline" class="mr-ci-status pr-2">
<a :href="pipelineStatus.details_path">
<ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
</a>
</span>
<issue-milestone
v-if="hasMilestone"
:milestone="milestone"
......
import _ from 'underscore';
import { sprintf, __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
......@@ -83,6 +84,16 @@ const mixins = {
required: false,
default: false,
},
isMergeRequest: {
type: Boolean,
required: false,
default: false,
},
pipelineStatus: {
type: Object,
required: false,
default: () => ({}),
},
},
components: {
icon,
......@@ -95,12 +106,18 @@ const mixins = {
hasState() {
return this.state && this.state.length > 0;
},
hasPipeline() {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
return this.state === 'opened';
},
isClosed() {
return this.state === 'closed';
},
isMerged() {
return this.state === 'merged';
},
hasTitle() {
return this.title.length > 0;
},
......@@ -108,9 +125,17 @@ const mixins = {
return !_.isEmpty(this.milestone);
},
iconName() {
if (this.isMergeRequest && this.isMerged) {
return 'merge';
}
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
iconClass() {
if (this.isMergeRequest && this.isClosed) {
return 'merge-request-status closed issue-token-state-icon-closed';
}
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
},
computedLinkElementType() {
......@@ -137,6 +162,11 @@ const mixins = {
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
},
pipelineStatusTooltip() {
return this.hasPipeline
? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label })
: '';
},
},
methods: {
onRemoveRequest() {
......
......@@ -88,11 +88,13 @@
Related issues
#merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } }
// This element is filled in using JavaScript.
- # This element is filled in using JavaScript.
#js-related-merge-requests{ data: { endpoint: api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid), project_namespace: @project.namespace.path, project_path: @project.path } }
- if can?(current_user, :download_code, @project)
#related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
// This element is filled in using JavaScript.
- # This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky
.row
......
......@@ -65,3 +65,61 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
store_frontend_fixture(response, fixture_file_name)
end
end
describe API::Issues, '(JavaScript fixtures)', type: :request do
include ApiHelpers
include JavaScriptFixturesHelpers
def get_related_merge_requests(project_id, issue_iid, user = nil)
get api("/projects/#{project_id}/issues/#{issue_iid}/related_merge_requests", user)
end
def create_referencing_mr(user, project, issue)
attributes = {
author: user,
source_project: project,
target_project: project,
source_branch: "master",
target_branch: "test",
assignee: user,
description: "See #{issue.to_reference}"
}
create(:merge_request, attributes).tap do |merge_request|
create(:note, :system, project: issue.project, noteable: issue, author: user, note: merge_request.to_reference(full: true))
end
end
it 'issues/related_merge_requests.json' do |example|
user = create(:user)
project = create(:project, :public, creator_id: user.id, namespace: user.namespace)
issue_title = 'foo'
issue_description = 'closed'
milestone = create(:milestone, title: '1.0.0', project: project)
issue = create :issue,
author: user,
assignees: [user],
project: project,
milestone: milestone,
created_at: generate(:past_time),
updated_at: 1.hour.ago,
title: issue_title,
description: issue_description
project.add_reporter(user)
related_mr = create_referencing_mr(user, project, issue)
create(:merge_request,
:simple,
author: user,
source_project: project,
target_project: project,
description: "Some description")
project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace)
create_referencing_mr(user, project2, issue).update!(head_pipeline: create(:ci_pipeline))
get_related_merge_requests(project.id, issue.iid, user)
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
import createStore from '~/related_merge_requests/store/index';
const FIXTURE_PATH = 'issues/related_merge_requests.json';
const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
const localVue = createLocalVue();
describe('RelatedMergeRequests', () => {
let wrapper;
let mock;
let mockData;
beforeEach(done => {
loadFixtures(FIXTURE_PATH);
mockData = getJSONFixture(FIXTURE_PATH);
mock = new MockAdapter(axios);
mock.onGet(API_ENDPOINT).reply(200, mockData);
wrapper = mount(RelatedMergeRequests, {
localVue,
sync: false,
store: createStore(),
propsData: {
endpoint: API_ENDPOINT,
projectNamespace: 'gitlab-org',
projectPath: 'gitlab-ce',
},
});
setTimeout(done);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('methods', () => {
describe('getAssignees', () => {
const assignees = [{ name: 'foo' }, { name: 'bar' }];
describe('when there is assignees array', () => {
it('should return assignees array', () => {
const mr = { assignees };
expect(wrapper.vm.getAssignees(mr)).toEqual(assignees);
});
});
it('should return an array with single assingee', () => {
const mr = { assignee: assignees[0] };
expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]);
});
it('should return empty array when assignee is not set', () => {
expect(wrapper.vm.getAssignees({})).toEqual([]);
expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]);
});
});
});
describe('template', () => {
it('should render related merge request items', () => {
expect(wrapper.find('.js-items-count').text()).toEqual('2');
expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2);
const props = wrapper
.findAll(RelatedIssuableItem)
.at(1)
.props();
const data = mockData[1];
expect(props.idKey).toEqual(data.id);
expect(props.pathIdSeparator).toEqual('!');
expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status);
expect(props.assignees).toEqual([data.assignee]);
expect(props.isMergeRequest).toBe(true);
expect(props.confidential).toEqual(false);
expect(props.title).toEqual(data.title);
expect(props.state).toEqual(data.state);
expect(props.createdAt).toEqual(data.created_at);
});
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as types from '~/related_merge_requests/store/mutation_types';
import actionsModule, * as actions from '~/related_merge_requests/store/actions';
import testAction from 'spec/helpers/vuex_action_helper';
describe('RelatedMergeRequest store actions', () => {
let state;
let flashSpy;
let mock;
beforeEach(() => {
state = {
apiEndpoint: '/api/related_merge_requests',
};
flashSpy = spyOnDependency(actionsModule, 'createFlash');
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('setInitialState', () => {
it('commits types.SET_INITIAL_STATE with given props', done => {
const props = { a: 1, b: 2 };
testAction(
actions.setInitialState,
props,
{},
[{ type: types.SET_INITIAL_STATE, payload: props }],
[],
done,
);
});
});
describe('requestData', () => {
it('commits types.REQUEST_DATA', done => {
testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
});
});
describe('receiveDataSuccess', () => {
it('commits types.RECEIVE_DATA_SUCCESS with data', done => {
const data = { a: 1, b: 2 };
testAction(
actions.receiveDataSuccess,
data,
{},
[{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
[],
done,
);
});
});
describe('receiveDataError', () => {
it('commits types.RECEIVE_DATA_ERROR', done => {
testAction(
actions.receiveDataError,
null,
{},
[{ type: types.RECEIVE_DATA_ERROR }],
[],
done,
);
});
});
describe('fetchMergeRequests', () => {
describe('for a successful request', () => {
it('should dispatch success action', done => {
const data = { a: 1 };
mock.onGet(state.apiEndpoint).replyOnce(200, data);
testAction(
actions.fetchMergeRequests,
null,
state,
[],
[{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: data }],
done,
);
});
});
describe('for a failing request', () => {
it('should dispatch error action', done => {
mock.onGet(state.apiEndpoint).replyOnce(400);
testAction(
actions.fetchMergeRequests,
null,
state,
[],
[{ type: 'requestData' }, { type: 'receiveDataError' }],
() => {
expect(flashSpy).toHaveBeenCalledTimes(1);
expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('Something went wrong'));
done();
},
);
});
});
});
});
import mutations from '~/related_merge_requests/store/mutations';
import * as types from '~/related_merge_requests/store/mutation_types';
describe('RelatedMergeRequests Store Mutations', () => {
describe('SET_INITIAL_STATE', () => {
it('should set initial state according to given data', () => {
const apiEndpoint = '/api';
const state = {};
mutations[types.SET_INITIAL_STATE](state, { apiEndpoint });
expect(state.apiEndpoint).toEqual(apiEndpoint);
});
});
describe('REQUEST_DATA', () => {
it('should set loading flag', () => {
const state = {};
mutations[types.REQUEST_DATA](state);
expect(state.isFetchingMergeRequests).toEqual(true);
});
});
describe('RECEIVE_DATA_SUCCESS', () => {
it('should set loading flag and data', () => {
const state = {};
const mrs = [1, 2, 3];
mutations[types.RECEIVE_DATA_SUCCESS](state, mrs);
expect(state.isFetchingMergeRequests).toEqual(false);
expect(state.mergeRequests).toEqual(mrs);
});
});
describe('RECEIVE_DATA_ERROR', () => {
it('should set loading and error flags', () => {
const state = {};
mutations[types.RECEIVE_DATA_ERROR](state);
expect(state.isFetchingMergeRequests).toEqual(false);
expect(state.hasErrorFetchingMergeRequests).toEqual(true);
});
});
});
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