Commit 375cbbfe authored by Michael Lunøe's avatar Michael Lunøe Committed by Illya Klymov

Fix(contribution analytics): tilt x-axis in charts

Fixes #6830
parent e1c83c24
export const BYTES_IN_KIB = 1024;
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 = {
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.
......@@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_');
* @param {Number} maxLength
* @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
......
......@@ -32,7 +32,7 @@ export default class AbuseReports {
$messageCellElement.text(originalMessage);
} else {
$messageCellElement.data('messageTruncated', 'true');
$messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`);
$messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH));
}
}
}
<script>
import { GlColumnChart } from '@gitlab/ui/dist/charts';
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 {
components: {
GlColumnChart,
ResizableChartContainer,
},
directives: {
GlResizeObserverDirective,
},
props: {
chartData: {
......@@ -28,6 +36,8 @@ export default {
},
data() {
return {
width: 0,
height: CHART_HEIGHT,
svgs: {},
};
},
......@@ -40,6 +50,18 @@ export default {
chartOptions() {
return {
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() {
......@@ -59,21 +81,27 @@ export default {
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');
columnChart.on('datazoom', this.updateAxisNamePadding);
},
},
height: CHART_HEIGHT,
};
</script>
<template>
<resizable-chart-container>
<div v-gl-resize-observer-directive="onResize">
<gl-column-chart
slot-scope="{ width }"
ref="columnChart"
v-bind="$attrs"
:width="width"
:height="$options.height"
:height="height"
:data="seriesData"
:x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle"
......@@ -81,5 +109,5 @@ export default {
:option="chartOptions"
@created="onChartCreated"
/>
</resizable-chart-container>
</div>
</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`] = `
<div>
<gl-column-chart-stub
data="[object Object]"
height="220"
height="350"
option="[object Object]"
width="0"
xaxistitle="Username"
......
......@@ -27,4 +27,21 @@ describe('Contribution Analytics Column Chart', () => {
it('matches the snapshot', () => {
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', () => {
it('returns truncated version of comment if it is longer than max length', () => {
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', () => {
});
});
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', () => {
it('shortens SHAs to 8 characters', () => {
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