Skip to content

Commit b27faa2

Browse files
committed
#RI-3646 - Change the Expiration timeline to bar charts
#RI-3721 - Buttons cancel/save are not visible fully
1 parent 88e75c6 commit b27faa2

File tree

13 files changed

+387
-12
lines changed

13 files changed

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

redisinsight/ui/src/components/inline-item-editor/styles.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
width: 80px;
2121
height: 33px;
2222

23-
z-index: 1;
23+
z-index: 3;
2424

2525
.tooltip,
2626
.declineBtn,

redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import ExpirationGroupsView from './ExpirationGroupsView'
55

66
describe('ExpirationGroupsView', () => {
77
it('should be rendered', async () => {
8-
expect(render(<ExpirationGroupsView data={null} loading={false} />)).toBeTruthy()
8+
expect(render(<ExpirationGroupsView data={null} extrapolation={1} loading={false} />)).toBeTruthy()
99
})
1010

1111
it('should render spinner if loading=true and data=null', async () => {
12-
const { queryByTestId } = render(<ExpirationGroupsView data={null} loading />)
12+
const { queryByTestId } = render(<ExpirationGroupsView data={null} extrapolation={1} loading />)
1313

1414
expect(queryByTestId('summary-per-ttl-loading')).toBeInTheDocument()
1515
expect(queryByTestId('analysis-ttl')).not.toBeInTheDocument()

0 commit comments

Comments
 (0)