Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { last } from 'lodash'
import React from 'react'
import { render, screen, fireEvent, waitFor } from 'uiSrc/utils/test-utils'

import BarChart, { BarChartData, BarChartDataType } from './BarChart'

const mockData: BarChartData[] = [
{ x: 1, y: 0, xlabel: '', ylabel: '' },
{ x: 5, y: 10, xlabel: '', ylabel: '' },
{ x: 10, y: 20, xlabel: '', ylabel: '' },
{ x: 2, y: 30, xlabel: '', ylabel: '' },
{ x: 30, y: 40, xlabel: '', ylabel: '' },
{ x: 15, y: 50000, xlabel: '', ylabel: '' },
]

describe('BarChart', () => {
it('should render with empty data', () => {
expect(render(<BarChart data={[]} />)).toBeTruthy()
})

it('should render with data', () => {
expect(render(<BarChart data={mockData} />)).toBeTruthy()
})

it('should not render area with empty data', () => {
const { container } = render(<BarChart data={[]} name="test" />)
expect(container).toBeEmptyDOMElement()
})

it('should render svg', () => {
render(<BarChart data={mockData} name="test" />)
expect(screen.getByTestId('bar-test')).toBeInTheDocument()
})

it('should render bars', () => {
render(<BarChart data={mockData} />)
mockData.forEach(({ x, y }) => {
expect(screen.getByTestId(`bar-${x}-${y}`)).toBeInTheDocument()
})
})

it('should render tooltip and content inside', async () => {
render(<BarChart data={mockData} name="test" />)

await waitFor(() => {
fireEvent.mouseMove(screen.getByTestId('bar-15-50000'))
}, { timeout: 210 }) // Account for long delay on tooltips

expect(screen.getByTestId('bar-tooltip')).toBeInTheDocument()
expect(screen.getByTestId('bar-tooltip')).toHaveTextContent('50000')
})

it('when dataType="Bytes" max value should be rounded by metric', async () => {
const lastDataValue = last(mockData)
const { queryByTestId } = render(<BarChart data={mockData} name="test" dataType={BarChartDataType.Bytes} />)

expect(queryByTestId(`ytick-${lastDataValue?.y}-4`)).not.toBeInTheDocument()
expect(queryByTestId('ytick-51200-8')).toBeInTheDocument()
expect(queryByTestId('ytick-51200-8')).toHaveTextContent('51200')
})

it('when dataType!="Bytes" max value should be rounded by default', async () => {
const lastDataValue = last(mockData)
const { queryByTestId } = render(<BarChart data={mockData} name="test" />)

expect(queryByTestId('ytick-51200-8')).not.toBeInTheDocument()
expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toBeInTheDocument()
expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toHaveTextContent(`${lastDataValue?.y}`)
})
})
229 changes: 229 additions & 0 deletions redisinsight/ui/src/components/charts/bar-chart/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import * as d3 from 'd3'
import React, { useEffect, useRef } from 'react'
import cx from 'classnames'
import { curryRight, flow, toNumber } from 'lodash'

import { formatBytes, toBytes } from 'uiSrc/utils'
import styles from './styles.module.scss'

export interface BarChartData {
y: number
x: number
xlabel: string
ylabel: string
}

interface IDatum extends BarChartData{
index: number
}

export enum BarChartDataType {
Bytes = 'bytes'
}

interface IProps {
name?: string
data?: BarChartData[]
dataType?: BarChartDataType
barWidth?: number
width?: number
height?: number
yCountTicks?: number
divideLastColumn?: boolean
multiplierGrid?: number
classNames?: {
bar?: string
dashedLine?: string
tooltip?: string
scatterPoints?: string
}
tooltipValidation?: (val: any, index: number) => string
leftAxiosValidation?: (val: any, index: number) => any
bottomAxiosValidation?: (val: any, index: number) => any
}

export const DEFAULT_MULTIPLIER_GRID = 5
export const DEFAULT_Y_TICKS = 8
export const DEFAULT_BAR_WIDTH = 40
let cleanedData: IDatum[] = []

const BarChart = (props: IProps) => {
const {
data = [],
name,
width: propWidth = 0,
height: propHeight = 0,
barWidth = DEFAULT_BAR_WIDTH,
yCountTicks = DEFAULT_Y_TICKS,
dataType,
classNames,
divideLastColumn,
multiplierGrid = DEFAULT_MULTIPLIER_GRID,
tooltipValidation = (val) => val,
leftAxiosValidation = (val) => val,
bottomAxiosValidation = (val) => val,
} = props

const margin = { top: 10, right: 0, bottom: 32, left: 60 }
const width = propWidth - margin.left - margin.right
const height = propHeight - margin.top - margin.bottom

const svgRef = useRef<SVGSVGElement>(null)

const getRoundedYMaxValue = (number: number): number => {
const numLen = number.toString().length
const dividerValue = toNumber(`1${'0'.repeat(numLen - 1)}`)

return Math.ceil(number / dividerValue) * dividerValue
}

useEffect(() => {
if (data.length === 0) {
return undefined
}

const tooltip = d3.select('body').append('div')
.attr('class', cx(styles.tooltip, classNames?.tooltip || ''))
.style('opacity', 0)

d3
.select(svgRef.current)
.select('g')
.remove()

// append the svg object to the body of the page
const svg = d3.select(svgRef.current)
.attr('data-testid', `bar-${name}`)
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom + 30)
.append('g')
.attr('transform',
`translate(${margin.left},${margin.top})`)

const tempData = [...data]

tempData.push({ x: 0, y: 0, xlabel: '', ylabel: '', })
cleanedData = tempData.map((datum, index) => ({
index,
xlabel: `${datum?.xlabel || ''}`,
ylabel: `${datum?.ylabel || ''}`,
y: datum.y || 0,
x: datum.x || 0,
}))

// Add X axis
const xAxis = d3.scaleLinear()
.domain(d3.extent(cleanedData, (d) => d.index) as [number, number])
.range([0, width])

let maxY = d3.max(cleanedData, (d) => d.y) || yCountTicks

if (dataType === BarChartDataType.Bytes) {
const curriedTyBytes = curryRight(toBytes)
const [maxYFormatted, type] = formatBytes(maxY, 1, true)

maxY = flow(
toNumber,
Math.ceil,
getRoundedYMaxValue,
curriedTyBytes(`${type}`)
)(maxYFormatted)
}

// Add Y axis
const yAxis = d3.scaleLinear()
.domain([0, maxY || 0])
.range([height, 0])

// bars
svg
.selectAll('.bar')
.data(cleanedData)
.enter()
.append('rect')
.attr('class', cx(styles.bar, classNames?.bar))
.attr('x', (d) => xAxis(d.index))
.attr('width', barWidth)
.attr('y', (d) => yAxis(d.y))
.attr('height', (d) => height - yAxis(d.y))
.attr('data-testid', (d) => `bar-${d.x}-${d.y}`)
.on('mousemove mouseenter', (event, d) => {
tooltip.transition()
.duration(200)
.style('opacity', 1)
tooltip.html(tooltipValidation(d.y, d.index))
.style('left', `${event.pageX + 16}px`)
.style('top', `${event.pageY + 16}px`)
.attr('data-testid', 'bar-tooltip')
})
.on('mouseout', () => {
tooltip.transition()
.style('opacity', 0)
})

// divider for last column
if (divideLastColumn) {
svg.append('line')
.attr('class', cx(styles.dashedLine, classNames?.dashedLine))
.attr('x1', xAxis(cleanedData.length - 2.3))
.attr('x2', xAxis(cleanedData.length - 2.3))
.attr('y1', 0)
.attr('y2', height)
}

// squared background for Y axis
svg.append('g')
.call(
d3.axisLeft(yAxis)
.tickSize(-width + ((2 * width) / ((cleanedData.length) * multiplierGrid)) + 6)
.tickValues([...d3.range(0, maxY, maxY / yCountTicks), maxY])
.tickFormat((d, i) => leftAxiosValidation(d, i))
.ticks(cleanedData.length * multiplierGrid)
.tickPadding(10)
)

const yTicks = d3.selectAll('.tick')
yTicks.attr('data-testid', (d, i) => `ytick-${d}-${i}`)

// squared background for X axis
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(
d3.axisBottom(xAxis)
.ticks(cleanedData.length * multiplierGrid)
.tickFormat((d, i) => bottomAxiosValidation(d, i))
.tickSize(-height)
.tickPadding(22)
)

// TODO: hide last 2 columns of background grid
const allTicks = d3.selectAll('.tick')
allTicks.attr('opacity', (_a, i) =>
(i === allTicks.size() - 1 || i === allTicks.size() - 2 ? 0 : 1))

// moving X axios labels under the center of Bar
svg.selectAll('text')
.attr('x', barWidth / 2)

// roll back all changes for Y axios labels
yTicks.attr('opacity', '1')
yTicks.selectAll('text')
.attr('x', -10)

return () => {
tooltip.remove()
}
}, [data, width, height])

if (!data.length) {
return null
}

return (
<div className={styles.wrapper} style={{ width: propWidth, height: propHeight }}>
<svg ref={svgRef} className={styles.svg} />
</div>
)
}

export default BarChart
5 changes: 5 additions & 0 deletions redisinsight/ui/src/components/charts/bar-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BarChart from './BarChart'

export * from './BarChart'

export default BarChart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.wrapper {
margin: 0 auto;
}

.svg {
width: 100%;
height: 100%;
}

.bar {
fill: rgba(var(--euiColorPrimaryRGB), 0.1);
stroke: var(--euiColorPrimary);
stroke-width: 1.5px;
}

.tooltip {
position: fixed;
min-width: 50px;
background: var(--euiTooltipBackgroundColor);
color: var(--euiTooltipTextColor) !important;
z-index: 10;
border-radius: 8px;
pointer-events: none;
font-weight: 400;
font-size: 12px !important;
box-shadow: 0 3px 15px var(--controlsBoxShadowColor) !important;
bottom: 0;
height: 36px;
min-height: 36px;
padding: 10px;
line-height: 16px;
}

.scatterPoints {
fill: var(--euiColorPrimary);
cursor: pointer;
}

.dashedLine {
stroke: var(--euiTextSubduedColor);
stroke-width: 1px;
stroke-dasharray: 5, 3;
}

:global {
.tick line {
stroke: var(--textColorShade);
opacity: 0.1;
}

.domain {
opacity: 0;
}

text {
color: var(--euiTextSubduedColor);
}
}
2 changes: 2 additions & 0 deletions redisinsight/ui/src/components/charts/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import DonutChart from './donut-chart'
import AreaChart from './area-chart'
import BarChart from './bar-chart'

export {
DonutChart,
AreaChart,
BarChart,
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
width: 80px;
height: 33px;

z-index: 1;
z-index: 3;

.tooltip,
.declineBtn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import ExpirationGroupsView from './ExpirationGroupsView'

describe('ExpirationGroupsView', () => {
it('should be rendered', async () => {
expect(render(<ExpirationGroupsView data={null} loading={false} />)).toBeTruthy()
expect(render(<ExpirationGroupsView data={null} extrapolation={1} loading={false} />)).toBeTruthy()
})

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

expect(queryByTestId('summary-per-ttl-loading')).toBeInTheDocument()
expect(queryByTestId('analysis-ttl')).not.toBeInTheDocument()
Expand Down
Loading