Commit a59ca232 authored by Martin Wortschack's avatar Martin Wortschack Committed by manojmj

Render statistics panel

- Renders a Vue for the admin statistics panel and fetches data async
- Show flash message on error
- Update PO file
- Add changelog entry
parent 9f9303a6
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import statisticsLabels from '../constants';
export default {
components: {
GlLoadingIcon,
},
data() {
return {
statisticsLabels,
};
},
computed: {
...mapState(['isLoading', 'statistics']),
...mapGetters(['getStatistics']),
},
mounted() {
this.fetchStatistics();
},
methods: {
...mapActions(['fetchStatistics']),
},
};
</script>
<template>
<div class="info-well">
<div class="well-segment admin-well admin-well-statistics">
<h4>{{ __('Statistics') }}</h4>
<gl-loading-icon v-if="isLoading" size="md" class="my-3" />
<template v-else>
<p
v-for="statistic in getStatistics(statisticsLabels)"
:key="statistic.key"
class="js-stats"
>
{{ statistic.label }}
<span class="light float-right">{{ statistic.value }}</span>
</p>
</template>
</div>
</div>
</template>
import { s__ } from '~/locale';
const statisticsLabels = {
forks: s__('AdminStatistics|Forks'),
issues: s__('AdminStatistics|Issues'),
mergeRequests: s__('AdminStatistics|Merge Requests'),
notes: s__('AdminStatistics|Notes'),
snippets: s__('AdminStatistics|Snippets'),
sshKeys: s__('AdminStatistics|SSH Keys'),
milestones: s__('AdminStatistics|Milestones'),
activeUsers: s__('AdminStatistics|Active Users'),
};
export default statisticsLabels;
import Vue from 'vue';
import StatisticsPanelApp from './components/app.vue';
import createStore from './store';
export default function(el) {
if (!el) {
return false;
}
const store = createStore();
return new Vue({
el,
store,
components: {
StatisticsPanelApp,
},
render(h) {
return h(StatisticsPanelApp);
},
});
}
import Api from '~/api';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export const requestStatistics = ({ commit }) => commit(types.REQUEST_STATISTICS);
export const fetchStatistics = ({ dispatch }) => {
dispatch('requestStatistics');
Api.adminStatistics()
.then(({ data }) => {
dispatch('receiveStatisticsSuccess', convertObjectPropsToCamelCase(data, { deep: true }));
})
.catch(error => dispatch('receiveStatisticsError', error));
};
export const receiveStatisticsSuccess = ({ commit }, statistics) =>
commit(types.RECEIVE_STATISTICS_SUCCESS, statistics);
export const receiveStatisticsError = ({ commit }, error) => {
commit(types.RECEIVE_STATISTICS_ERROR, error);
createFlash(s__('AdminDashboard|Error loading the statistics. Please try again'));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
/**
* Merges the statisticsLabels with the state's data
* and returns an array of the following form:
* [{ key: "forks", label: "Forks", value: 50 }]
*/
export const getStatistics = state => labels =>
Object.keys(labels).map(key => {
const result = {
key,
label: labels[key],
value: state.statistics && state.statistics[key] ? state.statistics[key] : null,
};
return result;
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(),
});
export const REQUEST_STATISTICS = 'REQUEST_STATISTICS';
export const RECEIVE_STATISTICS_SUCCESS = 'RECEIVE_STATISTICS_SUCCESS';
export const RECEIVE_STATISTICS_ERROR = 'RECEIVE_STATISTICS_ERROR';
import * as types from './mutation_types';
export default {
[types.REQUEST_STATISTICS](state) {
state.isLoading = true;
},
[types.RECEIVE_STATISTICS_SUCCESS](state, data) {
state.isLoading = false;
state.error = null;
state.statistics = data;
},
[types.RECEIVE_STATISTICS_ERROR](state, error) {
state.isLoading = false;
state.error = error;
},
};
export default () => ({
error: null,
isLoading: false,
statistics: null,
});
......@@ -36,6 +36,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases',
adminStatisticsPath: 'api/:version/application/statistics',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -376,6 +377,11 @@ const Api = {
return axios.get(url);
},
adminStatistics() {
const url = Api.buildUrl(this.adminStatisticsPath);
return axios.get(url);
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
......
import initAdmin from './admin';
import initAdminStatisticsPanel from '../../admin/statistics_panel/index';
document.addEventListener('DOMContentLoaded', initAdmin());
document.addEventListener('DOMContentLoaded', () => {
const statisticsPanelContainer = document.getElementById('js-admin-statistics-container');
initAdmin();
initAdminStatisticsPanel(statisticsPanelContainer);
});
......@@ -35,41 +35,7 @@
= link_to 'New group', new_admin_group_path, class: "btn btn-success"
.row
.col-md-4
.info-well
.well-segment.admin-well.admin-well-statistics
%h4 Statistics
%p
Forks
%span.light.float-right
= approximate_fork_count_with_delimiters(@counts)
%p
Issues
%span.light.float-right
= approximate_count_with_delimiters(@counts, Issue)
%p
Merge Requests
%span.light.float-right
= approximate_count_with_delimiters(@counts, MergeRequest)
%p
Notes
%span.light.float-right
= approximate_count_with_delimiters(@counts, Note)
%p
Snippets
%span.light.float-right
= approximate_count_with_delimiters(@counts, Snippet)
%p
SSH Keys
%span.light.float-right
= approximate_count_with_delimiters(@counts, Key)
%p
Milestones
%span.light.float-right
= approximate_count_with_delimiters(@counts, Milestone)
%p
Active Users
%span.light.float-right
= number_with_delimiter(User.active.count)
#js-admin-statistics-container
.col-md-4
.info-well
.well-segment.admin-well.admin-well-features
......
---
title: 'Admin dashboard: Fetch and render statistics async'
merge_request: 32449
author:
type: other
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import StatisticsPanelApp from '~/admin/statistics_panel/components/app.vue';
import statisticsLabels from '~/admin/statistics_panel/constants';
import createStore from '~/admin/statistics_panel/store';
import { GlLoadingIcon } from '@gitlab/ui';
import mockStatistics from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Admin statistics app', () => {
let wrapper;
let store;
const createComponent = () => {
wrapper = shallowMount(StatisticsPanelApp, {
localVue,
store,
sync: false,
});
};
beforeEach(() => {
store = createStore();
});
afterEach(() => {
wrapper.destroy();
});
const findStats = idx => wrapper.findAll('.js-stats').at(idx);
describe('template', () => {
describe('when app is loading', () => {
it('renders a loading indicator', () => {
store.dispatch('requestStatistics');
createComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('when app has finished loading', () => {
const statistics = convertObjectPropsToCamelCase(mockStatistics, { deep: true });
it.each`
statistic | count | index
${'forks'} | ${12} | ${0}
${'issues'} | ${180} | ${1}
${'mergeRequests'} | ${31} | ${2}
${'notes'} | ${986} | ${3}
${'snippets'} | ${50} | ${4}
${'sshKeys'} | ${10} | ${5}
${'milestones'} | ${40} | ${6}
${'activeUsers'} | ${50} | ${7}
`('renders the count for the $statistic statistic', ({ statistic, count, index }) => {
const label = statisticsLabels[statistic];
store.dispatch('receiveStatisticsSuccess', statistics);
createComponent();
expect(findStats(index).text()).toContain(label);
expect(findStats(index).text()).toContain(count);
});
});
});
});
const mockStatistics = {
forks: 12,
issues: 180,
merge_requests: 31,
notes: 986,
snippets: 50,
ssh_keys: 10,
milestones: 40,
users: 50,
projects: 29,
groups: 9,
active_users: 50,
};
export default mockStatistics;
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as actions from '~/admin/statistics_panel/store/actions';
import * as types from '~/admin/statistics_panel/store/mutation_types';
import getInitialState from '~/admin/statistics_panel/store/state';
import mockStatistics from '../mock_data';
describe('Admin statistics panel actions', () => {
let mock;
let state;
beforeEach(() => {
state = getInitialState();
mock = new MockAdapter(axios);
});
describe('fetchStatistics', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics);
});
it('dispatches success with received data', done =>
testAction(
actions.fetchStatistics,
null,
state,
[],
[
{ type: 'requestStatistics' },
{
type: 'receiveStatisticsSuccess',
payload: expect.objectContaining(
convertObjectPropsToCamelCase(mockStatistics, { deep: true }),
),
},
],
done,
));
});
describe('error', () => {
beforeEach(() => {
mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500);
});
it('dispatches error', done =>
testAction(
actions.fetchStatistics,
null,
state,
[],
[
{
type: 'requestStatistics',
},
{
type: 'receiveStatisticsError',
payload: new Error('Request failed with status code 500'),
},
],
done,
));
});
});
describe('requestStatistic', () => {
it('should commit the request mutation', done =>
testAction(
actions.requestStatistics,
null,
state,
[{ type: types.REQUEST_STATISTICS }],
[],
done,
));
});
describe('receiveStatisticsSuccess', () => {
it('should commit received data', done =>
testAction(
actions.receiveStatisticsSuccess,
mockStatistics,
state,
[
{
type: types.RECEIVE_STATISTICS_SUCCESS,
payload: mockStatistics,
},
],
[],
done,
));
});
describe('receiveStatisticsError', () => {
it('should commit error', done => {
testAction(
actions.receiveStatisticsError,
500,
state,
[
{
type: types.RECEIVE_STATISTICS_ERROR,
payload: 500,
},
],
[],
done,
);
});
});
});
import createState from '~/admin/statistics_panel/store/state';
import * as getters from '~/admin/statistics_panel/store/getters';
describe('Admin statistics panel getters', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('getStatistics', () => {
describe('when statistics data exists', () => {
it('returns an array of statistics objects with key, label and value', () => {
state.statistics = { forks: 10, issues: 20 };
const statisticsLabels = {
forks: 'Forks',
issues: 'Issues',
};
const statisticsData = [
{ key: 'forks', label: 'Forks', value: 10 },
{ key: 'issues', label: 'Issues', value: 20 },
];
expect(getters.getStatistics(state)(statisticsLabels)).toEqual(statisticsData);
});
});
describe('when no statistics data exists', () => {
it('returns an array of statistics objects with key, label and sets value to null', () => {
state.statistics = null;
const statisticsLabels = {
forks: 'Forks',
issues: 'Issues',
};
const statisticsData = [
{ key: 'forks', label: 'Forks', value: null },
{ key: 'issues', label: 'Issues', value: null },
];
expect(getters.getStatistics(state)(statisticsLabels)).toEqual(statisticsData);
});
});
});
});
import mutations from '~/admin/statistics_panel/store/mutations';
import * as types from '~/admin/statistics_panel/store/mutation_types';
import getInitialState from '~/admin/statistics_panel/store/state';
import mockStatistics from '../mock_data';
describe('Admin statistics panel mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(`${types.REQUEST_STATISTICS}`, () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_STATISTICS](state);
expect(state.isLoading).toBe(true);
});
});
describe(`${types.RECEIVE_STATISTICS_SUCCESS}`, () => {
it('updates the store with the with statistics', () => {
mutations[types.RECEIVE_STATISTICS_SUCCESS](state, mockStatistics);
expect(state.isLoading).toBe(false);
expect(state.error).toBe(null);
expect(state.statistics).toEqual(mockStatistics);
});
});
describe(`${types.RECEIVE_STATISTICS_ERROR}`, () => {
it('sets error and clears data', () => {
const error = 500;
mutations[types.RECEIVE_STATISTICS_ERROR](state, error);
expect(state.isLoading).toBe(false);
expect(state.error).toBe(error);
expect(state.statistics).toEqual(null);
});
});
});
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