diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index a30b18348ec7ffcb3a16f74d24fe8e56846310f3..815f1a0e15187e30056de19adb487f2526ca936b 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -35,12 +35,22 @@ export default { return sprintf(s__('CiCdAnalytics|Date range: %{range}'), { range: this.chart.range }); }, }, + methods: { + onInput(selectedChart) { + this.selectedChart = selectedChart; + this.$emit('select-chart', selectedChart); + }, + }, }; </script> <template> <div> <div class="gl-display-flex gl-flex-wrap gl-gap-5"> - <segmented-control-button-group v-model="selectedChart" :options="chartRanges" /> + <segmented-control-button-group + :options="chartRanges" + :value="selectedChart" + @input="onInput" + /> <slot name="extend-button-group"></slot> </div> <ci-cd-analytics-area-chart diff --git a/ee/app/assets/javascripts/dora/components/deployment_frequency_charts.vue b/ee/app/assets/javascripts/dora/components/deployment_frequency_charts.vue index 487cbc4870c971541b74cb103ae88caecba5b64d..50ba31078d535920de8f73272712123385581095 100644 --- a/ee/app/assets/javascripts/dora/components/deployment_frequency_charts.vue +++ b/ee/app/assets/javascripts/dora/components/deployment_frequency_charts.vue @@ -5,14 +5,13 @@ import { DATA_VIZ_BLUE_500 } from '@gitlab/ui/dist/tokens/js/tokens'; import * as DoraApi from 'ee/api/dora_api'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; import { toYmd } from '~/analytics/shared/utils'; -import { linearRegression } from 'ee/analytics/shared/utils'; import { createAlert } from '~/alert'; import { __, s__, sprintf } from '~/locale'; import { spriteIcon } from '~/lib/utils/common_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { nDaysAfter } from '~/lib/utils/datetime_utility'; import { SUMMARY_METRICS_REQUEST } from '~/analytics/cycle_analytics/constants'; import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; +import { DEFAULT_SELECTED_CHART } from '~/vue_shared/components/ci_cd_analytics/constants'; import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import DoraChartHeader from './dora_chart_header.vue'; @@ -29,7 +28,12 @@ import { LAST_180_DAYS, CHART_TITLE, } from './static_data/deployment_frequency'; -import { apiDataToChartSeries, seriesToAverageSeries } from './util'; +import { + apiDataToChartSeries, + seriesToAverageSeries, + calculateForecast, + forecastDataToSeries, +} from './util'; const VISIBLE_METRICS = ['deploys', 'deployment-frequency', 'deployment_frequency']; const filterFn = (data) => @@ -102,6 +106,7 @@ export default { [LAST_90_DAYS]: {}, [LAST_180_DAYS]: {}, }, + selectedChartIndex: DEFAULT_SELECTED_CHART, }; }, computed: { @@ -166,19 +171,20 @@ export default { }; if (apiData?.length > 0) { - const { data: forecastedData } = apiDataToChartSeries( - linearRegression(apiData, this.$options.forecastDays[id]), + const forecastDataSeries = forecastDataToSeries({ + forecastData: calculateForecast({ + rawApiData: apiData, + forecastHorizon: this.$options.forecastDays[id], + }), + forecastHorizon: this.$options.forecastDays[id], + forecastSeriesLabel: this.$options.i18n.forecast, + dataSeries: seriesData[0].data, endDate, - nDaysAfter(endDate, this.$options.forecastDays[id]), - this.$options.i18n.forecast, - )[0]; - - // Add the last point from the data series so the chart visually joins together - const lastDataPoint = seriesData[0].data.slice(-1); + }); this.forecastChartData[id] = { ...this.forecastChartData[id], - data: [...lastDataPoint, ...forecastedData], + data: forecastDataSeries, }; } }), @@ -200,10 +206,13 @@ export default { } }, methods: { - getMetricsRequestParams(selectedChart) { + async onSelectChart(selectedChartIndex) { + this.selectedChartIndex = selectedChartIndex; + }, + getMetricsRequestParams(selectedChartIndex) { const { requestParams: { start_date }, - } = allChartDefinitions[selectedChart]; + } = allChartDefinitions[selectedChartIndex]; return { created_after: toYmd(start_date), @@ -251,7 +260,11 @@ export default { :chart-description-text="$options.chartDescriptionText" :chart-documentation-href="$options.chartDocumentationHref" /> - <ci-cd-analytics-charts :charts="charts" :chart-options="$options.areaChartOptions"> + <ci-cd-analytics-charts + :charts="charts" + :chart-options="$options.areaChartOptions" + @select-chart="onSelectChart" + > <template v-if="glFeatures.doraChartsForecast" #extend-button-group> <div class="gl-display-flex gl-align-items-center"> <gl-toggle diff --git a/ee/app/assets/javascripts/dora/components/util.js b/ee/app/assets/javascripts/dora/components/util.js index bba723271adacbc74d2ffca565b91989853b2b3b..07ef730830a33c7db963da3851d1b6ec4afe6a14 100644 --- a/ee/app/assets/javascripts/dora/components/util.js +++ b/ee/app/assets/javascripts/dora/components/util.js @@ -2,11 +2,13 @@ import dateFormat from '~/lib/dateformat'; import { getDatesInRange, nDaysBefore, + nDaysAfter, getStartOfDay, humanizeTimeInterval, SECONDS_IN_DAY, } from '~/lib/utils/datetime_utility'; import { median } from '~/lib/utils/number_utils'; +import { linearRegression } from 'ee/analytics/shared/utils'; /** * Converts the raw data fetched from the @@ -170,3 +172,56 @@ export const formatAsPercentageWithoutSymbol = (decimalValue = 0, precision = 1) export const formatAsPercentage = (decimalValue = 0, precision = 1) => { return `${formatAsPercentageWithoutSymbol(decimalValue, precision)}%`; }; + +/** + * @typedef {[Date, Integer]} RawChartDataItem + */ + +/** + * Converts the forecast data into series data using + * the `apiDataToChartSeries` method. The returned series + * will also include the final data point from the data series. + * + * @param {Object} options + * @param {Array} options.forecastData The forecasted data in JSON format + * @param {Integer} options.forecastHorizon The number of days to be forecasted + * @param {String} options.forecastSeriesLabel The name of the series + * @param {Date} options.endDate The last day (exclusive) of the graph's date range + * @param {Array} options.dataSeries The historical data in JSON format + * @returns {RawChartDataItem[]} + */ +export const forecastDataToSeries = ({ + forecastData, + forecastHorizon, + forecastSeriesLabel, + dataSeries, + endDate, +}) => { + const { data } = apiDataToChartSeries( + forecastData, + endDate, + nDaysAfter(endDate, forecastHorizon), + forecastSeriesLabel, + )[0]; + + // Add the last point from the data series so the chart visually joins together + return [...dataSeries.slice(-1), ...data]; +}; + +/** + * @typedef {Object} ForecastDataItem + * @property {Date} date - YYYY-MM-DD date for the datapoint + * @property {Number} value - Forecasted value + */ + +/** + * Returns a data forecast for the given time horizon, uses a simple linear regression + * + * @param {Object} options An object containing the context needed for the forecast request + * @param {String} options.forecastHorizon - Number of days to be returned in the forecast + * @param {Array} options.rawApiData - Historical data used for generating the linear regression + * @returns {ForecastDataItem[]} + */ +export const calculateForecast = ({ forecastHorizon, rawApiData }) => { + return linearRegression(rawApiData, forecastHorizon); +}; diff --git a/ee/spec/frontend/dora/components/deployment_frequency_charts_spec.js b/ee/spec/frontend/dora/components/deployment_frequency_charts_spec.js index 1c7fb1d62159845aa4599a597e3d864b6bb1dd66..a3a158054db6e28d7a4a17e772010c279520963e 100644 --- a/ee/spec/frontend/dora/components/deployment_frequency_charts_spec.js +++ b/ee/spec/frontend/dora/components/deployment_frequency_charts_spec.js @@ -81,8 +81,42 @@ describe('deployment_frequency_charts.vue', () => { .replyOnce(HTTP_STATUS_OK, data); }; + const setupDefaultMockTimePeriods = () => { + setUpMockDeploymentFrequencies({ + start_date: '2015-06-27T00:00:00+0000', + data: lastWeekData, + }); + setUpMockDeploymentFrequencies({ + start_date: '2015-06-04T00:00:00+0000', + data: lastMonthData, + }); + setUpMockDeploymentFrequencies({ + start_date: '2015-04-05T00:00:00+0000', + data: last90DaysData, + }); + setUpMockDeploymentFrequencies({ + start_date: '2015-01-05T00:00:00+0000', + data: last180DaysData, + }); + }; + const findValueStreamMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findCiCdAnalyticsCharts = () => wrapper.findComponent(CiCdAnalyticsCharts); + const findDataForecastToggle = () => wrapper.findByTestId('data-forecast-toggle'); + const findExperimentBadge = () => wrapper.findComponent(GlBadge); + const getChartData = () => findCiCdAnalyticsCharts().props().charts; + + const selectChartByIndex = async (id) => { + await findCiCdAnalyticsCharts().vm.$emit('select-chart', id); + await waitForPromises(); + }; + + async function toggleDataForecast(confirmationValue = true) { + confirmAction.mockResolvedValueOnce(confirmationValue); + + await findDataForecastToggle().vm.$emit('change', !findDataForecastToggle().props('value')); + await waitForPromises(); + } afterEach(() => { mock.restore(); @@ -92,22 +126,7 @@ describe('deployment_frequency_charts.vue', () => { beforeEach(async () => { mock = new MockAdapter(axios); - setUpMockDeploymentFrequencies({ - start_date: '2015-06-27T00:00:00+0000', - data: lastWeekData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-06-04T00:00:00+0000', - data: lastMonthData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-04-05T00:00:00+0000', - data: last90DaysData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-01-05T00:00:00+0000', - data: last180DaysData, - }); + setupDefaultMockTimePeriods(); createComponent(); @@ -286,37 +305,11 @@ describe('deployment_frequency_charts.vue', () => { }, }; - const findDataForecastToggle = () => wrapper.findByTestId('data-forecast-toggle'); - const findExperimentBadge = () => wrapper.findComponent(GlBadge); - const getChartData = () => findCiCdAnalyticsCharts().props().charts; - - async function toggleDataForecast(confirmationValue = true) { - confirmAction.mockResolvedValueOnce(confirmationValue); - - await findDataForecastToggle().vm.$emit('change', !findDataForecastToggle().props('value')); - await waitForPromises(); - } - beforeEach(async () => { mock = new MockAdapter(axios); window.gon = { features: { doraChartsForecast: true } }; - setUpMockDeploymentFrequencies({ - start_date: '2015-06-27T00:00:00+0000', - data: lastWeekData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-06-04T00:00:00+0000', - data: lastMonthData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-04-05T00:00:00+0000', - data: last90DaysData, - }); - setUpMockDeploymentFrequencies({ - start_date: '2015-01-05T00:00:00+0000', - data: last180DaysData, - }); + setupDefaultMockTimePeriods(); createComponent(mountOpts, mount); await axios.waitForAll(); @@ -368,6 +361,12 @@ describe('deployment_frequency_charts.vue', () => { expect(confirmAction).toHaveBeenCalledTimes(1); }); + it('no change when the confirmation is cancelled', async () => { + await toggleDataForecast(false); + + expect(findDataForecastToggle().props().value).toBe(false); + }); + it.each` timePeriod | chartDataIndex | daysForecasted | result ${'Last week'} | ${0} | ${4} | ${mockLastWeekForecastData} @@ -377,6 +376,7 @@ describe('deployment_frequency_charts.vue', () => { `( 'Calculates the forecasted data for $timePeriod', async ({ chartDataIndex, result, daysForecasted }) => { + await selectChartByIndex(chartDataIndex); await toggleDataForecast(); const currentTimePeriodChartData = getChartData()[chartDataIndex]; @@ -389,12 +389,6 @@ describe('deployment_frequency_charts.vue', () => { expect(forecastSeries.areaStyle).toEqual({ opacity: 0 }); }, ); - - it('no change when the confirmation is cancelled', async () => { - await toggleDataForecast(false); - - expect(findDataForecastToggle().props().value).toBe(false); - }); }); }); }); diff --git a/ee/spec/frontend/dora/components/helpers.js b/ee/spec/frontend/dora/components/helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..1031bd5a2bd1dcd2ab389b889cdd85dc21650635 --- /dev/null +++ b/ee/spec/frontend/dora/components/helpers.js @@ -0,0 +1,6 @@ +import dateFormat from '~/lib/dateformat'; + +export const formattedDate = (date) => dateFormat(date, 'mmm d', true); + +export const forecastDataToChartDate = (data, forecast) => + [...data.slice(-1), ...forecast].map(({ date, value }) => [formattedDate(date), value]); diff --git a/ee/spec/frontend/dora/components/util_spec.js b/ee/spec/frontend/dora/components/util_spec.js index 39c524b3aa7d364432e8ee0a5fa7454b66496579..e48b2574c962cb57690ab466d0d4443d9bd159c0 100644 --- a/ee/spec/frontend/dora/components/util_spec.js +++ b/ee/spec/frontend/dora/components/util_spec.js @@ -7,7 +7,11 @@ import { extractTimeSeriesTooltip, secondsToDays, formatAsPercentage, + forecastDataToSeries, + calculateForecast, } from 'ee/dora/components/util'; +import * as utils from 'ee/analytics/shared/utils'; +import { forecastDataToChartDate } from './helpers'; const NO_DATA_MESSAGE = 'No data available'; @@ -178,4 +182,62 @@ describe('ee/dora/components/util.js', () => { expect(formatAsPercentage('1.86', 0)).toBe('186%'); }); }); + + describe('forecastDataToSeries', () => { + let res; + + const mockTimePeriod = [ + { date: '2023-01-10', value: 7 }, + { date: '2023-01-11', value: 4 }, + { date: '2023-01-12', value: 3 }, + { date: '2023-01-13', value: 16 }, + ]; + const mockForecastPeriod = [ + { date: '2023-01-14', value: 47 }, + { date: '2023-01-15', value: 37 }, + { date: '2023-01-16', value: 106 }, + ]; + + const forecastResponse = forecastDataToChartDate(mockTimePeriod, mockForecastPeriod); + + beforeEach(() => { + res = forecastDataToSeries({ + forecastData: mockForecastPeriod, + forecastHorizon: 3, + forecastSeriesLabel: 'Forecast', + dataSeries: [ + ['Jan 10', 7], + ['Jan 11', 4], + ['Jan 12', 3], + ['Jan 13', 16], + ], + endDate: new Date('2023-01-14'), + }); + }); + + it('returns the data data series to be displayed in charts', () => { + expect(res).toEqual(forecastResponse); + }); + + it('includes the last data point from the data series', () => { + expect(res[0]).toEqual(['Jan 13', 16]); + }); + }); + + describe('calculateForecast', () => { + let linearRegressionSpy; + + const forecastHorizon = 3; + const rawApiData = []; + const defaultParams = { forecastHorizon, rawApiData }; + + beforeEach(() => { + linearRegressionSpy = jest.spyOn(utils, 'linearRegression'); + calculateForecast({ ...defaultParams, useHoltWintersForecast: false }); + }); + + it('will generate a linear regression request', () => { + expect(linearRegressionSpy).toHaveBeenCalledWith(rawApiData, forecastHorizon); + }); + }); });