diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 9b8347934286a9b4219cf417df3f3ef3cc5e11ae..78d17a619fb739749c52c6b8abcb0c571a251df2 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -11,11 +11,14 @@ import { __ } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; +import { MASTER_CHART_HEIGHT } from '../constants'; import ContributorAreaChart from './contributor_area_chart.vue'; +import IndividualChart from './individual_chart.vue'; const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; export default { + MASTER_CHART_HEIGHT, i18n: { history: __('History'), refSelectorTranslations: { @@ -27,6 +30,7 @@ export default { GlButton, GlLoadingIcon, ContributorAreaChart, + IndividualChart, RefSelector, }, props: { @@ -52,9 +56,8 @@ export default { return { masterChart: null, individualCharts: [], + individualChartZoom: {}, svgs: {}, - masterChartHeight: 264, - individualChartHeight: 216, selectedBranch: this.branch, }; }, @@ -195,23 +198,13 @@ export default { }); }) .catch(() => {}); - this.masterChart.on('datazoom', debounce(this.setIndividualChartsZoom, 200)); - }, - onIndividualChartCreated(chart) { - this.individualCharts.push(chart); - }, - setIndividualChartsZoom(options) { - this.charts.forEach((chart) => - chart.setOption( - { - dataZoom: { - start: options.start, - end: options.end, - show: false, - }, - }, - { lazyUpdate: true }, - ), + + this.masterChart.on( + 'datazoom', + debounce(() => { + const [{ startValue, endValue }] = this.masterChart.getOption().dataZoom; + this.individualChartZoom = { startValue, endValue }; + }, 200), ); }, visitBranch(selected) { @@ -230,7 +223,7 @@ export default { </div> <template v-else-if="showChart"> - <div class="gl-border-b gl-border-gray-100 gl-mb-6 gl-bg-gray-10 gl-p-5"> + <div class="gl-border-b gl-border-gray-100 gl-mb-6 gl-bg-gray-10 gl-py-5"> <div class="gl-display-flex"> <div class="gl-mr-3"> <ref-selector @@ -246,35 +239,25 @@ export default { </gl-button> </div> </div> - <div data-testid="contributors-charts"> - <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> - <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> - <contributor-area-chart - class="gl-mb-5" - :data="masterChartData" - :option="masterChartOptions" - :height="masterChartHeight" - @created="onMasterChartCreated" - /> - <div class="row"> - <div - v-for="(contributor, index) in individualChartsData" - :key="index" - class="col-lg-6 col-12 gl-my-5" - > - <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> - <p class="gl-mb-3"> - {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) - </p> - <contributor-area-chart - :data="contributor.dates" - :option="individualChartOptions" - :height="individualChartHeight" - @created="onIndividualChartCreated" - /> - </div> - </div> + <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <contributor-area-chart + class="gl-mb-5" + :data="masterChartData" + :option="masterChartOptions" + :height="$options.MASTER_CHART_HEIGHT" + @created="onMasterChartCreated" + /> + + <div class="row"> + <individual-chart + v-for="(contributor, index) in individualChartsData" + :key="index" + :contributor="contributor" + :chart-options="individualChartOptions" + :zoom="individualChartZoom" + /> </div> </template> </div> diff --git a/app/assets/javascripts/contributors/components/individual_chart.vue b/app/assets/javascripts/contributors/components/individual_chart.vue new file mode 100644 index 0000000000000000000000000000000000000000..1d23273fd0295bbd6a035935852824c2746c6caf --- /dev/null +++ b/app/assets/javascripts/contributors/components/individual_chart.vue @@ -0,0 +1,86 @@ +<script> +import { isNumber } from 'lodash'; +import { isInTimePeriod } from '~/lib/utils/datetime/date_calculation_utility'; +import { INDIVIDUAL_CHART_HEIGHT } from '../constants'; +import ContributorAreaChart from './contributor_area_chart.vue'; + +export default { + INDIVIDUAL_CHART_HEIGHT, + components: { + ContributorAreaChart, + }, + props: { + contributor: { + type: Object, + required: true, + }, + chartOptions: { + type: Object, + required: true, + }, + zoom: { + type: Object, + required: true, + }, + }, + data() { + return { + chart: null, + }; + }, + computed: { + hasZoom() { + const { startValue, endValue } = this.zoom; + return isNumber(startValue) && isNumber(endValue); + }, + commitCount() { + if (!this.hasZoom) return this.contributor.commits; + + const start = new Date(this.zoom.startValue); + const end = new Date(this.zoom.endValue); + + return this.contributor.dates[0].data + .filter(([date, count]) => count > 0 && isInTimePeriod(new Date(date), start, end)) + .map(([, count]) => count) + .reduce((acc, count) => acc + count, 0); + }, + }, + watch: { + chart() { + this.syncChartZoom(); + }, + zoom() { + this.syncChartZoom(); + }, + }, + methods: { + onChartCreated(chart) { + this.chart = chart; + }, + syncChartZoom() { + if (!this.hasZoom || !this.chart) return; + + const { startValue, endValue } = this.zoom; + this.chart.setOption( + { dataZoom: { startValue, endValue, show: false } }, + { lazyUpdate: true }, + ); + }, + }, +}; +</script> + +<template> + <div class="col-lg-6 col-12 gl-my-5"> + <h4 class="gl-mb-2 gl-mt-0" data-testid="chart-header">{{ contributor.name }}</h4> + <p class="gl-mb-3" data-testid="commit-count"> + {{ n__('%d commit', '%d commits', commitCount) }} ({{ contributor.email }}) + </p> + <contributor-area-chart + :data="contributor.dates" + :option="chartOptions" + :height="$options.INDIVIDUAL_CHART_HEIGHT" + @created="onChartCreated" + /> + </div> +</template> diff --git a/app/assets/javascripts/contributors/constants.js b/app/assets/javascripts/contributors/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..1c641c83e970b4e219652a631d0414fbb73e8bc9 --- /dev/null +++ b/app/assets/javascripts/contributors/constants.js @@ -0,0 +1,2 @@ +export const MASTER_CHART_HEIGHT = 264; +export const INDIVIDUAL_CHART_HEIGHT = 216; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 9bb2884e065f2a2c2844549931bda8aa7152f548..73cb64519aac0cd9a773abbd6f299d31fa02107f 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -667,6 +667,17 @@ export const isInFuture = (date) => */ export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0; +/** + * Checks whether date falls in the `start -> end` time period. + * + * @param {Date} date + * @param {Date} start + * @param {Date} end + * @return {Boolean} Returns true if date falls in the time period, otherwise false + */ +export const isInTimePeriod = (date, start, end) => + differenceInMilliseconds(start, date) >= 0 && differenceInMilliseconds(date, end) >= 0; + /** * Removes the time component of the date. * diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap deleted file mode 100644 index 50a4a21ef1f8414f007567b0d42370e75f45bd29..0000000000000000000000000000000000000000 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ /dev/null @@ -1,120 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Contributors charts should render charts and a RefSelector when loading completed and there is chart data 1`] = ` -<div> - <div - class="gl-bg-gray-10 gl-border-b gl-border-gray-100 gl-mb-6 gl-p-5" - > - <div - class="gl-display-flex" - > - <div - class="gl-mr-3" - > - <refselector-stub - enabledreftypes="REF_TYPE_BRANCHES,REF_TYPE_TAGS" - name="" - projectid="23" - state="true" - translations="[object Object]" - value="main" - /> - </div> - <a - class="btn btn-default btn-md gl-button" - data-testid="history-button" - href="some/path" - > - <span - class="gl-button-text" - > - History - </span> - </a> - </div> - </div> - <div - data-testid="contributors-charts" - > - <h4 - class="gl-mb-2 gl-mt-5" - > - Commits to main - </h4> - <span> - Excluding merge commits. Limited to 6,000 commits. - </span> - <glareachart-stub - class="gl-mb-5" - data="[object Object]" - format-tooltip-text="function () { [native code] }" - height="264" - option="[object Object]" - responsive="" - width="auto" - > - <div - data-testid="tooltip-title" - /> - <div - class="gl-display-flex gl-gap-6 gl-justify-content-space-between" - > - <span - data-testid="tooltip-label" - > - Number of commits - </span> - <span - data-testid="tooltip-value" - > - [] - </span> - </div> - </glareachart-stub> - <div - class="row" - > - <div - class="col-12 col-lg-6 gl-my-5" - > - <h4 - class="gl-mb-2 gl-mt-0" - > - John - </h4> - <p - class="gl-mb-3" - > - 2 commits (jawnnypoo@gmail.com) - </p> - <glareachart-stub - data="[object Object]" - format-tooltip-text="function () { [native code] }" - height="216" - option="[object Object]" - responsive="" - width="auto" - > - <div - data-testid="tooltip-title" - /> - <div - class="gl-display-flex gl-gap-6 gl-justify-content-space-between" - > - <span - data-testid="tooltip-label" - > - Commits - </span> - <span - data-testid="tooltip-value" - > - [] - </span> - </div> - </glareachart-stub> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index 6235d2610a94d1c887594496f2a28ffa38ab19da..ac06111155f97f8e887b3a897c87862b7115b736 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -1,14 +1,17 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ContributorsCharts from '~/contributors/components/contributors.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Contributors from '~/contributors/components/contributors.vue'; import { createStore } from '~/contributors/stores'; +import { MASTER_CHART_HEIGHT } from '~/contributors/constants'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import { SET_CHART_DATA, SET_LOADING_STATE } from '~/contributors/stores/mutation_types'; +import ContributorAreaChart from '~/contributors/components/contributor_area_chart.vue'; +import IndividualChart from '~/contributors/components/individual_chart.vue'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -28,36 +31,32 @@ const chartData = [ const projectId = '23'; const commitsPath = 'some/path'; -function factory() { +const createWrapper = () => { mock = new MockAdapter(axios); jest.spyOn(axios, 'get'); mock.onGet().reply(HTTP_STATUS_OK, chartData); store = createStore(); - wrapper = mountExtended(ContributorsCharts, { + wrapper = shallowMountExtended(Contributors, { propsData: { endpoint, branch, projectId, commitsPath, }, - stubs: { - GlLoadingIcon: true, - GlAreaChart: true, - RefSelector: true, - }, store, }); -} +}; const findLoadingIcon = () => wrapper.findByTestId('loading-app-icon'); const findRefSelector = () => wrapper.findComponent(RefSelector); const findHistoryButton = () => wrapper.findByTestId('history-button'); -const findContributorsCharts = () => wrapper.findByTestId('contributors-charts'); +const findMasterChart = () => wrapper.findComponent(ContributorAreaChart); +const findIndividualCharts = () => wrapper.findAllComponents(IndividualChart); -describe('Contributors charts', () => { +describe('Contributors', () => { beforeEach(() => { - factory(); + createWrapper(); }); afterEach(() => { @@ -74,43 +73,95 @@ describe('Contributors charts', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('should render charts and a RefSelector when loading completed and there is chart data', async () => { - store.commit(SET_LOADING_STATE, false); - store.commit(SET_CHART_DATA, chartData); - await nextTick(); + describe('loading complete', () => { + beforeEach(() => { + store.commit(SET_LOADING_STATE, false); + store.commit(SET_CHART_DATA, chartData); + return nextTick(); + }); - expect(findLoadingIcon().exists()).toBe(false); - expect(findRefSelector().exists()).toBe(true); - expect(findRefSelector().props()).toMatchObject({ - enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], - value: branch, - projectId, - translations: { dropdownHeader: 'Switch branch/tag' }, - useSymbolicRefNames: false, - state: true, - name: '', + it('does not display loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); }); - expect(findContributorsCharts().exists()).toBe(true); - expect(wrapper.element).toMatchSnapshot(); - }); - it('should have a history button with a set href attribute', async () => { - store.commit(SET_LOADING_STATE, false); - store.commit(SET_CHART_DATA, chartData); - await nextTick(); + it('renders the RefSelector', () => { + expect(findRefSelector().props()).toMatchObject({ + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: branch, + projectId, + translations: { dropdownHeader: 'Switch branch/tag' }, + useSymbolicRefNames: false, + state: true, + name: '', + }); + }); - const historyButton = findHistoryButton(); - expect(historyButton.exists()).toBe(true); - expect(historyButton.attributes('href')).toBe(commitsPath); - }); + it('should have a history button with a set href attribute', () => { + const historyButton = findHistoryButton(); + expect(historyButton.exists()).toBe(true); + expect(historyButton.attributes('href')).toBe(commitsPath); + }); - it('visits a URL when clicking on a branch/tag', async () => { - store.commit(SET_LOADING_STATE, false); - store.commit(SET_CHART_DATA, chartData); - await nextTick(); + it('visits a URL when clicking on a branch/tag', () => { + findRefSelector().vm.$emit('input', branch); - findRefSelector().vm.$emit('input', branch); + expect(visitUrl).toHaveBeenCalledWith(`${endpoint}/${branch}`); + }); - expect(visitUrl).toHaveBeenCalledWith(`${endpoint}/${branch}`); + it('renders the master chart', () => { + expect(findMasterChart().props()).toMatchObject({ + data: [{ name: 'Commits', data: expect.any(Array) }], + height: MASTER_CHART_HEIGHT, + option: { + xAxis: { + data: expect.any(Array), + splitNumber: 24, + min: '2019-03-03', + max: '2019-05-05', + }, + yAxis: { name: 'Number of commits' }, + grid: { bottom: 64, left: 64, right: 20, top: 20 }, + }, + }); + }); + + it('renders the individual charts', () => { + expect(findIndividualCharts().length).toBe(1); + expect(findIndividualCharts().at(0).props()).toMatchObject({ + contributor: { + name: 'John', + email: 'jawnnypoo@gmail.com', + commits: 2, + dates: [expect.any(Object)], + }, + chartOptions: { + xAxis: { + data: expect.any(Array), + splitNumber: 18, + min: '2019-03-03', + max: '2019-05-05', + }, + yAxis: { name: 'Commits', max: 1 }, + grid: { bottom: 27, left: 64, right: 20, top: 8 }, + }, + zoom: {}, + }); + }); + + describe('master chart was zoomed', () => { + const zoom = { startValue: 100, endValue: 200 }; + + beforeEach(() => { + findMasterChart().vm.$emit('created', { + setOption: jest.fn(), + on: jest.fn().mockImplementation((_, callback) => callback()), + getOption: jest.fn().mockImplementation(() => ({ dataZoom: [zoom] })), + }); + }); + + it('sets the individual chart zoom', () => { + expect(findIndividualCharts().at(0).props('zoom')).toEqual(zoom); + }); + }); }); }); diff --git a/spec/frontend/contributors/component/individual_chart_spec.js b/spec/frontend/contributors/component/individual_chart_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..571e65c812a24ad3f45daf348f5a643a02194c3a --- /dev/null +++ b/spec/frontend/contributors/component/individual_chart_spec.js @@ -0,0 +1,114 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { INDIVIDUAL_CHART_HEIGHT } from '~/contributors/constants'; +import IndividualChart from '~/contributors/components/individual_chart.vue'; +import ContributorAreaChart from '~/contributors/components/contributor_area_chart.vue'; + +describe('Individual chart', () => { + let wrapper; + let mockChart; + + const commitData = [ + ['2010-04-15', 5], + ['2010-05-15', 5], + ['2010-06-15', 5], + ['2010-07-15', 5], + ['2010-08-15', 5], + ]; + + const defaultContributor = { + name: 'Razputin Aquato', + email: 'razputin.aquato@psychonauts.com', + commits: 25, + dates: [{ name: 'Commits', data: commitData }], + }; + + const findHeader = () => wrapper.findByTestId('chart-header'); + const findCommitCount = () => wrapper.findByTestId('commit-count'); + const findContributorAreaChart = () => wrapper.findComponent(ContributorAreaChart); + + const createWrapper = (props = {}) => { + mockChart = { setOption: jest.fn() }; + + wrapper = shallowMountExtended(IndividualChart, { + propsData: { + contributor: defaultContributor, + chartOptions: {}, + zoom: {}, + ...props, + }, + }); + + findContributorAreaChart().vm.$emit('created', mockChart); + }; + + describe('when not zoomed', () => { + beforeEach(() => { + createWrapper(); + }); + + it('shows the contributor name as the chart header', () => { + expect(findHeader().text()).toBe(defaultContributor.name); + }); + + it('shows the total commit count', () => { + const { commits, email } = defaultContributor; + expect(findCommitCount().text()).toBe(`${commits} commits (${email})`); + }); + + it('renders the area chart with the given options', () => { + expect(findContributorAreaChart().props()).toMatchObject({ + data: defaultContributor.dates, + option: {}, + height: INDIVIDUAL_CHART_HEIGHT, + }); + }); + }); + + describe('when zoomed', () => { + const zoom = { + startValue: new Date('2010-05-01').getTime(), + endValue: new Date('2010-08-01').getTime(), + }; + + beforeEach(() => { + createWrapper({ zoom }); + }); + + it('shows only the commits for the zoomed time period', () => { + const { email } = defaultContributor; + expect(findCommitCount().text()).toBe(`15 commits (${email})`); + }); + + it('sets the dataZoom chart option', () => { + const { startValue, endValue } = zoom; + expect(mockChart.setOption).toHaveBeenCalledWith( + { dataZoom: { startValue, endValue, show: false } }, + { lazyUpdate: true }, + ); + }); + + describe('when zoom is changed', () => { + const newZoom = { + startValue: new Date('2010-04-01').getTime(), + endValue: new Date('2010-05-01').getTime(), + }; + + beforeEach(() => { + wrapper.setProps({ zoom: newZoom }); + }); + + it('shows only the commits for the zoomed time period', () => { + const { email } = defaultContributor; + expect(findCommitCount().text()).toBe(`5 commits (${email})`); + }); + + it('sets the dataZoom chart option', () => { + const { startValue, endValue } = newZoom; + expect(mockChart.setOption).toHaveBeenCalledWith( + { dataZoom: { startValue, endValue, show: false } }, + { lazyUpdate: true }, + ); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js index f9e3c314d025668c8d5e6d7c992e006e5e26a137..f45d173aa98447a953b2e0b255c1103b7e049a50 100644 --- a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js @@ -5,6 +5,7 @@ import { nSecondsAfter, nSecondsBefore, isToday, + isInTimePeriod, } from '~/lib/utils/datetime/date_calculation_utility'; import { useFakeDate } from 'helpers/fake_date'; @@ -93,3 +94,21 @@ describe('getCurrentUtcDate', () => { expect(getCurrentUtcDate()).toEqual(new Date('2022-12-05T00:00:00.000Z')); }); }); + +describe('isInTimePeriod', () => { + const date = '2022-03-22T01:23:45.000Z'; + + it.each` + start | end | expected + ${'2022-03-21'} | ${'2022-03-23'} | ${true} + ${'2022-03-20'} | ${'2022-03-21'} | ${false} + ${'2022-03-23'} | ${'2022-03-24'} | ${false} + ${date} | ${'2022-03-24'} | ${true} + ${'2022-03-21'} | ${date} | ${true} + ${'2022-03-22T00:23:45.000Z'} | ${'2022-03-22T02:23:45.000Z'} | ${true} + ${'2022-03-22T00:23:45.000Z'} | ${'2022-03-22T00:25:45.000Z'} | ${false} + ${'2022-03-22T02:23:45.000Z'} | ${'2022-03-22T03:25:45.000Z'} | ${false} + `('returns $expected for range: $start -> $end', ({ start, end, expected }) => { + expect(isInTimePeriod(new Date(date), new Date(start), new Date(end))).toBe(expected); + }); +});