Commit 95fc3e27 authored by Illya Klymov's avatar Illya Klymov

Merge branch '6830-tilt-axis-and-adjust-spacing-in-contribution-analytics-charts' into 'master'

Fix(contribution analytics): tilt x-axis and adjust spacing accordingly

Closes #6830

See merge request gitlab-org/gitlab!33692
parents 5f0667ee 375cbbfe
export const BYTES_IN_KIB = 1024; export const BYTES_IN_KIB = 1024;
export const HIDDEN_CLASS = 'hidden'; export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
export const DATETIME_RANGE_TYPES = { export const DATETIME_RANGE_TYPES = {
fixed: 'fixed', fixed: 'fixed',
......
import { isString } from 'lodash'; import { isString, memoize } from 'lodash';
import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} from '~/lib/utils/constants';
/** /**
* Adds a , to a string composed by numbers, at every 3 chars. * Adds a , to a string composed by numbers, at every 3 chars.
...@@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_'); ...@@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_');
* @param {Number} maxLength * @param {Number} maxLength
* @returns {String} * @returns {String}
*/ */
export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; export const truncate = (string, maxLength) => {
if (string.length - 1 > maxLength) {
return `${string.substr(0, maxLength - 1)}…`;
}
return string;
};
/**
* This function calculates the average char width. It does so by placing a string in the DOM and measuring the width.
* NOTE: This will cause a reflow and should be used sparsely!
* The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with.
* https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily
* https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize
* @param {Object} options
* @param {Number} options.fontSize style to size the text for measurement
* @param {String} options.fontFamily style of font family to measure the text with
* @param {String} options.chars string of chars to use as a basis for calculating average width
* @return {Number}
*/
const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) {
const {
fontSize = 12,
fontFamily = 'sans-serif',
// eslint-disable-next-line @gitlab/require-i18n-strings
chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
} = options;
const div = document.createElement('div');
div.style.fontFamily = fontFamily;
div.style.fontSize = `${fontSize}px`;
// Place outside of view
div.style.position = 'absolute';
div.style.left = -1000;
div.style.top = -1000;
div.innerHTML = chars;
document.body.appendChild(div);
const width = div.clientWidth;
document.body.removeChild(div);
return width / chars.length / fontSize;
});
/**
* This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`,
* otherwise it will return the original `string`
* Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf
* @param {String} string text to truncate
* @param {Object} options
* @param {Number} options.maxWidth largest rendered width the text may have
* @param {Number} options.fontSize size of the font used to render the text
* @return {String} either the original string or a truncated version
*/
export const truncateWidth = (string, options = {}) => {
const {
maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH,
fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} = options;
const { truncateIndex } = string.split('').reduce(
(memo, char, index) => {
let newIndex = index;
if (memo.width > maxWidth) {
newIndex = memo.truncateIndex;
}
return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex };
},
{ width: 0, truncateIndex: 0 },
);
return truncate(string, truncateIndex);
};
/** /**
* Truncate SHA to 8 characters * Truncate SHA to 8 characters
......
...@@ -32,7 +32,7 @@ export default class AbuseReports { ...@@ -32,7 +32,7 @@ export default class AbuseReports {
$messageCellElement.text(originalMessage); $messageCellElement.text(originalMessage);
} else { } else {
$messageCellElement.data('messageTruncated', 'true'); $messageCellElement.data('messageTruncated', 'true');
$messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`); $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH));
} }
} }
} }
<script> <script>
import { GlColumnChart } from '@gitlab/ui/dist/charts'; import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; import { truncateWidth } from '~/lib/utils/text_utility';
import { GlResizeObserverDirective } from '@gitlab/ui';
const CHART_HEIGHT = 220; import {
CHART_HEIGHT,
CHART_X_AXIS_NAME_TOP_PADDING,
CHART_X_AXIS_ROTATE,
INNER_CHART_HEIGHT,
} from '../constants';
export default { export default {
components: { components: {
GlColumnChart, GlColumnChart,
ResizableChartContainer, },
directives: {
GlResizeObserverDirective,
}, },
props: { props: {
chartData: { chartData: {
...@@ -28,6 +36,8 @@ export default { ...@@ -28,6 +36,8 @@ export default {
}, },
data() { data() {
return { return {
width: 0,
height: CHART_HEIGHT,
svgs: {}, svgs: {},
}; };
}, },
...@@ -40,6 +50,18 @@ export default { ...@@ -40,6 +50,18 @@ export default {
chartOptions() { chartOptions() {
return { return {
dataZoom: [this.dataZoomConfig], dataZoom: [this.dataZoomConfig],
height: INNER_CHART_HEIGHT,
xAxis: {
axisLabel: {
rotate: CHART_X_AXIS_ROTATE,
formatter(value) {
return truncateWidth(value);
},
},
nameTextStyle: {
padding: [CHART_X_AXIS_NAME_TOP_PADDING, 0, 0, 0],
},
},
}; };
}, },
seriesData() { seriesData() {
...@@ -59,21 +81,27 @@ export default { ...@@ -59,21 +81,27 @@ export default {
console.error('SVG could not be rendered correctly: ', e); console.error('SVG could not be rendered correctly: ', e);
}); });
}, },
onChartCreated() { onResize() {
const { columnChart } = this.$refs;
if (!columnChart) return;
const { width } = columnChart.$el.getBoundingClientRect();
this.width = width;
},
onChartCreated(columnChart) {
this.setSvg('scroll-handle'); this.setSvg('scroll-handle');
columnChart.on('datazoom', this.updateAxisNamePadding);
}, },
}, },
height: CHART_HEIGHT,
}; };
</script> </script>
<template> <template>
<resizable-chart-container> <div v-gl-resize-observer-directive="onResize">
<gl-column-chart <gl-column-chart
slot-scope="{ width }" ref="columnChart"
v-bind="$attrs" v-bind="$attrs"
:width="width" :width="width"
:height="$options.height" :height="height"
:data="seriesData" :data="seriesData"
:x-axis-title="xAxisTitle" :x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle" :y-axis-title="yAxisTitle"
...@@ -81,5 +109,5 @@ export default { ...@@ -81,5 +109,5 @@ export default {
:option="chartOptions" :option="chartOptions"
@created="onChartCreated" @created="onChartCreated"
/> />
</resizable-chart-container> </div>
</template> </template>
export const CHART_HEIGHT = 350;
export const INNER_CHART_HEIGHT = 200;
export const CHART_X_AXIS_ROTATE = 45;
export const CHART_X_AXIS_NAME_TOP_PADDING = 55;
---
title: 'Tilt contribution analytics x-axis chart titles'
merge_request: 33692
author:
type: fixed
...@@ -4,7 +4,7 @@ exports[`Contribution Analytics Column Chart matches the snapshot 1`] = ` ...@@ -4,7 +4,7 @@ exports[`Contribution Analytics Column Chart matches the snapshot 1`] = `
<div> <div>
<gl-column-chart-stub <gl-column-chart-stub
data="[object Object]" data="[object Object]"
height="220" height="350"
option="[object Object]" option="[object Object]"
width="0" width="0"
xaxistitle="Username" xaxistitle="Username"
......
...@@ -27,4 +27,21 @@ describe('Contribution Analytics Column Chart', () => { ...@@ -27,4 +27,21 @@ describe('Contribution Analytics Column Chart', () => {
it('matches the snapshot', () => { it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('matches properties', () => {
const {
height,
xAxis: {
axisLabel: { formatter, ...axisLabel },
nameTextStyle,
},
} = wrapper.find('gl-column-chart-stub').props().option;
expect(height).toEqual(200);
expect(nameTextStyle).toEqual({
padding: [55, 0, 0, 0],
});
expect(axisLabel).toEqual({
rotate: 45,
});
});
}); });
...@@ -110,7 +110,7 @@ describe('DiffGutterAvatars', () => { ...@@ -110,7 +110,7 @@ describe('DiffGutterAvatars', () => {
it('returns truncated version of comment if it is longer than max length', () => { it('returns truncated version of comment if it is longer than max length', () => {
const note = wrapper.vm.discussions[0].notes[1]; const note = wrapper.vm.discussions[0].notes[1];
expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...'); expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is rea…');
}); });
}); });
}); });
...@@ -145,6 +145,56 @@ describe('text_utility', () => { ...@@ -145,6 +145,56 @@ describe('text_utility', () => {
}); });
}); });
describe('truncate', () => {
it('returns the original string when str length is less than maxLength', () => {
const str = 'less than 20 chars';
expect(textUtils.truncate(str, 20)).toEqual(str);
});
it('returns truncated string when str length is more than maxLength', () => {
const str = 'more than 10 chars';
expect(textUtils.truncate(str, 10)).toEqual(`${str.substring(0, 10 - 1)}…`);
});
it('returns the original string when rendered width is exactly equal to maxWidth', () => {
const str = 'Exactly 16 chars';
expect(textUtils.truncate(str, 16)).toEqual(str);
});
});
describe('truncateWidth', () => {
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth');
beforeAll(() => {
// Mock measured width of ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
Object.defineProperty(Element.prototype, 'clientWidth', {
value: 431,
writable: false,
});
});
afterAll(() => {
Object.defineProperty(Element.prototype, 'clientWidth', clientWidthDescriptor);
});
it('returns the original string when rendered width is less than maxWidth', () => {
const str = '< 80px';
expect(textUtils.truncateWidth(str)).toEqual(str);
});
it('returns truncated string when rendered width is more than maxWidth', () => {
const str = 'This is wider than 80px';
expect(textUtils.truncateWidth(str)).toEqual(`${str.substring(0, 10)}…`);
});
it('returns the original string when rendered width is exactly equal to maxWidth', () => {
const str = 'Exactly 159.62962962962965px';
expect(textUtils.truncateWidth(str, { maxWidth: 159.62962962962965, fontSize: 10 })).toEqual(
str,
);
});
});
describe('truncateSha', () => { describe('truncateSha', () => {
it('shortens SHAs to 8 characters', () => { it('shortens SHAs to 8 characters', () => {
expect(textUtils.truncateSha('verylongsha')).toBe('verylong'); expect(textUtils.truncateSha('verylongsha')).toBe('verylong');
......
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