Skip to content

Commit 66102ea

Browse files
authored
Merge pull request #1321 from RedisInsight/feature/RI-3646_Change_Expiration_timeline_to_Bar_chart
#RI-3646, #RI-3721
2 parents 9d75621 + 98e525b commit 66102ea

File tree

15 files changed

+423
-32
lines changed

15 files changed

+423
-32
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { isEmpty, last, min as minBy, reject } from 'lodash'
2+
import React from 'react'
3+
import { render, screen, fireEvent, waitFor } from 'uiSrc/utils/test-utils'
4+
5+
import BarChart, { BarChartData, BarChartDataType } from './BarChart'
6+
7+
const mockData: BarChartData[] = [
8+
{ x: 1, y: 0, xlabel: '', ylabel: '' },
9+
{ x: 5, y: 0.1, xlabel: '', ylabel: '' },
10+
{ x: 10, y: 20, xlabel: '', ylabel: '' },
11+
{ x: 2, y: 30, xlabel: '', ylabel: '' },
12+
{ x: 30, y: 40, xlabel: '', ylabel: '' },
13+
{ x: 15, y: 50000, xlabel: '', ylabel: '' },
14+
]
15+
16+
describe('BarChart', () => {
17+
it('should render with empty data', () => {
18+
expect(render(<BarChart data={[]} />)).toBeTruthy()
19+
})
20+
21+
it('should render with data', () => {
22+
expect(render(<BarChart data={mockData} />)).toBeTruthy()
23+
})
24+
25+
it('should not render area with empty data', () => {
26+
const { container } = render(<BarChart data={[]} name="test" />)
27+
expect(container).toBeEmptyDOMElement()
28+
})
29+
30+
it('should render svg', () => {
31+
render(<BarChart data={mockData} name="test" />)
32+
expect(screen.getByTestId('bar-test')).toBeInTheDocument()
33+
})
34+
35+
it('should render bars', () => {
36+
render(<BarChart data={mockData} />)
37+
mockData.forEach(({ x, y }) => {
38+
expect(screen.getByTestId(`bar-${x}-${y}`)).toBeInTheDocument()
39+
})
40+
})
41+
42+
it('should render smallest bar with min height', () => {
43+
const minBarHeight = 5
44+
const smallestBar = minBy(
45+
reject([...mockData], ({ y }) => !y),
46+
({ y }, i) => y,
47+
) ?? { x: 0, y: 0 }
48+
49+
render(<BarChart data={mockData} minBarHeight={minBarHeight} />)
50+
expect(screen.getByTestId(`bar-${smallestBar.x}-${smallestBar.y}`)).toBeInTheDocument()
51+
expect(screen.getByTestId(`bar-${smallestBar.x}-${smallestBar.y}`)).toHaveAttribute('height', `${minBarHeight}`)
52+
})
53+
54+
it('should render tooltip and content inside', async () => {
55+
render(<BarChart data={mockData} name="test" />)
56+
57+
await waitFor(() => {
58+
fireEvent.mouseMove(screen.getByTestId('bar-15-50000'))
59+
}, { timeout: 210 }) // Account for long delay on tooltips
60+
61+
expect(screen.getByTestId('bar-tooltip')).toBeInTheDocument()
62+
expect(screen.getByTestId('bar-tooltip')).toHaveTextContent('50000')
63+
})
64+
65+
it('when dataType="Bytes" max value should be rounded by metric', async () => {
66+
const lastDataValue = last(mockData)
67+
const { queryByTestId } = render(<BarChart data={mockData} name="test" dataType={BarChartDataType.Bytes} />)
68+
69+
expect(queryByTestId(`ytick-${lastDataValue?.y}-4`)).not.toBeInTheDocument()
70+
expect(queryByTestId('ytick-51200-8')).toBeInTheDocument()
71+
expect(queryByTestId('ytick-51200-8')).toHaveTextContent('51200')
72+
})
73+
74+
it('when dataType!="Bytes" max value should be rounded by default', async () => {
75+
const lastDataValue = last(mockData)
76+
const { queryByTestId } = render(<BarChart data={mockData} name="test" />)
77+
78+
expect(queryByTestId('ytick-51200-8')).not.toBeInTheDocument()
79+
expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toBeInTheDocument()
80+
expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toHaveTextContent(`${lastDataValue?.y}`)
81+
})
82+
})
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import * as d3 from 'd3'
2+
import React, { useEffect, useRef } from 'react'
3+
import cx from 'classnames'
4+
import { curryRight, flow, toNumber } from 'lodash'
5+
6+
import { formatBytes, toBytes } from 'uiSrc/utils'
7+
import styles from './styles.module.scss'
8+
9+
export interface BarChartData {
10+
y: number
11+
x: number
12+
xlabel: string
13+
ylabel: string
14+
}
15+
16+
interface IDatum extends BarChartData{
17+
index: number
18+
}
19+
20+
export enum BarChartDataType {
21+
Bytes = 'bytes'
22+
}
23+
24+
interface IProps {
25+
name?: string
26+
data?: BarChartData[]
27+
dataType?: BarChartDataType
28+
barWidth?: number
29+
minBarHeight?: number
30+
width?: number
31+
height?: number
32+
yCountTicks?: number
33+
divideLastColumn?: boolean
34+
multiplierGrid?: number
35+
classNames?: {
36+
bar?: string
37+
dashedLine?: string
38+
tooltip?: string
39+
scatterPoints?: string
40+
}
41+
tooltipValidation?: (val: any, index: number) => string
42+
leftAxiosValidation?: (val: any, index: number) => any
43+
bottomAxiosValidation?: (val: any, index: number) => any
44+
}
45+
46+
export const DEFAULT_MULTIPLIER_GRID = 5
47+
export const DEFAULT_Y_TICKS = 8
48+
export const DEFAULT_BAR_WIDTH = 40
49+
export const MIN_BAR_HEIGHT = 3
50+
let cleanedData: IDatum[] = []
51+
52+
const BarChart = (props: IProps) => {
53+
const {
54+
data = [],
55+
name,
56+
width: propWidth = 0,
57+
height: propHeight = 0,
58+
barWidth = DEFAULT_BAR_WIDTH,
59+
yCountTicks = DEFAULT_Y_TICKS,
60+
minBarHeight = MIN_BAR_HEIGHT,
61+
dataType,
62+
classNames,
63+
divideLastColumn,
64+
multiplierGrid = DEFAULT_MULTIPLIER_GRID,
65+
tooltipValidation = (val) => val,
66+
leftAxiosValidation = (val) => val,
67+
bottomAxiosValidation = (val) => val,
68+
} = props
69+
70+
const margin = { top: 10, right: 0, bottom: 32, left: 60 }
71+
const width = propWidth - margin.left - margin.right
72+
const height = propHeight - margin.top - margin.bottom
73+
74+
const svgRef = useRef<SVGSVGElement>(null)
75+
76+
const getRoundedYMaxValue = (number: number): number => {
77+
const numLen = number.toString().length
78+
const dividerValue = toNumber(`1${'0'.repeat(numLen - 1)}`)
79+
80+
return Math.ceil(number / dividerValue) * dividerValue
81+
}
82+
83+
useEffect(() => {
84+
if (data.length === 0) {
85+
return undefined
86+
}
87+
88+
const tooltip = d3.select('body').append('div')
89+
.attr('class', cx(styles.tooltip, classNames?.tooltip || ''))
90+
.style('opacity', 0)
91+
92+
d3
93+
.select(svgRef.current)
94+
.select('g')
95+
.remove()
96+
97+
// append the svg object to the body of the page
98+
const svg = d3.select(svgRef.current)
99+
.attr('data-testid', `bar-${name}`)
100+
.attr('width', width + margin.left + margin.right)
101+
.attr('height', height + margin.top + margin.bottom + 30)
102+
.append('g')
103+
.attr('transform',
104+
`translate(${margin.left},${margin.top})`)
105+
106+
const tempData = [...data]
107+
108+
tempData.push({ x: 0, y: 0, xlabel: '', ylabel: '', })
109+
cleanedData = tempData.map((datum, index) => ({
110+
index,
111+
xlabel: `${datum?.xlabel || ''}`,
112+
ylabel: `${datum?.ylabel || ''}`,
113+
y: datum.y || 0,
114+
x: datum.x || 0,
115+
}))
116+
117+
// Add X axis
118+
const xAxis = d3.scaleLinear()
119+
.domain(d3.extent(cleanedData, (d) => d.index) as [number, number])
120+
.range([0, width])
121+
122+
let maxY = d3.max(cleanedData, (d) => d.y) || yCountTicks
123+
124+
if (dataType === BarChartDataType.Bytes) {
125+
const curriedTyBytes = curryRight(toBytes)
126+
const [maxYFormatted, type] = formatBytes(maxY, 1, true)
127+
128+
maxY = flow(
129+
toNumber,
130+
Math.ceil,
131+
getRoundedYMaxValue,
132+
curriedTyBytes(`${type}`)
133+
)(maxYFormatted)
134+
}
135+
136+
// Add Y axis
137+
const yAxis = d3.scaleLinear()
138+
.domain([0, maxY || 0])
139+
.range([height, 0])
140+
141+
// divider for last column
142+
if (divideLastColumn) {
143+
svg.append('line')
144+
.attr('class', cx(styles.dashedLine, classNames?.dashedLine))
145+
.attr('x1', xAxis(cleanedData.length - 2.3))
146+
.attr('x2', xAxis(cleanedData.length - 2.3))
147+
.attr('y1', 0)
148+
.attr('y2', height)
149+
}
150+
151+
// squared background for Y axis
152+
svg.append('g')
153+
.call(
154+
d3.axisLeft(yAxis)
155+
.tickSize(-width + ((2 * width) / ((cleanedData.length) * multiplierGrid)) + 6)
156+
.tickValues([...d3.range(0, maxY, maxY / yCountTicks), maxY])
157+
.tickFormat((d, i) => leftAxiosValidation(d, i))
158+
.ticks(cleanedData.length * multiplierGrid)
159+
.tickPadding(10)
160+
)
161+
162+
const yTicks = d3.selectAll('.tick')
163+
yTicks.attr('data-testid', (d, i) => `ytick-${d}-${i}`)
164+
165+
// squared background for X axis
166+
svg.append('g')
167+
.attr('transform', `translate(0,${height})`)
168+
.call(
169+
d3.axisBottom(xAxis)
170+
.ticks(cleanedData.length * multiplierGrid)
171+
.tickFormat((d, i) => bottomAxiosValidation(d, i))
172+
.tickSize(-height)
173+
.tickPadding(22)
174+
)
175+
176+
// TODO: hide last 2 columns of background grid
177+
const allTicks = d3.selectAll('.tick')
178+
allTicks.attr('opacity', (_a, i) =>
179+
(i === allTicks.size() - 1 || i === allTicks.size() - 2 ? 0 : 1))
180+
181+
// moving X axios labels under the center of Bar
182+
svg.selectAll('text')
183+
.attr('x', barWidth / 2)
184+
185+
// roll back all changes for Y axios labels
186+
yTicks.attr('opacity', '1')
187+
yTicks.selectAll('text')
188+
.attr('x', -10)
189+
190+
// bars
191+
svg
192+
.selectAll('.bar')
193+
.data(cleanedData)
194+
.enter()
195+
.append('rect')
196+
.attr('class', cx(styles.bar, classNames?.bar))
197+
.attr('x', (d) => xAxis(d.index))
198+
.attr('width', barWidth)
199+
// set minimal height for Bar
200+
.attr('y', (d) => (d.y && height - yAxis(d.y) < minBarHeight ? height - minBarHeight : yAxis(d.y)))
201+
.attr('height', (d) => {
202+
const initialHeight = height - yAxis(d.y)
203+
return initialHeight && initialHeight < minBarHeight ? minBarHeight : initialHeight
204+
})
205+
.attr('data-testid', (d) => `bar-${d.x}-${d.y}`)
206+
.on('mouseenter mousemove', (event, d) => {
207+
tooltip
208+
.style('opacity', 1)
209+
tooltip.html(tooltipValidation(d.y, d.index))
210+
.style('left', `${event.pageX + 16}px`)
211+
.style('top', `${event.pageY + 16}px`)
212+
.attr('data-testid', 'bar-tooltip')
213+
})
214+
.on('mouseleave', () => {
215+
tooltip
216+
.style('opacity', 0)
217+
})
218+
219+
return () => {
220+
tooltip.remove()
221+
}
222+
}, [data, width, height])
223+
224+
if (!data.length) {
225+
return null
226+
}
227+
228+
return (
229+
<div className={styles.wrapper} style={{ width: propWidth, height: propHeight }}>
230+
<svg ref={svgRef} className={styles.svg} />
231+
</div>
232+
)
233+
}
234+
235+
export default BarChart
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import BarChart from './BarChart'
2+
3+
export * from './BarChart'
4+
5+
export default BarChart
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
.wrapper {
2+
margin: 0 auto;
3+
}
4+
5+
.svg {
6+
width: 100%;
7+
height: 100%;
8+
}
9+
10+
.bar {
11+
fill: rgba(var(--euiColorPrimaryRGB), 0.1);
12+
stroke: var(--euiColorPrimary);
13+
stroke-width: 1.5px;
14+
}
15+
16+
.tooltip {
17+
position: fixed;
18+
min-width: 50px;
19+
background: var(--euiTooltipBackgroundColor);
20+
color: var(--euiTooltipTextColor) !important;
21+
z-index: 10;
22+
border-radius: 8px;
23+
pointer-events: none;
24+
font-weight: 400;
25+
font-size: 12px !important;
26+
box-shadow: 0 3px 15px var(--controlsBoxShadowColor) !important;
27+
bottom: 0;
28+
height: 36px;
29+
min-height: 36px;
30+
padding: 10px;
31+
line-height: 16px;
32+
}
33+
34+
.scatterPoints {
35+
fill: var(--euiColorPrimary);
36+
cursor: pointer;
37+
}
38+
39+
.dashedLine {
40+
stroke: var(--euiTextSubduedColor);
41+
stroke-width: 1px;
42+
stroke-dasharray: 5, 3;
43+
}
44+
45+
:global {
46+
.tick line {
47+
stroke: var(--textColorShade);
48+
opacity: 0.1;
49+
}
50+
51+
.domain {
52+
opacity: 0;
53+
}
54+
55+
text {
56+
color: var(--euiTextSubduedColor);
57+
}
58+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import DonutChart from './donut-chart'
22
import AreaChart from './area-chart'
3+
import BarChart from './bar-chart'
34

45
export {
56
DonutChart,
67
AreaChart,
8+
BarChart,
79
}

0 commit comments

Comments
 (0)