Commit 70e259de authored by Martin Wortschack's avatar Martin Wortschack Committed by Clement Ho

Add metric chart component

- Introduce reusable metric chart component
for productivity analytics
parent 9aa4f957
<script>
import _ from 'underscore';
import { s__ } from '~/locale';
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'MetricChart',
components: {
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
Icon,
},
props: {
title: {
type: String,
required: false,
default: '',
},
description: {
type: String,
required: false,
default: '',
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
metricTypes: {
type: Array,
required: false,
default: () => [],
},
selectedMetric: {
type: String,
required: false,
default: '',
},
chartData: {
type: [Object, Array],
required: false,
default: () => {},
},
},
computed: {
hasMetricTypes() {
return this.metricTypes.length;
},
metricDropdownLabel() {
const foundMetric = this.metricTypes.find(m => m.key === this.selectedMetric);
return foundMetric ? foundMetric.label : s__('MetricChart|Please select a metric');
},
hasChartData() {
return !_.isEmpty(this.chartData);
},
},
methods: {
isSelectedMetric(key) {
return this.selectedMetric === key;
},
},
};
</script>
<template>
<div>
<h5 v-if="title">{{ title }}</h5>
<gl-loading-icon v-if="isLoading" size="md" class="my-4 py-4" />
<template v-else>
<div v-if="!hasChartData" ref="noData" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
</div>
<template v-else>
<gl-dropdown
v-if="hasMetricTypes"
class="mb-4 metric-dropdown"
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="metricDropdownLabel"
>
<gl-dropdown-item
v-for="metric in metricTypes"
:key="metric.key"
active-class="is-active"
class="w-100"
@click="$emit('metricTypeChange', metric.key)"
>
<span class="d-flex">
<icon
:title="s__('MetricChart|Selected')"
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric(metric.key),
}"
name="mobile-issue-close"
:aria-label="s__('MetricChart|Selected')"
/>
{{ metric.label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<p v-if="description" class="text-muted">{{ description }}</p>
<div ref="chart">
<slot v-if="hasChartData"></slot>
</div>
</template>
</template>
</div>
</template>
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MetricChart component template when isLoading is false and chart data is empty matches the snapshot 1`] = `
<div>
<!---->
<div
class="bs-callout bs-callout-info"
>
There is no data available. Please change your selection.
</div>
</div>
`;
exports[`MetricChart component template when isLoading is false and chartData is not empty and metricTypes exist matches the snapshot 1`] = `
<div>
<!---->
<gldropdown-stub
class="mb-4 metric-dropdown"
menu-class="w-100 mw-100"
text="Please select a metric"
toggle-class="dropdown-menu-toggle w-100"
>
<gldropdownitem-stub
active-class="is-active"
class="w-100"
>
<span
class="d-flex"
>
<icon-stub
aria-label="Selected"
class="flex-shrink-0 append-right-4 invisible"
cssclasses=""
name="mobile-issue-close"
size="16"
title="Selected"
/>
Time from last commit to merge
</span>
</gldropdownitem-stub>
<gldropdownitem-stub
active-class="is-active"
class="w-100"
>
<span
class="d-flex"
>
<icon-stub
aria-label="Selected"
class="flex-shrink-0 append-right-4 invisible"
cssclasses=""
name="mobile-issue-close"
size="16"
title="Selected"
/>
Time from first comment to last commit
</span>
</gldropdownitem-stub>
</gldropdown-stub>
<!---->
<div>
mockChart
</div>
</div>
`;
exports[`MetricChart component template when isLoading is true matches the snapshot 1`] = `
<div>
<!---->
<glloadingicon-stub
class="my-4 py-4"
color="orange"
label="Loading"
size="md"
/>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import MetricChart from 'ee/analytics/productivity_analytics/components/metric_chart.vue';
import { GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
describe('MetricChart component', () => {
let wrapper;
const defaultProps = {
title: 'My Chart',
};
const mockChart = 'mockChart';
const metricTypes = [
{
key: 'time_to_merge',
label: 'Time from last commit to merge',
},
{
key: 'time_to_last_commit',
label: 'Time from first comment to last commit',
},
];
const factory = (props = defaultProps) => {
wrapper = shallowMount(MetricChart, {
sync: false,
propsData: { ...props },
slots: {
default: mockChart,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findLoadingIndicator = () => wrapper.find(GlLoadingIcon);
const findNoDataSection = () => wrapper.find({ ref: 'noData' });
const findMetricDropdown = () => wrapper.find(GlDropdown);
const findMetricDropdownItems = () => findMetricDropdown().findAll(GlDropdownItem);
const findChartSlot = () => wrapper.find({ ref: 'chart' });
describe('template', () => {
describe('when title exists', () => {
beforeEach(() => {
factory();
});
it('renders a title', () => {
expect(wrapper.text()).toContain('My Chart');
});
});
describe("when title doesn't exist", () => {
beforeEach(() => {
factory({ title: null, description: null });
});
it("doesn't render a title", () => {
expect(wrapper.text()).not.toContain('My Chart');
});
});
describe('when isLoading is true', () => {
beforeEach(() => {
factory({ isLoading: true });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
});
describe('when isLoading is false', () => {
const isLoading = false;
it('does not render a loading indicator', () => {
factory({ isLoading });
expect(findLoadingIndicator().exists()).toBe(false);
});
describe('and chart data is empty', () => {
beforeEach(() => {
factory({ isLoading, chartData: [] });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('does not show the slot for the chart', () => {
expect(findChartSlot().exists()).toBe(false);
});
it('shows a "no data" info text', () => {
expect(findNoDataSection().text()).toContain(
'There is no data available. Please change your selection.',
);
});
});
describe('and chartData is not empty', () => {
const chartData = [[0, 1]];
describe('and metricTypes exist', () => {
beforeEach(() => {
factory({ isLoading, metricTypes, chartData });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders a metric dropdown', () => {
expect(findMetricDropdown().exists()).toBe(true);
});
it('renders a dropdown item for each item in metricTypes', () => {
expect(findMetricDropdownItems().length).toBe(2);
});
it('should emit `metricTypeChange` event when dropdown item gets clicked', () => {
jest.spyOn(wrapper.vm, '$emit');
findMetricDropdownItems()
.at(0)
.vm.$emit('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('metricTypeChange', 'time_to_merge');
});
it('should set the `invisible` class on the icon of the first dropdown item', () => {
wrapper.setProps({ selectedMetric: 'time_to_last_commit' });
expect(
findMetricDropdownItems()
.at(0)
.find(Icon)
.classes(),
).toContain('invisible');
});
});
describe('and a description exists', () => {
it('renders a description', () => {
factory({ isLoading, chartData, description: 'Test description' });
expect(wrapper.text()).toContain('Test description');
});
});
it('contains chart from slot', () => {
factory({ isLoading, chartData });
expect(findChartSlot().text()).toBe(mockChart);
});
});
});
});
describe('computed', () => {
describe('hasMetricTypes', () => {
it('returns true if metricTypes exist', () => {
factory({ metricTypes });
expect(wrapper.vm.hasMetricTypes).toBe(2);
});
it('returns true if no metricTypes exist', () => {
factory();
expect(wrapper.vm.hasMetricTypes).toBe(0);
});
});
describe('metricDropdownLabel', () => {
describe('when a metric is selected', () => {
it('returns the label of the currently selected metric', () => {
factory({ metricTypes, selectedMetric: 'time_to_merge' });
expect(wrapper.vm.metricDropdownLabel).toBe('Time from last commit to merge');
});
});
describe('when no metric is selected', () => {
it('returns the default dropdown label', () => {
factory({ metricTypes });
expect(wrapper.vm.metricDropdownLabel).toBe('Please select a metric');
});
});
});
describe('hasChartData', () => {
describe('when chartData is an object', () => {
it('returns true if chartData is not empty', () => {
factory({ chartData: { 1: 0 } });
expect(wrapper.vm.hasChartData).toBe(true);
});
it('returns false if chartData is empty', () => {
factory({ chartData: {} });
expect(wrapper.vm.hasChartData).toBe(false);
});
});
describe('when chartData is an array', () => {
it('returns true if chartData is not empty', () => {
factory({ chartData: [[1, 0]] });
expect(wrapper.vm.hasChartData).toBe(true);
});
it('returns false if chartData is empty', () => {
factory({ chartData: [] });
expect(wrapper.vm.hasChartData).toBe(false);
});
});
});
});
describe('methods', () => {
describe('isSelectedMetric', () => {
it('returns true if the given key matches the selectedMetric prop', () => {
factory({ selectedMetric: 'time_to_merge' });
expect(wrapper.vm.isSelectedMetric('time_to_merge')).toBe(true);
});
});
});
});
......@@ -9705,6 +9705,12 @@ msgstr ""
msgid "Metric was successfully updated."
msgstr ""
msgid "MetricChart|Please select a metric"
msgstr ""
msgid "MetricChart|Selected"
msgstr ""
msgid "Metrics"
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