Commit bcfad41e authored by Mark Fletcher's avatar Mark Fletcher Committed by Rémy Coutable

Support multiple charts per page for Insights

- Config file change
  - No longer accepts single chart, array only
  - Display warning for old config detected
- Loads new charts upon tab change
  - Renders those successful
  - Flash message for errors
  - Summary of error instead of chart
parent 191909bc
......@@ -11,7 +11,7 @@ Configure the Insights that matter for your projects to explore data such as
triage hygiene, issues created/closed per a given period, average time for merge
requests to be merged and much more.
![Insights example stacked bar chart](img/insights_example_stacked_bar_chart.png)
![Insights example stacked bar chart](img/project_insights.png)
NOTE: **Note:**
This feature is [also available at the group level](../../group/insights/index.md).
......@@ -48,9 +48,28 @@ You may also consult the [group permissions table](../../permissions.md#group-me
## Writing your `.gitlab/insights.yml`
The `.gitlab/insights.yml` file defines the structure and order of the Insights
charts that will be displayed in the Insights page of your project or group.
charts that will be displayed in each Insights page of your project or group.
Each chart have a unique key, and a definition hash composed of key-value pairs.
Each page has a unique key and a collection of charts to fetch and display.
For example, here's a single definition for Insights that will display one page with one chart:
```yaml
bugsCharts:
title: 'Charts for Bugs'
charts:
- title: Monthly Bugs Created (bar)
type: bar
query:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
group_by: month
period_limit: 24
```
Each chart definition is made up of a hash composed of key-value pairs.
For example, here's single chart definition:
......@@ -237,43 +256,43 @@ you defined.
## Complete example
```yaml
monthlyBugsCreated:
title: Monthly Bugs Created (bar)
type: bar
query:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
group_by: month
period_limit: 24
weeklyBugsBySeverity:
title: Weekly Bugs By Severity (stacked bar)
type: stacked-bar
query:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
collection_labels:
- S1
- S2
- S3
- S4
group_by: week
period_limit: 104
monthlyBugsByTeamLine:
title: Monthly Bugs By Team (line)
type: line
query:
issuable_type: merge_request
issuable_state: opened
filter_labels:
- bug
collection_labels:
- Manage
- Plan
- Create
group_by: month
period_limit: 24
bugsCharts:
title: 'Charts for Bugs'
charts:
- title: Monthly Bugs Created (bar)
type: bar
query:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
group_by: month
period_limit: 24
- title: Weekly Bugs By Severity (stacked bar)
type: stacked-bar
query:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
collection_labels:
- S1
- S2
- S3
- S4
group_by: week
period_limit: 104
- title: Monthly Bugs By Team (line)
type: line
query:
issuable_type: merge_request
issuable_state: opened
filter_labels:
- bug
collection_labels:
- Manage
- Plan
- Create
group_by: month
period_limit: 24
```
......@@ -3,8 +3,8 @@ import Chart from 'chart.js';
export default {
props: {
info: {
type: Object,
chartTitle: {
type: String,
required: true,
},
data: {
......@@ -25,7 +25,7 @@ export default {
return {
title: {
display: true,
text: this.info.title,
text: this.chartTitle,
},
};
},
......
<script>
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import StackedBar from './chart_js/stacked_bar.vue';
import Bar from './chart_js/bar.vue';
import LineChart from './chart_js/line.vue';
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import InsightsPage from './insights_page.vue';
import InsightsConfigWarning from './insights_config_warning.vue';
export default {
components: {
GlLoadingIcon,
NavigationTabs,
StackedBar,
Bar,
LineChart,
InsightsPage,
InsightsConfigWarning,
GlDropdown,
GlDropdownItem,
},
props: {
endpoint: {
......@@ -25,15 +23,8 @@ export default {
},
},
computed: {
...mapState('insights', [
'configData',
'configLoading',
'activeTab',
'activeChart',
'chartData',
'chartLoading',
]),
navigationTabs() {
...mapState('insights', ['configData', 'configLoading', 'activeTab', 'activePage']),
pages() {
const { configData, activeTab } = this;
if (!configData) {
......@@ -50,50 +41,52 @@ export default {
isActive: this.activeTab === key,
}));
},
chartType() {
switch (this.activeChart.type) {
case 'line':
// Apparently Line clashes with another component
return 'line-chart';
default:
return this.activeChart.type;
}
},
drawChart() {
return this.chartData && this.activeChart && !this.chartLoading;
},
},
watch: {
activeChart() {
this.fetchChartData(this.queryEndpoint);
configPresent() {
return !this.configLoading && this.configData != null;
},
},
mounted() {
this.fetchConfigData(this.endpoint);
},
methods: {
...mapActions('insights', ['fetchConfigData', 'fetchChartData', 'setActiveTab']),
onChangeTab(scope) {
this.setActiveTab(scope);
...mapActions('insights', ['fetchConfigData', 'setActiveTab']),
onChangePage(page) {
this.setActiveTab(page);
},
},
};
</script>
<template>
<div class="insights-container">
<div class="insights-container prepend-top-default">
<div v-if="configLoading" class="insights-config-loading text-center">
<gl-loading-icon :inline="true" :size="4" />
<gl-loading-icon :inline="true" size="lg" />
</div>
<div v-else class="insights-wrapper">
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
<navigation-tabs :tabs="navigationTabs" @onChangeTab="onChangeTab" />
</div>
<div class="insights-chart">
<div v-if="chartLoading" class="insights-chart-loading text-center">
<gl-loading-icon :inline="true" :size="4" />
</div>
<component :is="chartType" v-if="drawChart" :info="activeChart" :data="chartData" />
</div>
<div v-else-if="configPresent" class="insights-wrapper">
<gl-dropdown
class="js-insights-dropdown col-8 col-md-9 gl-pr-0"
menu-class="w-100 mw-100"
toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
:text="__('Select Page')"
>
<gl-dropdown-item
v-for="page in pages"
:key="page.scope"
class="w-100"
@click="onChangePage(page.scope)"
>{{ page.name }}</gl-dropdown-item
>
</gl-dropdown>
<insights-page :query-endpoint="queryEndpoint" :page-config="activePage" />
</div>
<insights-config-warning
v-else
:title="__('Invalid Insights config file detected')"
:summary="
__(
'Please check the configuration file to ensure that it is available and the YAML is valid',
)
"
image="illustrations/monitoring/getting_started.svg"
/>
</div>
</template>
<script>
export default {
props: {
error: {
type: String,
required: true,
},
chartName: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
summary: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="row js-empty-state empty-state">
<div class="col-12">
<div class="text-content">
<h4 class="content-title">{{ title }}: "{{ chartName }}"</h4>
<p class="content-summary">{{ summary }}</p>
<p class="content-summary">{{ error }}</p>
</div>
</div>
</div>
</template>
<script>
import { imagePath } from '~/lib/utils/common_utils';
export default {
props: {
image: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
summary: {
type: String,
required: true,
},
},
computed: {
imageSrc() {
return imagePath(this.image);
},
},
};
</script>
<template>
<div class="row js-empty-state empty-state">
<div class="col-12">
<div class="svg-content"><img class="content-image" :src="imageSrc" /></div>
</div>
<div class="col-12">
<div class="text-content">
<h4 class="content-title text-center">{{ title }}</h4>
<p class="content-summary">{{ summary }}</p>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import InsightsChartError from './insights_chart_error.vue';
import InsightsConfigWarning from './insights_config_warning.vue';
import StackedBar from './chart_js/stacked_bar.vue';
import Bar from './chart_js/bar.vue';
import LineChart from './chart_js/line.vue';
export default {
components: {
GlLoadingIcon,
InsightsChartError,
InsightsConfigWarning,
StackedBar,
Bar,
LineChart,
},
props: {
queryEndpoint: {
type: String,
required: true,
},
pageConfig: {
type: Object,
required: true,
},
},
computed: {
...mapState('insights', ['chartData', 'pageLoading']),
charts() {
return this.pageConfig.charts;
},
chartKeys() {
return this.charts.map(chart => chart.title);
},
storeKeys() {
return Object.keys(this.chartData);
},
hasChartsConfigured() {
return !_.isUndefined(this.charts) && this.charts.length > 0;
},
},
watch: {
pageConfig() {
this.setChartData({});
this.setPageLoading(true);
this.fetchCharts();
},
},
mounted() {
this.fetchCharts();
},
methods: {
...mapActions('insights', ['fetchChartData', 'setChartData', 'setPageLoading']),
chartType(type) {
switch (type) {
case 'line':
// Apparently Line clashes with another component
return 'line-chart';
default:
return type;
}
},
fetchCharts() {
if (this.hasChartsConfigured) {
const insightsRequests = this.charts.map(chart =>
this.fetchChartData({ endpoint: this.queryEndpoint, chart }),
);
Promise.all(insightsRequests)
.then(() => {
this.setPageLoading(!this.storePopulated());
})
.catch(() => {
this.setPageLoading(false);
});
}
},
storePopulated() {
return this.chartKeys.filter(key => this.storeKeys.includes(key)).length > 0;
},
},
};
</script>
<template>
<div class="insights-page">
<div v-if="hasChartsConfigured" class="js-insights-page-container">
<h4 class="text-center">{{ pageConfig.title }}</h4>
<div v-if="!pageLoading" class="insights-charts">
<div v-for="(insights, key, index) in chartData" :key="index" class="insights-chart">
<component
:is="chartType(insights.type)"
v-if="insights.loaded"
:chart-title="key"
:data="insights.data"
/>
<insights-chart-error
v-else
:chart-name="key"
:title="__('This chart could not be displayed')"
:summary="__('Please check the configuration file for this chart')"
:error="insights.error"
/>
</div>
</div>
<div v-else class="insights-chart-loading text-center">
<gl-loading-icon :inline="true" size="lg" />
</div>
</div>
<insights-config-warning
v-else
:title="__('There are no charts configured for this page')"
:summary="
__(
'Please check the configuration file to ensure that a collection of charts has been declared.',
)
"
image="illustrations/monitoring/getting_started.svg"
/>
</div>
</template>
......@@ -23,37 +23,39 @@ export const fetchConfigData = ({ dispatch }, endpoint) => {
});
};
export const requestChartData = ({ commit }) => commit(types.REQUEST_CHART);
export const receiveChartDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_CHART_SUCCESS, data);
export const receiveChartDataError = ({ commit }) => commit(types.RECEIVE_CHART_ERROR);
export const receiveChartDataSuccess = ({ commit }, { chart, data }) =>
commit(types.RECEIVE_CHART_SUCCESS, { chart, data });
export const receiveChartDataError = ({ commit }, { chart, error }) =>
commit(types.RECEIVE_CHART_ERROR, { chart, error });
export const fetchChartData = ({ dispatch, state }, endpoint) => {
const { activeChart } = state;
dispatch('requestChartData');
return axios
export const fetchChartData = ({ dispatch }, { endpoint, chart }) =>
axios
.post(endpoint, {
query: activeChart.query,
chart_type: activeChart.type,
query: chart.query,
chart_type: chart.type,
})
.then(({ data }) => dispatch('receiveChartDataSuccess', data))
.then(({ data }) => dispatch('receiveChartDataSuccess', { chart, data }))
.catch(error => {
const message = `${__('There was an error gathering the chart data')}: ${
error.response.data.message
}`;
let message = `${__('There was an error gathering the chart data')}`;
if (error.response.data && error.response.data.message) {
message += `: ${error.response.data.message}`;
}
createFlash(message);
dispatch('receiveChartDataError');
dispatch('receiveChartDataError', { chart, error: message });
});
};
export const setActiveTab = ({ commit, state }, key) => {
const { configData } = state;
const chart = configData[key];
const page = configData[key];
commit(types.SET_ACTIVE_TAB, key);
commit(types.SET_ACTIVE_CHART, chart);
commit(types.SET_ACTIVE_PAGE, page);
};
export const setChartData = ({ commit }, store) => commit(types.SET_CHART_DATA, store);
export const setPageLoading = ({ commit }, pageLoading) =>
commit(types.SET_PAGE_LOADING, pageLoading);
export default () => {};
......@@ -5,4 +5,6 @@ export const REQUEST_CHART = 'REQUEST_CHART';
export const RECEIVE_CHART_SUCCESS = 'RECEIVE_CHART_SUCCESS';
export const RECEIVE_CHART_ERROR = 'RECEIVE_CHART_ERROR';
export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB';
export const SET_ACTIVE_CHART = 'SET_ACTIVE_CHART';
export const SET_ACTIVE_PAGE = 'SET_ACTIVE_PAGE';
export const SET_CHART_DATA = 'SET_CHART_DATA';
export const SET_PAGE_LOADING = 'SET_PAGE_LOADING';
......@@ -14,24 +14,36 @@ export default {
state.configLoading = false;
},
[types.REQUEST_CHART](state) {
state.chartData = null;
state.chartLoading = true;
},
[types.RECEIVE_CHART_SUCCESS](state, data) {
state.chartData = data;
state.chartLoading = false;
state.redraw = true;
},
[types.RECEIVE_CHART_ERROR](state) {
state.chartData = null;
state.chartLoading = false;
[types.RECEIVE_CHART_SUCCESS](state, { chart, data }) {
const { type } = chart;
state.chartData[chart.title] = {
type,
data,
loaded: true,
};
},
[types.RECEIVE_CHART_ERROR](state, { chart, error }) {
const { type } = chart;
state.chartData[chart.title] = {
type,
data: null,
loaded: false,
error,
};
},
[types.SET_ACTIVE_TAB](state, tab) {
state.activeTab = tab;
},
[types.SET_ACTIVE_CHART](state, chartData) {
state.activeChart = chartData;
[types.SET_ACTIVE_PAGE](state, pageData) {
state.activePage = pageData;
},
[types.SET_CHART_DATA](state, chartData) {
state.chartData = chartData;
},
[types.SET_PAGE_LOADING](state, pageLoading) {
state.pageLoading = pageLoading;
},
};
......@@ -2,7 +2,7 @@ export default () => ({
configData: null,
configLoading: true,
activeTab: null,
activeChart: null,
chartLoading: false,
chartData: null,
activePage: null,
chartData: {},
pageLoading: true,
});
......@@ -6,6 +6,9 @@ module InsightsActions
included do
before_action :check_insights_available!
before_action :validate_params, only: [:query]
rescue_from Gitlab::Insights::Validators::ParamsValidator::ParamsValidatorError,
Gitlab::Insights::Finders::IssuableFinder::IssuableFinderError, with: :render_insights_chart_error
end
def show
......@@ -69,4 +72,8 @@ module InsightsActions
def config_data
insights_entity.insights_config
end
def render_insights_chart_error(exception)
render json: { message: exception.message }, status: :unprocessable_entity
end
end
- page_title _('Insights')
%div{ class: container_class }
%h2 Insights chart data
#js-insights-pane{ data: { endpoint: endpoint, query_endpoint: query_endpoint } }
---
title: Support multiple chart per page for Insights
merge_request:
author:
type: changed
weeklyMerged:
title: Weekly merged merge requests
type: bar
query:
issuable_type: merge_request
issuable_state: merged
group_by: week
monthlyMerged:
title: Monthly merged merge requests
type: bar
query:
issuable_type: merge_request
issuable_state: merged
group_by: month
monthlyByPriority:
title: Monthly issues by priority
type: stacked-bar
query:
issuable_type: issue
collection_labels:
- priority::1
- priority::2
- priority::3
- priority::4
group_by: month
monthlyBySeverity:
title: Monthly issues by severity
type: stacked-bar
query:
issuable_type: issue
collection_labels:
- severity::1
- severity::2
- severity::3
- severity::4
group_by: month
issues:
title: Issues Dashboard
charts:
- title: Bugs created per month by Priority
type: stacked-bar
query:
issuable_type: issue
filter_labels:
- bug
collection_labels:
- priority::1
- priority::2
- priority::3
- priority::4
group_by: month
- title: Bugs created per month by Severity
type: stacked-bar
query:
issuable_type: issue
filter_labels:
- bug
collection_labels:
- severity::1
- severity::2
- severity::3
- severity::4
group_by: month
mergeRequests:
title: Merge Requests Dashboard
charts:
- title: Merge Requests merged per week
type: bar
query:
issuable_type: merge_request
issuable_state: merged
group_by: week
- title: Merge Requests merged per month
type: bar
query:
issuable_type: merge_request
issuable_state: merged
group_by: month
......@@ -4,6 +4,15 @@ import * as types from 'ee/insights/stores/modules/insights/mutation_types';
describe('Insights mutations', () => {
let state;
const chart = {
title: 'Bugs Per Team',
type: 'stacked-bar',
query: {
name: 'filter_issues_by_label_category',
filter_label: 'bug',
category_labels: ['Plan', 'Create', 'Manage'],
},
};
beforeEach(() => {
state = createState();
......@@ -57,17 +66,21 @@ describe('Insights mutations', () => {
});
});
describe(types.REQUEST_CHART, () => {
it('sets chartLoading state when starting request', () => {
mutations[types.REQUEST_CHART](state);
describe(types.SET_ACTIVE_TAB, () => {
it('sets activeTab state', () => {
mutations[types.SET_ACTIVE_TAB](state, 'key');
expect(state.chartLoading).toBe(true);
expect(state.activeTab).toBe('key');
});
});
describe(types.SET_ACTIVE_PAGE, () => {
const pageData = { key: 'page' };
it('resets chartData state when starting request', () => {
mutations[types.REQUEST_CHART](state);
it('sets activePage state', () => {
mutations[types.SET_ACTIVE_PAGE](state, pageData);
expect(state.chartData).toBe(null);
expect(state.activePage).toBe(pageData);
});
});
......@@ -90,52 +103,84 @@ describe('Insights mutations', () => {
],
};
it('sets chartLoading state to false on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, data);
it('sets charts loaded state to true on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, { chart, data });
const { chartData } = state;
expect(state.chartLoading).toBe(false);
expect(chartData[chart.title].loaded).toBe(true);
});
it('sets chartData state to incoming data on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, data);
it('sets charts data to incoming data on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, { chart, data });
expect(state.chartData).toBe(data);
const { chartData } = state;
expect(chartData[chart.title].data).toBe(data);
});
it('sets charts type to incoming type on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, { chart, data });
const { chartData } = state;
expect(chartData[chart.title].type).toBe(chart.type);
});
});
describe(types.RECEIVE_CHART_ERROR, () => {
it('sets chartLoading state to false on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state);
const error = 'myError';
it('sets charts loaded state to false on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state, { chart, error });
const { chartData } = state;
expect(state.chartLoading).toBe(false);
expect(chartData[chart.title].loaded).toBe(false);
});
it('sets chartData state to null on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state);
it('sets charts data state to null on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state, { chart, error });
expect(state.chartData).toBe(null);
const { chartData } = state;
expect(chartData[chart.title].data).toBe(null);
});
});
describe(types.SET_ACTIVE_TAB, () => {
it('sets activeTab state', () => {
mutations[types.SET_ACTIVE_TAB](state, 'key');
it('sets charts type to incoming type on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state, { chart, error });
expect(state.activeTab).toBe('key');
const { chartData } = state;
expect(chartData[chart.title].type).toBe(chart.type);
});
it('sets charts error state to error message on error', () => {
mutations[types.RECEIVE_CHART_ERROR](state, { chart, error });
const { chartData } = state;
expect(chartData[chart.title].error).toBe(error);
});
});
describe(types.SET_ACTIVE_CHART, () => {
let chartData;
describe(types.SET_CHART_DATA, () => {
const chartData = { a: { data: 'data' } };
beforeEach(() => {
chartData = { key: 'chart' };
it('sets chartData state', () => {
mutations[types.SET_CHART_DATA](state, chartData);
expect(state.chartData).toBe(chartData);
});
});
describe(types.SET_PAGE_LOADING, () => {
const pageLoading = true;
it('sets activeChart state', () => {
mutations[types.SET_ACTIVE_CHART](state, chartData);
it('sets pageLoading state', () => {
mutations[types.SET_PAGE_LOADING](state, pageLoading);
expect(state.activeChart).toBe(chartData);
expect(state.pageLoading).toBe(pageLoading);
});
});
});
......@@ -11,7 +11,7 @@ describe('Insights bar chart component', () => {
beforeEach(() => {
mountComponent = data => {
const props = data || {
info: chartInfo,
chartTitle: chartInfo.title,
data: chartData,
};
return mountComponentWithStore(Component, { props });
......
......@@ -11,7 +11,7 @@ describe('Insights line chart component', () => {
beforeEach(() => {
mountComponent = data => {
const props = data || {
info: chartInfo,
chartTitle: chartInfo.title,
data: chartData,
};
return mountComponentWithStore(Component, { props });
......
......@@ -11,7 +11,7 @@ describe('Insights Stacked Bar chart component', () => {
beforeEach(() => {
mountComponent = data => {
const props = data || {
info: chartInfo,
chartTitle: chartInfo.title,
data: chartData,
};
return mountComponentWithStore(Component, { props });
......
import Vue from 'vue';
import InsightsPage from 'ee/insights/components/insights_page.vue';
import { createStore } from 'ee/insights/stores';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { chartInfo, pageInfo, pageInfoNoCharts, chartData } from '../mock_data';
describe('Insights page component', () => {
let component;
let store;
let Component;
beforeEach(() => {
store = createStore();
spyOn(store, 'dispatch').and.stub();
Component = Vue.extend(InsightsPage);
});
afterEach(() => {
component.$destroy();
});
describe('no chart config available', () => {
it('shows an empty state', done => {
component = mountComponentWithStore(Component, {
store,
props: {
queryEndpoint: `${gl.TEST_HOST}/query`,
pageConfig: pageInfoNoCharts,
},
});
expect(component.$el.querySelector('.js-empty-state')).not.toBe(null);
done();
});
});
describe('charts configured', () => {
beforeEach(() => {
component = mountComponentWithStore(Component, {
store,
props: {
queryEndpoint: `${gl.TEST_HOST}/query`,
pageConfig: pageInfo,
},
});
});
it('fetches chart data when mounted', done => {
expect(store.dispatch).toHaveBeenCalledWith('insights/fetchChartData', {
endpoint: `${gl.TEST_HOST}/query`,
chart: chartInfo,
});
done();
});
describe('when charts loading', () => {
beforeEach(() => {
component.$store.state.insights.pageLoading = true;
});
it('renders loading state', done => {
Vue.nextTick(() => {
expect(
component.$el.querySelector('.js-insights-page-container .insights-chart-loading'),
).not.toBe(null);
done();
});
});
it('does not display chart area', done => {
Vue.nextTick(() => {
expect(component.$el.querySelector('.js-insights-page-container .insights-charts')).toBe(
null,
);
done();
});
});
});
describe('when charts loaded', () => {
beforeEach(() => {
component.$store.state.insights.pageLoading = false;
component.$store.state.insights.chartData[chartInfo.title] = {
type: chartInfo.type,
data: chartData,
loaded: true,
};
});
it('displays correct chart post load', done => {
component.$nextTick(() => {
const chartCanvas = component.$el.querySelectorAll(
'.js-insights-page-container .insights-charts .insights-chart canvas',
);
expect(chartCanvas.length).toEqual(1);
expect(chartCanvas[0].classList).toContain('bar');
done();
});
});
});
describe('chart data retrieve error', () => {
const error = 'my error';
beforeEach(() => {
component.$store.state.insights.pageLoading = false;
component.$store.state.insights.chartData[chartInfo.title] = {
type: chartInfo.type,
data: null,
loaded: false,
error,
};
});
it('displays info about the error', done => {
component.$nextTick(() => {
const errorElements = component.$el.querySelectorAll(
'.js-insights-page-container .insights-charts .insights-chart .js-empty-state',
);
expect(errorElements.length).toEqual(1);
expect(errorElements[0].textContent).toContain(error);
done();
});
});
});
describe('pageConfig changes', () => {
it('reflects new state', done => {
// Establish rendered state
component.$nextTick();
component.pageConfig = pageInfoNoCharts;
component.$nextTick(() => {
expect(component.$el.querySelector('.js-empty-state')).not.toBe(null);
done();
});
});
});
});
});
......@@ -47,109 +47,40 @@ describe('Insights component', () => {
describe('when config loaded', () => {
const title = 'Bugs Per Team';
const page = {
title,
};
beforeEach(() => {
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.activePage = page;
vm.$store.state.insights.configData = {
bugsPerTeam: {
title,
},
bugsPerTeam: page,
};
});
it('has the correct nav tabs', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
expect(vm.$el.querySelector('.nav-links li a').innerText.trim()).toBe(title);
done();
});
});
});
describe('when activeChart changes', () => {
it('loads chart data', done => {
vm.$store.state.insights.activeChart = { key: 'chart' };
vm.$nextTick(() => {
expect(store.dispatch).toHaveBeenCalledWith(
'insights/fetchChartData',
`${gl.TEST_HOST}/query`,
expect(vm.$el.querySelector('.js-insights-dropdown')).not.toBe(null);
expect(vm.$el.querySelector('.js-insights-dropdown .dropdown-item').innerText.trim()).toBe(
title,
);
done();
});
});
});
describe('when loading chart', () => {
describe('empty config', () => {
beforeEach(() => {
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.chartLoading = true;
});
it('hides the config loading state', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-config-loading')).toBe(null);
done();
});
vm.$store.state.insights.configData = null;
});
it('renders chart loading state', done => {
it('it displays a warning', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-wrapper')).not.toBe(null);
expect(vm.$el.querySelector('.insights-chart .insights-chart-loading')).not.toBe(null);
done();
});
});
it('chart is not shown', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-wrapper')).not.toBe(null);
expect(vm.$el.querySelector('.insights-chart .chart-canvas-wrapper')).toBe(null);
done();
});
});
});
describe('when chart loaded', () => {
const chart = {
title: 'Bugs Per Team',
type: 'stacked-bar',
query: {
name: 'filter_issues_by_label_category',
filter_label: 'bug',
category_labels: ['Plan', 'Create', 'Manage'],
},
};
beforeEach(() => {
vm.$store.state.insights.activeChart = chart;
vm.$store.state.insights.chartData = {};
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.chartLoading = false;
});
it('renders chart', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-wrapper')).not.toBe(null);
expect(vm.$el.querySelector('.insights-chart .chart-canvas-wrapper')).not.toBe(null);
done();
});
});
it('hides chart loading state', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-wrapper')).not.toBe(null);
expect(vm.$el.querySelector('.insights-chart .insights-chart-loading')).toBe(null);
done();
});
});
it('renders the correct type of chart', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.insights-wrapper')).not.toBe(null);
expect(
vm.$el.querySelector('.insights-chart .chart-canvas-wrapper canvas.stacked-bar'),
).not.toBe(null);
expect(vm.$el.querySelector('.js-empty-state').innerText.trim()).toContain(
'Invalid Insights config file detected',
);
done();
});
});
......
......@@ -25,3 +25,14 @@ export const chartData = {
},
],
};
export const pageInfo = {
title: 'Title',
charts: [chartInfo],
};
export const pageInfoNoCharts = {
page: {
title: 'Page No Charts',
},
};
......@@ -2,7 +2,6 @@ import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'spec/helpers/vuex_action_helper';
import * as actions from 'ee/insights/stores/modules/insights/actions';
import store from 'ee/insights/stores/';
describe('Insights store actions', () => {
const key = 'bugsPerTeam';
......@@ -15,10 +14,14 @@ describe('Insights store actions', () => {
category_labels: ['Plan', 'Create', 'Manage'],
},
};
const page = {
title: 'Bugs Charts',
charts: [chart],
};
const configData = {};
beforeEach(() => {
configData[key] = chart;
configData[key] = page;
});
describe('requestConfig', () => {
......@@ -31,9 +34,9 @@ describe('Insights store actions', () => {
it('commits RECEIVE_CONFIG_SUCCESS', done => {
testAction(
actions.receiveConfigSuccess,
[{ chart: 'chart' }],
[configData],
null,
[{ type: 'RECEIVE_CONFIG_SUCCESS', payload: [{ chart: 'chart' }] }],
[{ type: 'RECEIVE_CONFIG_SUCCESS', payload: [configData] }],
[],
done,
);
......@@ -97,19 +100,20 @@ describe('Insights store actions', () => {
});
});
describe('requestChartData', () => {
it('commits REQUEST_CHART', done => {
testAction(actions.requestChartData, null, null, [{ type: 'REQUEST_CHART' }], [], done);
});
});
describe('receiveChartDataSuccess', () => {
const chartData = { type: 'bar', data: {} };
it('commits RECEIVE_CHART_SUCCESS', done => {
testAction(
actions.receiveChartDataSuccess,
{ type: 'bar', data: {} },
{ chart, data: chartData },
null,
[{ type: 'RECEIVE_CHART_SUCCESS', payload: { type: 'bar', data: {} } }],
[
{
type: 'RECEIVE_CHART_SUCCESS',
payload: { chart, data: chartData },
},
],
[],
done,
);
......@@ -117,12 +121,19 @@ describe('Insights store actions', () => {
});
describe('receiveChartDataError', () => {
const error = 'myError';
it('commits RECEIVE_CHART_ERROR', done => {
testAction(
actions.receiveChartDataError,
{ chart, error },
null,
null,
[{ type: 'RECEIVE_CHART_ERROR' }],
[
{
type: 'RECEIVE_CHART_ERROR',
payload: { chart, error },
},
],
[],
done,
);
......@@ -132,7 +143,8 @@ describe('Insights store actions', () => {
describe('fetchChartData', () => {
let mock;
let dispatch;
let state;
const payload = { endpoint: `${gl.TEST_HOST}/query`, chart };
const chartData = {
labels: ['January'],
datasets: [
......@@ -152,64 +164,110 @@ describe('Insights store actions', () => {
};
beforeEach(() => {
store.state.insights.activeChart = chart;
state = store.state.insights;
dispatch = jasmine.createSpy('dispatch');
mock = new MockAdapter(axios);
mock
.onPost(`${gl.TEST_HOST}/query`, {
query: chart.query,
chart_type: chart.type,
})
.reply(200, chartData);
});
afterEach(() => {
mock.restore();
});
it('calls requestChartData', done => {
const context = {
dispatch,
state,
};
describe('successful request', () => {
beforeEach(() => {
mock
.onPost(`${gl.TEST_HOST}/query`, {
query: chart.query,
chart_type: chart.type,
})
.reply(200, chartData);
});
actions
.fetchChartData(context, `${gl.TEST_HOST}/query`)
.then(() => {
expect(dispatch.calls.argsFor(0)).toEqual(['requestChartData']);
})
.then(done)
.catch(done.fail);
it('calls receiveChartDataSuccess with chart data', done => {
const context = {
dispatch,
};
actions
.fetchChartData(context, payload)
.then(() => {
expect(dispatch.calls.argsFor(0)).toEqual([
'receiveChartDataSuccess',
{ chart, data: chartData },
]);
})
.then(done)
.catch(done.fail);
});
});
it('calls receiveChartDataSuccess with chart data', done => {
const context = {
dispatch,
state,
};
describe('failed request', () => {
beforeEach(() => {
mock
.onPost(`${gl.TEST_HOST}/query`, {
query: chart.query,
chart_type: chart.type,
})
.reply(500);
});
actions
.fetchChartData(context, `${gl.TEST_HOST}/query`)
.then(() => {
expect(dispatch.calls.argsFor(1)).toEqual(['receiveChartDataSuccess', chartData]);
})
.then(done)
.catch(done.fail);
it('calls receiveChartDataError with error message', done => {
const context = {
dispatch,
};
actions
.fetchChartData(context, payload)
.then(() => {
expect(dispatch.calls.argsFor(0)).toEqual([
'receiveChartDataError',
{ chart, error: 'There was an error gathering the chart data' },
]);
})
.then(done)
.catch(done.fail);
});
});
});
describe('setActiveTab', () => {
it('commits SET_ACTIVE_TAB and SET_ACTIVE_CHART', done => {
it('commits SET_ACTIVE_TAB and SET_ACTIVE_PAGE', done => {
const state = { configData };
testAction(
actions.setActiveTab,
key,
state,
[{ type: 'SET_ACTIVE_TAB', payload: key }, { type: 'SET_ACTIVE_CHART', payload: chart }],
[{ type: 'SET_ACTIVE_TAB', payload: key }, { type: 'SET_ACTIVE_PAGE', payload: page }],
[],
done,
);
});
});
describe('setChartData', () => {
it('commits SET_CHART_DATA', done => {
const chartData = { a: { data: 'data' } };
testAction(
actions.setChartData,
chartData,
null,
[{ type: 'SET_CHART_DATA', payload: chartData }],
[],
done,
);
});
});
describe('setPageLoading', () => {
it('commits SET_PAGE_LOADING', done => {
const pageLoading = false;
testAction(
actions.setPageLoading,
pageLoading,
null,
[{ type: 'SET_PAGE_LOADING', payload: false }],
[],
done,
);
......
......@@ -6331,6 +6331,9 @@ msgstr ""
msgid "Introducing Your Conversational Development Index"
msgstr ""
msgid "Invalid Insights config file detected"
msgstr ""
msgid "Invalid Login or password"
msgstr ""
......@@ -8460,6 +8463,15 @@ msgstr ""
msgid "Please accept the Terms of Service before continuing."
msgstr ""
msgid "Please check the configuration file for this chart"
msgstr ""
msgid "Please check the configuration file to ensure that a collection of charts has been declared."
msgstr ""
msgid "Please check the configuration file to ensure that it is available and the YAML is valid"
msgstr ""
msgid "Please choose a group URL with no special characters."
msgstr ""
......@@ -10142,6 +10154,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
msgid "Select Page"
msgstr ""
msgid "Select a group to invite"
msgstr ""
......@@ -11523,6 +11538,9 @@ msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no charts configured for this page"
msgstr ""
msgid "There are no closed issues"
msgstr ""
......@@ -11637,6 +11655,9 @@ msgstr ""
msgid "This branch has changed since you started editing. Would you like to create a new branch?"
msgstr ""
msgid "This chart could not be displayed"
msgstr ""
msgid "This commit is part of merge request %{link_to_merge_request}. Comments created here will be created in the context of that merge request."
msgstr ""
......
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