diff --git a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue index b1cab2026751fb08c649f923670b56c366aaa051..61e0e7497d09099f600560bc0bcbb308c47baa99 100644 --- a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue +++ b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue @@ -1,5 +1,4 @@ <script> -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; import { createAlert } from '~/alert'; import { toYmd } from '~/analytics/shared/utils'; @@ -12,7 +11,7 @@ import ProjectFlowMetricsQuery from '../graphql/project_flow_metrics.query.graph import GroupDoraMetricsQuery from '../graphql/group_dora_metrics.query.graphql'; import ProjectDoraMetricsQuery from '../graphql/project_dora_metrics.query.graphql'; import { BUCKETING_INTERVAL_ALL, MERGE_REQUESTS_STATE_MERGED } from '../graphql/constants'; -import { DASHBOARD_LOADING_FAILURE, DASHBOARD_NO_DATA, CHART_LOADING_FAILURE } from '../constants'; +import { DASHBOARD_LOADING_FAILURE, CHART_LOADING_FAILURE } from '../constants'; import { fetchMetricsForTimePeriods, extractGraphqlVulnerabilitiesData, @@ -21,10 +20,10 @@ import { extractGraphqlMergeRequestsData, } from '../api'; import { - hasDoraMetricValues, - generateDoraTimePeriodComparisonTable, + generateSkeletonTableData, + generateMetricComparisons, generateSparklineCharts, - mergeSparklineCharts, + mergeTableData, generateDateRanges, generateChartTimePeriods, generateValueStreamDashboardStartDate, @@ -46,8 +45,6 @@ const extractQueryResponseFromNamespace = ({ result, resultKey }) => { export default { name: 'ComparisonChart', components: { - GlAlert, - GlSkeletonLoader, ComparisonTable, }, props: { @@ -72,25 +69,19 @@ export default { }, data() { return { - tableData: [], + tableData: {}, chartData: {}, - loadingTable: false, }; }, computed: { - hasData() { - return Boolean(this.allData.length); + skeletonData() { + return generateSkeletonTableData(this.excludeMetrics); }, - hasTableData() { - return Boolean(this.tableData.length); - }, - hasChartData() { - return Boolean(Object.keys(this.chartData).length); - }, - allData() { - return this.hasChartData - ? mergeSparklineCharts(this.tableData, this.chartData) - : this.tableData; + combinedData() { + let data = this.skeletonData; + data = mergeTableData(data, this.tableData); + data = mergeTableData(data, this.chartData); + return data; }, namespaceRequestPath() { return this.isProject ? this.requestPath : joinPaths('groups', this.requestPath); @@ -103,15 +94,8 @@ export default { }, }, async mounted() { - this.loadingTable = true; - try { - await this.fetchTableMetrics(); - if (this.hasTableData) { - await this.fetchSparklineMetrics(); - } - } finally { - this.loadingTable = false; - } + await this.fetchTableMetrics(); + await this.fetchSparklineMetrics(); }, methods: { async fetchFlowMetricsQuery({ isProject, ...variables }) { @@ -213,12 +197,7 @@ export default { this.fetchGraphqlData, ); - this.tableData = hasDoraMetricValues(timePeriods) - ? generateDoraTimePeriodComparisonTable({ - timePeriods, - excludeMetrics: this.excludeMetrics, - }) - : []; + this.tableData = generateMetricComparisons(timePeriods); } catch (error) { createAlert({ message: DASHBOARD_LOADING_FAILURE, error, captureError: true }); } @@ -230,30 +209,20 @@ export default { this.fetchGraphqlData, ); - this.chartData = hasDoraMetricValues(chartData) ? generateSparklineCharts(chartData) : {}; + this.chartData = generateSparklineCharts(chartData); } catch (error) { createAlert({ message: CHART_LOADING_FAILURE, error, captureError: true }); } }, }, - i18n: { - noData: DASHBOARD_NO_DATA, - }, now, }; </script> <template> - <div> - <gl-skeleton-loader v-if="loadingTable" /> - <gl-alert v-else-if="!hasData" variant="info" :dismissible="false">{{ - $options.i18n.noData - }}</gl-alert> - <comparison-table - v-else - :table-data="allData" - :request-path="namespaceRequestPath" - :is-project="isProject" - :now="$options.now" - /> - </div> + <comparison-table + :table-data="combinedData" + :request-path="namespaceRequestPath" + :is-project="isProject" + :now="$options.now" + /> </template> diff --git a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_table.vue b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_table.vue index 1741fe7a982dd46ca0d29af968423d1246e7c891..cc99f09f2cb2a6e2d7a53b8dd0f6c73b7728f476 100644 --- a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_table.vue +++ b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_table.vue @@ -66,26 +66,31 @@ export default { </template> <template #cell()="{ value: { value, change, valueLimitMessage }, item: { invertTrendColor } }"> - {{ value }} - <gl-icon - v-if="valueLimitMessage" - v-gl-tooltip.hover - class="gl-text-blue-600" - name="information-o" - :title="valueLimitMessage" - data-testid="metric_max_value_info_icon" - /> - <trend-indicator - v-else-if="change" - :change="change" - :invert-color="invertTrendColor" - data-testid="metric_trend_indicator" - /> + <span v-if="value === undefined" data-testid="metric-comparison-skeleton"> + <gl-skeleton-loader :lines="1" :width="100" /> + </span> + <template v-else> + {{ value }} + <gl-icon + v-if="valueLimitMessage" + v-gl-tooltip.hover + class="gl-text-blue-600" + name="information-o" + :title="valueLimitMessage" + data-testid="metric-max-value-info-icon" + /> + <trend-indicator + v-else-if="change" + :change="change" + :invert-color="invertTrendColor" + data-testid="metric-trend-indicator" + /> + </template> </template> <template #cell(metric)="{ value: { identifier } }"> <metric-table-cell - :data-testid="`${identifier}_metric_cell`" + :data-testid="`${identifier}-metric-cell`" :identifier="identifier" :request-path="requestPath" :is-project="isProject" @@ -101,9 +106,9 @@ export default { :data="data" :smooth="0.2" :gradient="chartGradient(invertTrendColor)" - data-testid="metric_chart" + data-testid="metric-chart" /> - <div v-else class="gl-py-4" data-testid="metric_chart_skeleton"> + <div v-else class="gl-py-4" data-testid="metric-chart-skeleton"> <gl-skeleton-loader :lines="1" :width="100" /> </div> </template> diff --git a/ee/app/assets/javascripts/analytics/dashboards/constants.js b/ee/app/assets/javascripts/analytics/dashboards/constants.js index 0bb209f42a0cb1ced67bc25a649c9dccc4586a86..017a3fee307ea5e50dc95583ab1e7fd9ab1e7068 100644 --- a/ee/app/assets/javascripts/analytics/dashboards/constants.js +++ b/ee/app/assets/javascripts/analytics/dashboards/constants.js @@ -103,7 +103,6 @@ export const DASHBOARD_DESCRIPTION_GROUP = s__('DORA4Metrics|Metrics comparison export const DASHBOARD_DESCRIPTION_PROJECT = s__( 'DORA4Metrics|Metrics comparison for %{name} project', ); -export const DASHBOARD_NO_DATA = __('No data available'); export const DASHBOARD_LOADING_FAILURE = __('Failed to load'); export const DASHBOARD_NAMESPACE_LOAD_ERROR = s__( 'DORA4Metrics|Failed to load comparison chart for Namespace: %{fullPath}', diff --git a/ee/app/assets/javascripts/analytics/dashboards/utils.js b/ee/app/assets/javascripts/analytics/dashboards/utils.js index 07a53bfc742f4f92fd2fe4e949db3989c770a00c..6ec374fa2f1e35128b782a2c20bdaabee93d18dc 100644 --- a/ee/app/assets/javascripts/analytics/dashboards/utils.js +++ b/ee/app/assets/javascripts/analytics/dashboards/utils.js @@ -1,5 +1,4 @@ import { s__, __ } from '~/locale'; -import { isNumeric } from '~/lib/utils/number_utils'; import { formatDate, getStartOfDay, @@ -88,51 +87,49 @@ export const percentChange = ({ current, previous }) => previous > 0 && current > 0 ? (current - previous) / previous : 0; /** - * Takes an array of timePeriod objects containing DORA metrics, and returns - * true if any of the timePeriods contain metric values > 0. + * Creates the table rows filled with blank data for the comparison table. Once the data + * has loaded, it can be filled into the returned skeleton using `mergeTableData`. * - * @param {Array} timePeriods - array of objects containing DORA metric values - * @returns {Boolean} true if there is any metric data, otherwise false. + * @param {Array} excludeMetrics - Array of DORA metric identifiers to remove from the table + * @returns {Array} array of data-less table rows */ -export const hasDoraMetricValues = (timePeriods) => - timePeriods.some((timePeriod) => { - // timePeriod may contain more attributes than just the DORA metrics, - // so filter out non-metrics before making a list of the raw values - const metricValues = Object.entries(timePeriod) - .filter(([k]) => Object.keys(TABLE_METRICS).includes(k)) - .map(([, v]) => v.value); +export const generateSkeletonTableData = (excludeMetrics = []) => + Object.entries(TABLE_METRICS) + .filter(([identifier]) => !excludeMetrics.includes(identifier)) + .map(([identifier, { label, invertTrendColor, valueLimit }]) => ({ + invertTrendColor, + metric: { identifier, value: label }, + valueLimit, + })); - return metricValues.some((value) => isNumeric(value) && Number(value) > 0); +/** + * Fills the provided table rows with the matching metric data, returning a copy + * of the original table data. + * + * @param {Array} tableData - Table rows created by `generateSkeletonTableData` + * @param {Object} newData - New data to enter into the table rows. Object keys match the metric ID + * @returns {Array} A copy of `tableData` with the new data merged into each row + */ +export const mergeTableData = (tableData, newData) => + tableData.map((row) => { + const data = newData[row.metric.identifier]; + return data ? { ...row, ...data } : row; }); /** * Takes N time periods for a metric and generates the row for the comparison table. * * @param {String} identifier - ID of the metric to create a table row for. - * @param {String} label - User friendly name of the metric to show in the table row. * @param {String} units - The type of units used for this metric (ex. days, /day, count) - * @param {Boolean} invertTrendColor - Inverts the color indicator used for metric trends. * @param {Array} timePeriods - Array of the metrics for different time periods * @param {Object} valueLimit - Object representing the maximum value of a metric, mask that replaces the value if the limit is reached and a description to be used in a tooltip. * @returns {Object} The metric data formatted for the comparison table. */ -const buildMetricComparisonTableRow = ({ - identifier, - label, - units, - invertTrendColor, - timePeriods, - valueLimit, -}) => { - const data = { - invertTrendColor, - metric: { identifier, value: label }, - valueLimit, - }; - timePeriods.forEach((timePeriod, index) => { +const buildMetricComparisonTableRow = ({ identifier, units, timePeriods, valueLimit }) => + timePeriods.reduce((acc, timePeriod, index) => { // The last timePeriod is not rendered, we just use it // to determine the % change for the 2nd last timePeriod - if (index === timePeriods.length - 1) return; + if (index === timePeriods.length - 1) return acc; const current = timePeriod[identifier]; const previous = timePeriods[index + 1][identifier]; @@ -149,36 +146,35 @@ const buildMetricComparisonTableRow = ({ const formattedMetric = hasCurrentValue ? formatMetric(current.value, units) : '-'; const value = valueLimitMessage ? valueLimit?.mask : formattedMetric; - data[timePeriod.key] = { - value, - change, - valueLimitMessage, - }; - }); - return data; -}; + return Object.assign(acc, { + [timePeriod.key]: { + value, + change, + valueLimitMessage, + }, + }); + }, {}); /** - * Takes N time periods of DORA metrics and generates the data rows - * for the comparison table. + * Takes N time periods of DORA metrics and sorts the data into an + * object of metric comparisons, per metric. * * @param {Array} timePeriods - Array of the DORA metrics for different time periods - * @param {Array} excludeMetrics - Array of DORA metric identifiers to remove from the table - * @returns {Array} array comparing each DORA metric between the different time periods + * @returns {Object} object containing a comparisons of values for each metric */ -export const generateDoraTimePeriodComparisonTable = ({ timePeriods, excludeMetrics = [] }) => - Object.entries(TABLE_METRICS) - .filter(([identifier]) => !excludeMetrics.includes(identifier)) - .map(([identifier, { label, units, invertTrendColor, valueLimit }]) => - buildMetricComparisonTableRow({ - identifier, - label, - units, - invertTrendColor, - timePeriods, - valueLimit, +export const generateMetricComparisons = (timePeriods) => + Object.entries(TABLE_METRICS).reduce( + (acc, [identifier, { units, valueLimit }]) => + Object.assign(acc, { + [identifier]: buildMetricComparisonTableRow({ + identifier, + units, + timePeriods, + valueLimit, + }), }), - ); + {}, + ); /** * @param {Number|'-'|null|undefined} value @@ -206,30 +202,18 @@ export const generateSparklineCharts = (timePeriods) => (acc, [identifier, { units }]) => Object.assign(acc, { [identifier]: { - tooltipLabel: CHART_TOOLTIP_UNITS[units], - data: timePeriods.map((timePeriod) => [ - `${formatDate(timePeriod.start, 'mmm d')} - ${formatDate(timePeriod.end, 'mmm d')}`, - sanitizeSparklineData(timePeriod[identifier]?.value), - ]), + chart: { + tooltipLabel: CHART_TOOLTIP_UNITS[units], + data: timePeriods.map((timePeriod) => [ + `${formatDate(timePeriod.start, 'mmm d')} - ${formatDate(timePeriod.end, 'mmm d')}`, + sanitizeSparklineData(timePeriod[identifier]?.value), + ]), + }, }, }), {}, ); -/** - * Merges the results of `generateDoraTimePeriodComparisonTable` and `generateSparklineCharts` - * into a new array for the comparison table. - * - * @param {Array} tableData - Table rows created by `generateDoraTimePeriodComparisonTable` - * @param {Object} chartData - Charts object created by `generateSparklineCharts` - * @returns {Array} A copy of tableData with `chart` added in each row - */ -export const mergeSparklineCharts = (tableData, chartData) => - tableData.map((row) => { - const chart = chartData[row.metric.identifier]; - return chart ? { ...row, chart } : row; - }); - /** * Generate the dashboard time periods * this month - last month - 2 month ago - 3 month ago diff --git a/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js b/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js index 2ea77af4e6b0de0875242d1551414c5c308210cb..75d4a9c89fd5cdb7dc67ff1cee04fdb733a40b20 100644 --- a/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js +++ b/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js @@ -6,6 +6,7 @@ import { DASHBOARD_LOADING_FAILURE, CHART_LOADING_FAILURE, } from 'ee/analytics/dashboards/constants'; +import { generateSkeletonTableData } from 'ee/analytics/dashboards/utils'; import ComparisonChart from 'ee/analytics/dashboards/components/comparison_chart.vue'; import ComparisonTable from 'ee/analytics/dashboards/components/comparison_table.vue'; import GroupVulnerabilitiesQuery from 'ee/analytics/dashboards/graphql/group_vulnerabilities.query.graphql'; @@ -39,10 +40,6 @@ import { mockFlowMetricsResponseData, mockMergeRequestsResponseData, mockExcludeMetrics, - mockEmptyVulnerabilityResponse, - mockEmptyDoraResponse, - mockEmptyFlowMetricsResponse, - mockEmptyMergeRequestsResponse, } from '../mock_data'; const mockTypePolicy = { @@ -165,6 +162,17 @@ describe('Comparison chart', () => { createAlert.mockClear(); }); + describe('loading table and chart data', () => { + beforeEach(() => { + setGraphqlQueryHandlerResponses(); + createWrapper(); + }); + + it('will pass skeleton data to the comparison table', () => { + expect(getTableData()).toEqual(generateSkeletonTableData()); + }); + }); + describe('with table and chart data available', () => { beforeEach(async () => { setGraphqlQueryHandlerResponses(); @@ -265,10 +273,6 @@ describe('Comparison chart', () => { error: expect.anything(), }); }); - - it('renders no data message', () => { - expect(wrapper.text()).toContain('No data available'); - }); }); describe('failed chart request', () => { @@ -302,42 +306,6 @@ describe('Comparison chart', () => { }); }); - describe('no table data available', () => { - // When there is no table data available the chart data requests are skipped - - beforeEach(async () => { - setGraphqlQueryHandlerResponses({ - doraMetricsResponse: mockEmptyDoraResponse, - flowMetricsResponse: mockEmptyFlowMetricsResponse, - vulnerabilityResponse: mockEmptyVulnerabilityResponse, - mergeRequestsResponse: mockEmptyMergeRequestsResponse, - }); - mockApolloProvider = createMockApolloProvider(); - - await createWrapper({ apolloProvider: mockApolloProvider }); - }); - - it('will only request dora metrics for the table', () => { - expectDoraMetricsRequests(MOCK_TABLE_TIME_PERIODS); - }); - - it('will only request flow metrics for the table', () => { - expectFlowMetricsRequests(MOCK_TABLE_TIME_PERIODS); - }); - - it('will only request vulnerability metrics for the table', () => { - expectVulnerabilityRequests(MOCK_TABLE_TIME_PERIODS); - }); - - it('will only merge request metrics for the table', () => { - expectMergeRequestsRequests(MOCK_TABLE_TIME_PERIODS); - }); - - it('renders a message when theres no table data available', () => { - expect(wrapper.text()).toContain('No data available'); - }); - }); - describe('isProject=true', () => { const fakeProjectPath = 'fake/project/path'; diff --git a/ee/spec/frontend/analytics/dashboards/components/comparison_table_spec.js b/ee/spec/frontend/analytics/dashboards/components/comparison_table_spec.js index 86688a0168b460960524b0e9dfb92fef835d8edb..11f66ac093bee4b46466f7793429878e991a55d0 100644 --- a/ee/spec/frontend/analytics/dashboards/components/comparison_table_spec.js +++ b/ee/spec/frontend/analytics/dashboards/components/comparison_table_spec.js @@ -29,11 +29,12 @@ describe('Comparison table', () => { }); }; - const findMetricTableCell = (identifier) => wrapper.findByTestId(`${identifier}_metric_cell`); - const findChart = () => wrapper.findByTestId('metric_chart'); - const findChartSkeleton = () => wrapper.findByTestId('metric_chart_skeleton'); - const findTrendIndicator = () => wrapper.findByTestId('metric_trend_indicator'); - const findValueLimitInfoIcon = () => wrapper.findByTestId('metric_max_value_info_icon'); + const findMetricTableCell = (identifier) => wrapper.findByTestId(`${identifier}-metric-cell`); + const findMetricComparisonSkeletons = () => wrapper.findAllByTestId('metric-comparison-skeleton'); + const findChart = () => wrapper.findByTestId('metric-chart'); + const findChartSkeleton = () => wrapper.findByTestId('metric-chart-skeleton'); + const findTrendIndicator = () => wrapper.findByTestId('metric-trend-indicator'); + const findValueLimitInfoIcon = () => wrapper.findByTestId('metric-max-value-info-icon'); it.each(Object.keys(TABLE_METRICS))('renders table cell for %s metric', (identifier) => { createWrapper(); @@ -41,6 +42,11 @@ describe('Comparison table', () => { expect(findMetricTableCell(identifier).props('identifier')).toBe(identifier); }); + it('shows loading skeletons for each metric comparison cell', () => { + createWrapper({ tableData: [{ metric: mockMetric }] }); + expect(findMetricComparisonSkeletons().length).toBe(3); + }); + describe('date range table cell', () => { const valueLimit = { max: 10001, diff --git a/ee/spec/frontend/analytics/dashboards/mock_data.js b/ee/spec/frontend/analytics/dashboards/mock_data.js index 84d579c30fae86df18ba70ffb7d555ab5836fda3..3fc1add0a81608be150593f7054c0f70d2a8b635 100644 --- a/ee/spec/frontend/analytics/dashboards/mock_data.js +++ b/ee/spec/frontend/analytics/dashboards/mock_data.js @@ -416,6 +416,13 @@ export const mockComparativeTableData = [ }, ]; +export const mockGeneratedMetricComparisons = () => + mockComparativeTableData.reduce( + (acc, { metric, lastMonth, thisMonth, twoMonthsAgo }) => + Object.assign(acc, { [metric.identifier]: { lastMonth, thisMonth, twoMonthsAgo } }), + {}, + ); + const mockChartDataValues = (values) => values.map((v) => [expect.anything(), v]); const mockChartDataWithSameValue = (count, value) => @@ -423,91 +430,139 @@ const mockChartDataWithSameValue = (count, value) => export const mockSubsetChartData = { change_failure_rate: { - data: mockChartDataWithSameValue(2, 0), - tooltipLabel: '%', + chart: { + data: mockChartDataWithSameValue(2, 0), + tooltipLabel: '%', + }, }, cycle_time: { - data: mockChartDataWithSameValue(2, 0), - tooltipLabel: 'days', + chart: { + data: mockChartDataWithSameValue(2, 0), + tooltipLabel: 'days', + }, }, deployment_frequency: { - data: mockChartDataWithSameValue(2, 0), - tooltipLabel: '/day', + chart: { + data: mockChartDataWithSameValue(2, 0), + tooltipLabel: '/day', + }, }, deploys: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, issues: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, issues_completed: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, lead_time: { - data: mockChartDataWithSameValue(2, 0), - tooltipLabel: 'days', + chart: { + data: mockChartDataWithSameValue(2, 0), + tooltipLabel: 'days', + }, }, lead_time_for_changes: { - data: mockChartDataValues([1, 2]), - tooltipLabel: 'days', + chart: { + data: mockChartDataValues([1, 2]), + tooltipLabel: 'days', + }, }, time_to_restore_service: { - data: mockChartDataValues([100, 99]), - tooltipLabel: 'days', + chart: { + data: mockChartDataValues([100, 99]), + tooltipLabel: 'days', + }, }, vulnerability_critical: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, vulnerability_high: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, merge_request_throughput: { - data: mockChartDataWithSameValue(2, 0), + chart: { + data: mockChartDataWithSameValue(2, 0), + }, }, }; export const mockChartData = { lead_time_for_changes: { - tooltipLabel: 'days', - data: mockChartDataWithSameValue(6, null), + chart: { + tooltipLabel: 'days', + data: mockChartDataWithSameValue(6, null), + }, }, time_to_restore_service: { - tooltipLabel: 'days', - data: mockChartDataWithSameValue(6, 0), + chart: { + tooltipLabel: 'days', + data: mockChartDataWithSameValue(6, 0), + }, }, change_failure_rate: { - tooltipLabel: '%', - data: mockChartDataWithSameValue(6, 0), + chart: { + tooltipLabel: '%', + data: mockChartDataWithSameValue(6, 0), + }, }, deployment_frequency: { - tooltipLabel: '/day', - data: mockChartDataValues([0, 1, 2, 3, 4, 5]), + chart: { + tooltipLabel: '/day', + data: mockChartDataValues([0, 1, 2, 3, 4, 5]), + }, }, lead_time: { - tooltipLabel: 'days', - data: mockChartDataValues([1, 2, 3, 4, 5, 6]), + chart: { + tooltipLabel: 'days', + data: mockChartDataValues([1, 2, 3, 4, 5, 6]), + }, }, cycle_time: { - tooltipLabel: 'days', - data: mockChartDataValues([0, 2, 4, 6, 8, 10]), + chart: { + tooltipLabel: 'days', + data: mockChartDataValues([0, 2, 4, 6, 8, 10]), + }, }, issues: { - data: mockChartDataValues([100, 98, 96, 94, 92, 90]), + chart: { + data: mockChartDataValues([100, 98, 96, 94, 92, 90]), + }, }, issues_completed: { - data: mockChartDataValues([200, 198, 196, 194, 192, 190]), + chart: { + data: mockChartDataValues([200, 198, 196, 194, 192, 190]), + }, }, deploys: { - data: mockChartDataValues([0, 1, 4, 9, 16, 25]), + chart: { + data: mockChartDataValues([0, 1, 4, 9, 16, 25]), + }, }, vulnerability_critical: { - data: mockChartDataValues([0, 1, 2, 3, 0, 1]), + chart: { + data: mockChartDataValues([0, 1, 2, 3, 0, 1]), + }, }, vulnerability_high: { - data: mockChartDataValues([0, 1, 0, 1, 0, 1]), + chart: { + data: mockChartDataValues([0, 1, 0, 1, 0, 1]), + }, }, merge_request_throughput: { - data: mockChartDataValues([0, 1, 2, 3, 4, 5]), + chart: { + data: mockChartDataValues([0, 1, 2, 3, 4, 5]), + }, }, }; @@ -617,17 +672,6 @@ export const mockExcludeMetrics = [ DORA_METRICS.LEAD_TIME_FOR_CHANGES, ]; -export const mockEmptyVulnerabilityResponse = [{ date: null, critical: null, high: null }]; -export const mockEmptyDoraResponse = { metrics: [] }; -export const mockEmptyMergeRequestsResponse = { mergeRequests: {} }; -export const mockEmptyFlowMetricsResponse = { - issues: null, - issues_completed: null, - deploys: null, - cycle_time: null, - lead_time: null, -}; - export const MOCK_LABELS = [ { id: 1, title: 'one', color: '#FFFFFF' }, { id: 2, title: 'two', color: '#000000' }, diff --git a/ee/spec/frontend/analytics/dashboards/utils_spec.js b/ee/spec/frontend/analytics/dashboards/utils_spec.js index e19f2c6830580c7227f4e67a6c3e4230dd541cb3..e5d5849edf1ad6e710bb07c0c7ab07faa6069cf0 100644 --- a/ee/spec/frontend/analytics/dashboards/utils_spec.js +++ b/ee/spec/frontend/analytics/dashboards/utils_spec.js @@ -5,10 +5,10 @@ import { useFakeDate } from 'helpers/fake_date'; import { percentChange, formatMetric, - hasDoraMetricValues, - generateDoraTimePeriodComparisonTable, + generateSkeletonTableData, + generateMetricComparisons, generateSparklineCharts, - mergeSparklineCharts, + mergeTableData, hasTrailingDecimalZero, generateDateRanges, generateChartTimePeriods, @@ -16,14 +16,13 @@ import { generateValueStreamDashboardStartDate, groupDoraPerformanceScoreCountsByCategory, } from 'ee/analytics/dashboards/utils'; -import { CHANGE_FAILURE_RATE, LEAD_TIME_FOR_CHANGES } from 'ee/api/dora_api'; import { LEAD_TIME_METRIC_TYPE, CYCLE_TIME_METRIC_TYPE } from '~/api/analytics_api'; import { mockMonthToDateTimePeriod, mockPreviousMonthTimePeriod, mockTwoMonthsAgoTimePeriod, mockThreeMonthsAgoTimePeriod, - mockComparativeTableData, + mockGeneratedMetricComparisons, mockChartsTimePeriods, mockChartData, mockSubsetChartsTimePeriods, @@ -93,7 +92,24 @@ describe('Analytics Dashboards utils', () => { }); }); - describe('generateDoraTimePeriodComparisonTable', () => { + describe('generateSkeletonTableData', () => { + it('returns blank row data for each metric', () => { + const tableData = generateSkeletonTableData(); + tableData.forEach((data) => + expect(Object.keys(data)).toEqual(['invertTrendColor', 'metric', 'valueLimit']), + ); + }); + + it('does not include metrics that were in excludeMetrics', () => { + const excludeMetrics = [LEAD_TIME_METRIC_TYPE, CYCLE_TIME_METRIC_TYPE]; + const tableData = generateSkeletonTableData(excludeMetrics); + + const metrics = tableData.map(({ metric }) => metric.identifier); + expect(metrics).not.toEqual(expect.arrayContaining(excludeMetrics)); + }); + }); + + describe('generateMetricComparisons', () => { const timePeriods = [ mockMonthToDateTimePeriod, mockPreviousMonthTimePeriod, @@ -102,30 +118,19 @@ describe('Analytics Dashboards utils', () => { ]; it('calculates the changes between the 2 time periods', () => { - const tableData = generateDoraTimePeriodComparisonTable({ timePeriods }); - expect(tableData).toEqual(mockComparativeTableData); + const tableData = generateMetricComparisons(timePeriods); + expect(tableData).toEqual(mockGeneratedMetricComparisons()); }); it('returns the comparison table fields + metadata for each row', () => { - generateDoraTimePeriodComparisonTable({ timePeriods }).forEach((row) => { - expect(Object.keys(row)).toEqual([ - 'invertTrendColor', - 'metric', - 'valueLimit', - 'thisMonth', - 'lastMonth', - 'twoMonthsAgo', - ]); + Object.values(generateMetricComparisons(timePeriods)).forEach((row) => { + expect(row).toMatchObject({ + thisMonth: expect.any(Object), + lastMonth: expect.any(Object), + twoMonthsAgo: expect.any(Object), + }); }); }); - - it('does not include metrics that were in excludeMetrics', () => { - const excludeMetrics = [LEAD_TIME_METRIC_TYPE, CYCLE_TIME_METRIC_TYPE]; - const tableData = generateDoraTimePeriodComparisonTable({ timePeriods, excludeMetrics }); - - const metrics = tableData.map(({ metric }) => metric.identifier); - expect(metrics).not.toEqual(expect.arrayContaining(excludeMetrics)); - }); }); describe('generateSparklineCharts', () => { @@ -150,46 +155,19 @@ describe('Analytics Dashboards utils', () => { }); }); - describe('mergeSparklineCharts', () => { - it('returns the table data with the additive chart data', () => { - const chart = { data: [1, 2, 3] }; - const rowNoChart = { metric: { identifier: 'noChart' } }; - const rowWithChart = { metric: { identifier: 'withChart' } }; + describe('mergeTableData', () => { + it('correctly integrates existing and new data', () => { + const newData = { chart: { data: [1, 2, 3] }, lastMonth: { test: 'test' } }; + const rowNoData = { metric: { identifier: 'noData' } }; + const rowWithData = { metric: { identifier: 'withData' } }; - expect(mergeSparklineCharts([rowNoChart, rowWithChart], { withChart: chart })).toEqual([ - rowNoChart, - { ...rowWithChart, chart }, + expect(mergeTableData([rowNoData, rowWithData], { withData: newData })).toEqual([ + rowNoData, + { ...rowWithData, ...newData }, ]); }); }); - describe('hasDoraMetricValues', () => { - it('returns false if only non-DORA metrics contain a value > 0', () => { - const timePeriods = [{ nonDoraMetric: { value: 100 } }]; - expect(hasDoraMetricValues(timePeriods)).toBe(false); - }); - - it('returns false if all DORA metrics contain a non-numerical value', () => { - const timePeriods = [{ [LEAD_TIME_FOR_CHANGES]: { value: 'YEET' } }]; - expect(hasDoraMetricValues(timePeriods)).toBe(false); - }); - - it('returns false if all DORA metrics contain a value == 0', () => { - const timePeriods = [{ [LEAD_TIME_FOR_CHANGES]: { value: 0 } }]; - expect(hasDoraMetricValues(timePeriods)).toBe(false); - }); - - it('returns true if any DORA metrics contain a value > 0', () => { - const timePeriods = [ - { - [LEAD_TIME_FOR_CHANGES]: { value: 0 }, - [CHANGE_FAILURE_RATE]: { value: 100 }, - }, - ]; - expect(hasDoraMetricValues(timePeriods)).toBe(true); - }); - }); - describe('generateDateRanges', () => { it('return correct value', () => { const now = MOCK_TABLE_TIME_PERIODS[0].end; @@ -235,7 +213,7 @@ describe('Analytics Dashboards utils', () => { useFakeDate(2020, 4, 4); it('will return the correct day', () => { - expect(generateValueStreamDashboardStartDate().toISOString()).toEqual( + expect(generateValueStreamDashboardStartDate().toISOString()).toBe( '2020-05-04T00:00:00.000Z', ); }); @@ -245,7 +223,7 @@ describe('Analytics Dashboards utils', () => { useFakeDate(2023, 6, 1); it('will return the previous day', () => { - expect(generateValueStreamDashboardStartDate().toISOString()).toEqual( + expect(generateValueStreamDashboardStartDate().toISOString()).toBe( '2023-06-30T00:00:00.000Z', ); });