Commit 2b921921 authored by Martin Wortschack's avatar Martin Wortschack

Add code review analytics app

- Wire app to API and display
paginated MR table
parent ccb07235
...@@ -460,6 +460,7 @@ img.emoji { ...@@ -460,6 +460,7 @@ img.emoji {
.w-8em { width: 8em; } .w-8em { width: 8em; }
.w-3rem { width: 3rem; } .w-3rem { width: 3rem; }
.w-15p { width: 15%; } .w-15p { width: 15%; }
.w-30p { width: 30%; }
.w-70p { width: 70%; } .w-70p { width: 70%; }
.h-12em { height: 12em; } .h-12em { height: 12em; }
......
import Vue from 'vue'; import Vue from 'vue';
import createStore from './store'; import store from './store';
import CodeAnalyticsApp from './components/app.vue'; import CodeAnalyticsApp from './components/app.vue';
import FilteredSearchCodeReviewAnalytics from './filtered_search_code_review_analytics'; import FilteredSearchCodeReviewAnalytics from './filtered_search_code_review_analytics';
...@@ -12,7 +12,7 @@ export default () => { ...@@ -12,7 +12,7 @@ export default () => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: container, el: container,
store: createStore(), store,
created() { created() {
this.filterManager = new FilteredSearchCodeReviewAnalytics(); this.filterManager = new FilteredSearchCodeReviewAnalytics();
this.filterManager.setup(); this.filterManager.setup();
......
<script> <script>
import { mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { GlBadge, GlLoadingIcon, GlPagination } from '@gitlab/ui';
import MergeRequestTable from './merge_request_table.vue';
export default { export default {
components: {
GlBadge,
GlLoadingIcon,
GlPagination,
MergeRequestTable,
},
props: { props: {
projectId: { projectId: {
type: Number, type: Number,
required: true, required: true,
}, },
}, },
computed: {
...mapState({
isLoading: 'isLoading',
perPage: state => state.pageInfo.perPage,
totalItems: state => state.pageInfo.total,
page: state => state.pageInfo.page,
}),
...mapGetters(['showMrCount']),
currentPage: {
get() {
return this.page;
},
set(newVal) {
this.setPage(newVal);
this.fetchMergeRequests();
},
},
},
created() { created() {
this.setProjectId(this.projectId); this.setProjectId(this.projectId);
this.fetchMergeRequests();
}, },
methods: { methods: {
...mapActions(['setProjectId']), ...mapActions(['setProjectId', 'fetchMergeRequests', 'setPage']),
}, },
}; };
</script> </script>
...@@ -21,6 +48,18 @@ export default { ...@@ -21,6 +48,18 @@ export default {
<div class="mt-2"> <div class="mt-2">
<div> <div>
<span class="font-weight-bold">{{ __('Merge Requests in Review') }}</span> <span class="font-weight-bold">{{ __('Merge Requests in Review') }}</span>
<gl-badge v-show="showMrCount" pill>{{ totalItems }}</gl-badge>
</div> </div>
<gl-loading-icon v-show="isLoading" size="md" class="mt-3" />
<template v-if="!isLoading">
<merge-request-table />
<gl-pagination
v-model="currentPage"
:per-page="perPage"
:total-items="totalItems"
align="center"
class="w-100"
/>
</template>
</div> </div>
</template> </template>
<script>
import { escape } from 'underscore';
import { mapState } from 'vuex';
import { __, sprintf, n__ } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { GlTable, GlLink, GlIcon, GlAvatarLink, GlAvatar } from '@gitlab/ui';
export default {
name: 'MergeRequestTable',
components: {
GlTable,
GlLink,
GlIcon,
GlAvatarLink,
GlAvatar,
},
computed: {
...mapState(['mergeRequests']),
},
methods: {
getTimeAgoString(createdAt) {
return sprintf(__('opened %{timeAgo}'), {
timeAgo: escape(getTimeago().format(createdAt)),
});
},
formatReviewTime(hours) {
if (hours >= 24) {
const days = Math.floor(hours / 24);
return n__('1 day', '%d days', days);
} else if (hours >= 1 && hours < 24) {
return n__('1 hour', '%d hours', hours);
}
return __('< 1 hour');
},
},
tableHeaderFields: [
{
key: 'mr_details',
label: __('Merge Request'),
thClass: 'w-30p',
tdClass: 'table-col d-flex align-items-center',
},
{
key: 'review_time',
label: __('Review time'),
class: 'text-right',
tdClass: 'table-col d-flex align-items-center d-sm-table-cell',
},
{
key: 'author',
label: __('Author'),
tdClass: 'table-col d-flex align-items-center d-sm-table-cell',
},
{
key: 'notes_count',
label: __('Comments'),
class: 'text-right',
tdClass: 'table-col d-flex align-items-center d-sm-table-cell',
},
{
key: 'diff_stats',
label: __('Commits'),
class: 'text-right',
tdClass: 'table-col d-flex align-items-center d-sm-table-cell',
},
{
key: 'line_changes',
label: __('Line changes'),
class: 'text-right',
tdClass: 'table-col d-flex align-items-center d-sm-table-cell',
},
],
};
</script>
<template>
<gl-table
class="my-3"
:fields="$options.tableHeaderFields"
:items="mergeRequests"
stacked="sm"
thead-class="thead-white border-bottom"
>
<template #mr_details="items">
<div class="d-flex flex-column flex-grow align-items-end align-items-sm-start">
<div class="mr-title str-truncated my-2">
<gl-link :href="items.item.web_url" target="_blank" class="font-weight-bold text-plain">{{
items.item.title
}}</gl-link>
</div>
<ul class="horizontal-list list-items-separated text-secondary mb-0">
<li>!{{ items.item.iid }}</li>
<li>{{ getTimeAgoString(items.item.created_at) }}</li>
<li v-if="items.item.milestone">
<span class="d-flex align-items-center">
<gl-icon name="clock" class="mr-2" />
{{ items.item.milestone.title }}
</span>
</li>
</ul>
</div>
</template>
<template #review_time="{ value }">
<template v-if="value">
{{ formatReviewTime(value) }}
</template>
<template v-else>
&ndash;
</template>
</template>
<template #author="{ value }">
<gl-avatar-link target="blank" :href="value.web_url">
<gl-avatar :size="24" :src="value.avatar_url" :entity-name="value.name" />
</gl-avatar-link>
</template>
<template #diff_stats="{ value }">
<span>{{ value.commits_count }}</span>
</template>
<template #line_changes="items">
<span class="font-weight-bold cgreen"> +{{ items.item.diff_stats.additions }} </span>
<span class="font-weight-bold cred"> -{{ items.item.diff_stats.deletions }} </span>
</template>
</gl-table>
</template>
import CodeReviewAnalyticsFilteredSearchTokenKeys from './code_review_analytics_filtered_search_token_keys'; import CodeReviewAnalyticsFilteredSearchTokenKeys from './code_review_analytics_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import createStore from './store'; import store from './store';
export default class FilteredSearchCodeReviewAnalytics extends FilteredSearchManager { export default class FilteredSearchCodeReviewAnalytics extends FilteredSearchManager {
constructor() { constructor() {
...@@ -21,7 +21,6 @@ export default class FilteredSearchCodeReviewAnalytics extends FilteredSearchMan ...@@ -21,7 +21,6 @@ export default class FilteredSearchCodeReviewAnalytics extends FilteredSearchMan
*/ */
updateObject = path => { updateObject = path => {
const filters = urlParamsToObject(path); const filters = urlParamsToObject(path);
const store = createStore();
store.dispatch('setFilters', filters); store.dispatch('setFilters', filters);
}; };
} }
import API from 'ee/api';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setFilters = ({ commit }, { label_name, milestone_title }) => { export const setFilters = ({ commit, dispatch }, { label_name, milestone_title }) => {
commit(types.SET_FILTERS, { labelName: label_name, milestoneTitle: milestone_title }); commit(types.SET_FILTERS, { labelName: label_name, milestoneTitle: milestone_title });
dispatch('fetchMergeRequests');
};
export const fetchMergeRequests = ({ dispatch, state }) => {
dispatch('requestMergeRequests');
const { projectId, filters, pageInfo } = state;
const params = {
project_id: projectId,
milestone_title: filters.milestoneTitle,
label_name: filters.labelName,
page: pageInfo.page,
};
return API.codeReviewAnalytics(params)
.then(response => {
const { headers, data } = response;
dispatch('receiveMergeRequestsSuccess', { headers, data });
})
.catch(err => dispatch('receiveMergeRequestsError', err));
};
export const requestMergeRequests = ({ commit }) => commit(types.REQUEST_MERGE_REQUESTS);
export const receiveMergeRequestsSuccess = ({ commit }, { headers, data: mergeRequests }) => {
const normalizedHeaders = normalizeHeaders(headers);
const pageInfo = parseIntPagination(normalizedHeaders);
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { pageInfo, mergeRequests });
}; };
export const receiveMergeRequestsError = ({ commit }, { response }) => {
const { status } = response;
commit(types.RECEIVE_MERGE_REQUESTS_ERROR, status);
createFlash(__('An error occurred while loading merge requests.'));
};
export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page);
// eslint-disable-next-line import/prefer-default-export
export const showMrCount = state => !state.isLoading && !state.errorCode;
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -10,7 +11,8 @@ const createStore = () => ...@@ -10,7 +11,8 @@ const createStore = () =>
new Vuex.Store({ new Vuex.Store({
state: state(), state: state(),
actions, actions,
getters,
mutations, mutations,
}); });
export default createStore; export default createStore();
export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_FILTERS = 'SET_FILTERS'; export const SET_FILTERS = 'SET_FILTERS';
export const REQUEST_MERGE_REQUESTS = 'REQUEST_MERGE_REQUESTS';
export const RECEIVE_MERGE_REQUESTS_SUCCESS = 'RECEIVE_MERGE_REQUESTS_SUCCESS';
export const RECEIVE_MERGE_REQUESTS_ERROR = 'RECEIVE_MERGE_REQUESTS_ERRORR';
export const SET_PAGE = 'SET_PAGE';
...@@ -7,5 +7,24 @@ export default { ...@@ -7,5 +7,24 @@ export default {
[types.SET_FILTERS](state, { labelName, milestoneTitle }) { [types.SET_FILTERS](state, { labelName, milestoneTitle }) {
state.filters.labelName = labelName; state.filters.labelName = labelName;
state.filters.milestoneTitle = milestoneTitle; state.filters.milestoneTitle = milestoneTitle;
state.pageInfo.page = 1;
},
[types.REQUEST_MERGE_REQUESTS](state) {
state.isLoading = true;
},
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { pageInfo, mergeRequests }) {
state.isLoading = false;
state.errorCode = null;
state.pageInfo = pageInfo;
state.mergeRequests = mergeRequests;
},
[types.RECEIVE_MERGE_REQUESTS_ERROR](state, errorCode) {
state.isLoading = false;
state.errorCode = errorCode;
state.pageInfo = {};
state.mergeRequests = [];
},
[types.SET_PAGE](state, page) {
state.pageInfo = { ...state.pageInfo, page };
}, },
}; };
export default () => ({ export default () => ({
projectId: null, projectId: null,
isLoading: false,
errorCode: null,
mergeRequests: [],
pageInfo: {},
filters: { filters: {
labelName: [], labelName: [],
milestoneTitle: null, milestoneTitle: null,
......
...@@ -22,6 +22,7 @@ export default { ...@@ -22,6 +22,7 @@ export default {
cycleAnalyticsStageMedianPath: '/-/analytics/cycle_analytics/stages/:stage_id/median', cycleAnalyticsStageMedianPath: '/-/analytics/cycle_analytics/stages/:stage_id/median',
cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id', cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id',
cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart', cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart',
codeReviewAnalyticsPath: '/api/:version/analytics/code_review',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -203,6 +204,11 @@ export default { ...@@ -203,6 +204,11 @@ export default {
}); });
}, },
codeReviewAnalytics(params = {}) {
const url = Api.buildUrl(this.codeReviewAnalyticsPath);
return axios.get(url, { params });
},
getGeoDesigns(params = {}) { getGeoDesigns(params = {}) {
const url = Api.buildUrl(this.geoDesignsPath); const url = Api.buildUrl(this.geoDesignsPath);
return axios.get(url, { params }); return axios.get(url, { params });
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequestTable component template matches the snapshot 1`] = `
<table
aria-busy="false"
aria-colcount="6"
aria-describedby="__BVID__13__caption_"
class="table b-table gl-table my-3 b-table-stacked-sm"
id="__BVID__13"
role="table"
>
<!---->
<!---->
<thead
class="thead-white border-bottom"
role="rowgroup"
>
<!---->
<tr
role="row"
>
<th
aria-colindex="1"
class="w-30p"
role="columnheader"
scope="col"
>
Merge Request
</th>
<th
aria-colindex="2"
class="text-right"
role="columnheader"
scope="col"
>
Review time
</th>
<th
aria-colindex="3"
class=""
role="columnheader"
scope="col"
>
Author
</th>
<th
aria-colindex="4"
class="text-right"
role="columnheader"
scope="col"
>
Comments
</th>
<th
aria-colindex="5"
class="text-right"
role="columnheader"
scope="col"
>
Commits
</th>
<th
aria-colindex="6"
class="text-right"
role="columnheader"
scope="col"
>
Line changes
</th>
</tr>
</thead>
<!---->
<tbody
class=""
role="rowgroup"
>
<!---->
<!---->
<!---->
</tbody>
</table>
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon, GlBadge, GlPagination } from '@gitlab/ui';
import CodeReviewAnalyticsApp from 'ee/analytics/code_review_analytics/components/app.vue';
import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue';
import createState from 'ee/analytics/code_review_analytics/store/state';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('CodeReviewAnalyticsApp component', () => {
let wrapper;
let vuexStore;
let setPage;
let fetchMergeRequests;
const pageInfo = {
page: 1,
perPage: 10,
total: 50,
};
const createStore = (initialState = {}, getters = {}) =>
new Vuex.Store({
state: {
...createState(),
...initialState,
},
actions: {
setProjectId: jest.fn(),
setPage,
fetchMergeRequests,
},
getters: {
showMrCount: () => false,
...getters,
},
});
const createComponent = store =>
shallowMount(CodeReviewAnalyticsApp, {
localVue,
store,
propsData: {
projectId: 1,
},
});
beforeEach(() => {
setPage = jest.fn();
fetchMergeRequests = jest.fn();
});
afterEach(() => {
wrapper.destroy();
});
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findBadge = () => wrapper.find(GlBadge);
const findMrTable = () => wrapper.find(MergeRequestTable);
const findPagination = () => wrapper.find(GlPagination);
describe('template', () => {
describe('while loading', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: true });
wrapper = createComponent(vuexStore);
});
it('should display a loading indicator', () => {
expect(findLoadingIcon().isVisible()).toBe(true);
});
it('should not show the badge containing the MR count', () => {
expect(findBadge().isVisible()).toBe(false);
});
it('should not render the merge requests table', () => {
expect(findMrTable().exists()).toBe(false);
});
it('should not render the pagination', () => {
expect(findPagination().exists()).toBe(false);
});
});
describe('when finished loading', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true });
wrapper = createComponent(vuexStore);
});
it('should hide the loading indicator', () => {
expect(findLoadingIcon().isVisible()).toBe(false);
});
it('should show the badge containing the MR count', () => {
expect(findBadge().isVisible()).toBe(true);
expect(findBadge().text()).toEqual(`${50}`);
});
it('should render the merge requests table', () => {
expect(findMrTable().exists()).toBe(true);
});
it('should render the pagination', () => {
expect(findPagination().exists()).toBe(true);
});
});
});
describe('changing the page', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true });
wrapper = createComponent(vuexStore);
wrapper.vm.currentPage = 2;
});
it('should call the setPage action', () => {
expect(setPage).toHaveBeenCalledWith(expect.anything(), 2, undefined);
});
it('should call fetchMergeRequests action', () => {
expect(fetchMergeRequests).toHaveBeenCalled();
});
});
});
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlTable } from '@gitlab/ui';
import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue';
import createState from 'ee/analytics/code_review_analytics/store/state';
import mergeRequests from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('MergeRequestTable component', () => {
let wrapper;
let vuexStore;
const createStore = (initialState = {}, getters = {}) =>
new Vuex.Store({
state: {
...createState(),
...initialState,
},
actions: {
setProjectId: jest.fn(),
setPage: jest.fn(),
fetchMergeRequests: jest.fn(),
},
getters: {
showMrCount: () => false,
...getters,
},
});
const createComponent = store =>
mount(MergeRequestTable, {
localVue,
store,
});
afterEach(() => {
wrapper.destroy();
});
const findTable = () => wrapper.find(GlTable);
describe('template', () => {
beforeEach(() => {
vuexStore = createStore({ mergeRequests });
wrapper = createComponent(vuexStore);
});
it('renders the GlTable component', () => {
expect(findTable().exists()).toBe(true);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the correct table headers', () => {
const tableHeaders = [
'Merge Request',
'Review time',
'Author',
'Comments',
'Commits',
'Line changes',
];
const headers = findTable().findAll('th');
expect(headers.length).toBe(tableHeaders.length);
tableHeaders.forEach((headerText, i) => expect(headers.at(i).text()).toEqual(headerText));
});
});
describe('methods', () => {
describe('formatReviewTime', () => {
it('returns "days" when review time is >= 24', () => {
expect(wrapper.vm.formatReviewTime(51)).toBe('2 days');
});
it('returns "hours" when review time is < 18', () => {
expect(wrapper.vm.formatReviewTime(18)).toBe('18 hours');
});
it('returns "< 1 hour" when review is < 1', () => {
expect(wrapper.vm.formatReviewTime(0)).toBe('< 1 hour');
});
});
});
});
// eslint-disable-next-line import/prefer-default-export
export const mergeRequests = [
{
title:
'This is just a super long merge request title that does not fit into one line so it needs to be truncated',
iid: 12345,
web_url: 'https://gitlab.com/gitlab-org/gitlab/merge_requests/38062',
created_at: '2020-01-08',
milestone: {
id: 123,
iid: 1234,
title: '11.1',
web_url: 'https://gitlab.com/gitlab-org/gitlab/merge_requests?milestone_title=12.7',
due_date: '2020-01-31',
},
review_time: 64,
author: {
id: 123,
username: 'foo',
name: 'foo',
web_url: 'https://gitlab.com/foo',
avatar_url: '',
},
approved_by: [
{
id: 123,
username: 'bar',
name: 'bar',
web_url: 'https://gitlab.com/bar',
avatar_url: '',
},
],
notes_count: 21,
diff_stats: { additions: 504, deletions: 10, total: 514, commits_count: 7 },
},
];
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/code_review_analytics/store/actions'; import * as actions from 'ee/analytics/code_review_analytics/store/actions';
import * as types from 'ee/analytics/code_review_analytics/store/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/mutation_types';
import getInitialState from 'ee/analytics/code_review_analytics/store/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/state';
import createFlash from '~/flash'; import createFlash from '~/flash';
import mockMergeRequests from '../mock_data';
jest.mock('~/flash', () => jest.fn()); jest.mock('~/flash', () => jest.fn());
describe('Code review analytics actions', () => { describe('Code review analytics actions', () => {
let state;
let mock;
const pageInfo = {
page: 1,
nextPage: 2,
previousPage: 1,
perPage: 10,
total: 50,
totalPages: 5,
};
const headers = {
'X-Next-Page': pageInfo.nextPage,
'X-Page': pageInfo.page,
'X-Per-Page': pageInfo.perPage,
'X-Prev-Page': pageInfo.previousPage,
'X-Total': pageInfo.total,
'X-Total-Pages': pageInfo.totalPages,
};
beforeEach(() => {
state = getInitialState();
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
mock.restore();
createFlash.mockClear(); createFlash.mockClear();
}); });
...@@ -16,7 +46,7 @@ describe('Code review analytics actions', () => { ...@@ -16,7 +46,7 @@ describe('Code review analytics actions', () => {
testAction( testAction(
actions.setProjectId, actions.setProjectId,
1, 1,
getInitialState(), state,
[ [
{ {
type: types.SET_PROJECT_ID, type: types.SET_PROJECT_ID,
...@@ -27,6 +57,91 @@ describe('Code review analytics actions', () => { ...@@ -27,6 +57,91 @@ describe('Code review analytics actions', () => {
)); ));
}); });
describe('fetchMergeRequests', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(/api\/(.*)\/analytics\/code_review/).replyOnce(200, mockMergeRequests, headers);
});
it('dispatches success with received data', () => {
testAction(
actions.fetchMergeRequests,
null,
state,
[],
[
{ type: 'requestMergeRequests' },
{ type: 'receiveMergeRequestsSuccess', payload: { headers, data: mockMergeRequests } },
],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(/api\/(.*)\/analytics\/code_review/).replyOnce(500);
});
it('dispatches error', () => {
testAction(
actions.fetchMergeRequests,
null,
state,
[],
[
{ type: 'requestMergeRequests' },
{
type: 'receiveMergeRequestsError',
payload: new Error('Request failed with status code 500'),
},
],
);
});
});
});
describe('requestMergeRequests', () => {
it('commits REQUEST_MERGE_REQUESTS mutation', () => {
testAction(
actions.requestMergeRequests,
null,
state,
[{ type: types.REQUEST_MERGE_REQUESTS }],
[],
);
});
});
describe('receiveMergeRequestsSuccess', () => {
it('commits RECEIVE_MERGE_REQUESTS_SUCCESS mutation', () => {
testAction(
actions.receiveMergeRequestsSuccess,
{ headers, data: mockMergeRequests },
state,
[
{
type: types.RECEIVE_MERGE_REQUESTS_SUCCESS,
payload: { pageInfo, mergeRequests: mockMergeRequests },
},
],
[],
);
});
});
describe('receiveMergeRequestsError', () => {
it('commits SET_MERGE_REQUEST_ERROR mutation', () =>
testAction(
actions.receiveMergeRequestsError,
{ response: { status: 500 } },
state,
[{ type: types.RECEIVE_MERGE_REQUESTS_ERROR, payload: 500 }],
[],
).then(() => {
expect(createFlash).toHaveBeenCalled();
}));
});
describe('setFilters', () => { describe('setFilters', () => {
const milestoneTitle = 'my milestone'; const milestoneTitle = 'my milestone';
const labelName = ['first label', 'second label']; const labelName = ['first label', 'second label'];
...@@ -35,15 +150,21 @@ describe('Code review analytics actions', () => { ...@@ -35,15 +150,21 @@ describe('Code review analytics actions', () => {
testAction( testAction(
actions.setFilters, actions.setFilters,
{ milestone_title: milestoneTitle, label_name: labelName }, { milestone_title: milestoneTitle, label_name: labelName },
getInitialState(), state,
[ [
{ {
type: types.SET_FILTERS, type: types.SET_FILTERS,
payload: { milestoneTitle, labelName }, payload: { milestoneTitle, labelName },
}, },
], ],
[], [{ type: 'fetchMergeRequests' }],
); );
}); });
}); });
describe('setPage', () => {
it('commits SET_PAGE mutation', () => {
testAction(actions.setPage, 2, state, [{ type: types.SET_PAGE, payload: 2 }], []);
});
});
}); });
import createState from 'ee/analytics/code_review_analytics/store/state';
import * as getters from 'ee/analytics/code_review_analytics/store/getters';
describe('Code review analytics getteers', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('showMrCount', () => {
it('returns false when is loading', () => {
state = { isLoading: true, errorCode: null };
expect(getters.showMrCount(state)).toBe(false);
});
it('returns true when not loading and no error', () => {
state = { isLoading: false, errorCode: null };
expect(getters.showMrCount(state)).toBe(true);
});
});
});
import * as types from 'ee/analytics/code_review_analytics/store/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/mutation_types';
import mutations from 'ee/analytics/code_review_analytics/store/mutations'; import mutations from 'ee/analytics/code_review_analytics/store/mutations';
import getInitialState from 'ee/analytics/code_review_analytics/store/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/state';
import { mockMergeRequests } from '../../productivity_analytics/mock_data';
describe('Code review analytics mutations', () => { describe('Code review analytics mutations', () => {
let state; let state;
...@@ -8,6 +9,15 @@ describe('Code review analytics mutations', () => { ...@@ -8,6 +9,15 @@ describe('Code review analytics mutations', () => {
const milestoneTitle = 'my milestone'; const milestoneTitle = 'my milestone';
const labelName = ['first label', 'second label']; const labelName = ['first label', 'second label'];
const pageInfo = {
page: 1,
nextPage: 2,
previousPage: 1,
perPage: 10,
total: 50,
totalPages: 5,
};
beforeEach(() => { beforeEach(() => {
state = getInitialState(); state = getInitialState();
}); });
...@@ -26,6 +36,57 @@ describe('Code review analytics mutations', () => { ...@@ -26,6 +36,57 @@ describe('Code review analytics mutations', () => {
expect(state.filters.milestoneTitle).toBe(milestoneTitle); expect(state.filters.milestoneTitle).toBe(milestoneTitle);
expect(state.filters.labelName).toBe(labelName); expect(state.filters.labelName).toBe(labelName);
expect(state.pageInfo.page).toBe(1);
});
});
describe(types.REQUEST_MERGE_REQUESTS, () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_MERGE_REQUESTS](state);
expect(state.isLoading).toBe(true);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => {
it('updates mergeRequests with the received data and updates the pageInfo', () => {
mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, {
pageInfo,
mergeRequests: mockMergeRequests,
});
expect(state.isLoading).toBe(false);
expect(state.errorCode).toBe(null);
expect(state.mergeRequests).toEqual(mockMergeRequests);
expect(state.pageInfo).toEqual(pageInfo);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => {
const errorCode = 500;
beforeEach(() => {
mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](state, errorCode);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false);
});
it('sets errorCode to 500', () => {
expect(state.errorCode).toBe(errorCode);
});
it('clears data', () => {
expect(state.mergeRequests).toEqual([]);
expect(state.pageInfo).toEqual({});
});
});
describe('SET_PAGE', () => {
it('sets the page on the pageInfo object', () => {
mutations[types.SET_PAGE](state, 2);
expect(state.pageInfo.page).toBe(2);
}); });
}); });
}); });
...@@ -587,7 +587,9 @@ msgstr[0] "" ...@@ -587,7 +587,9 @@ msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 hour" msgid "1 hour"
msgstr "" msgid_plural "%d hours"
msgstr[0] ""
msgstr[1] ""
msgid "1 merged merge request" msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests" msgid_plural "%{merge_requests} merged merge requests"
...@@ -684,6 +686,9 @@ msgstr "" ...@@ -684,6 +686,9 @@ msgstr ""
msgid "8 hours" msgid "8 hours"
msgstr "" msgstr ""
msgid "< 1 hour"
msgstr ""
msgid "<code>\"johnsmith@example.com\": \"@johnsmith\"</code> will add \"By <a href=\"#\">@johnsmith</a>\" to all issues and comments originally created by johnsmith@example.com, and will set <a href=\"#\">@johnsmith</a> as the assignee on all issues originally assigned to johnsmith@example.com." msgid "<code>\"johnsmith@example.com\": \"@johnsmith\"</code> will add \"By <a href=\"#\">@johnsmith</a>\" to all issues and comments originally created by johnsmith@example.com, and will set <a href=\"#\">@johnsmith</a> as the assignee on all issues originally assigned to johnsmith@example.com."
msgstr "" msgstr ""
...@@ -1774,6 +1779,9 @@ msgstr "" ...@@ -1774,6 +1779,9 @@ msgstr ""
msgid "An error occurred while loading issues" msgid "An error occurred while loading issues"
msgstr "" msgstr ""
msgid "An error occurred while loading merge requests."
msgstr ""
msgid "An error occurred while loading the data. Please try again." msgid "An error occurred while loading the data. Please try again."
msgstr "" msgstr ""
...@@ -11031,6 +11039,9 @@ msgid_plural "Limited to showing %d events at most" ...@@ -11031,6 +11039,9 @@ msgid_plural "Limited to showing %d events at most"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Line changes"
msgstr ""
msgid "Link copied" msgid "Link copied"
msgstr "" msgstr ""
...@@ -15808,6 +15819,9 @@ msgstr "" ...@@ -15808,6 +15819,9 @@ msgstr ""
msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"." msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
msgstr "" msgstr ""
msgid "Review time"
msgstr ""
msgid "Review time is defined as the time it takes from first comment until merged." msgid "Review time is defined as the time it takes from first comment until merged."
msgstr "" msgstr ""
...@@ -22600,6 +22614,9 @@ msgstr "" ...@@ -22600,6 +22614,9 @@ msgstr ""
msgid "opened %{timeAgoString} by %{user}" msgid "opened %{timeAgoString} by %{user}"
msgstr "" msgstr ""
msgid "opened %{timeAgo}"
msgstr ""
msgid "out of %d total test" msgid "out of %d total test"
msgid_plural "out of %d total tests" msgid_plural "out of %d total tests"
msgstr[0] "" msgstr[0] ""
......
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