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
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'rehype-stringify': '<rootDir>/redisinsight/__mocks__/rehypeStringify.js',
'unist-util-visit': '<rootDir>/redisinsight/__mocks__/unistUtilsVisit.js',
'react-children-utilities': '<rootDir>/redisinsight/__mocks__/react-children-utilities.js',
d3: '<rootDir>/node_modules/d3/dist/d3.min.js',
},
setupFiles: [
'<rootDir>/redisinsight/ui/src/setup-env.ts',
Expand All @@ -38,6 +39,11 @@ module.exports = {
transformIgnorePatterns: [
'node_modules/(?!(monaco-editor|react-monaco-editor)/)',
],
// TODO: add tests for plugins
modulePathIgnorePatterns: [
'<rootDir>/redisinsight/ui/src/packages',
'<rootDir>/redisinsight/ui/src/mocks',
],
coverageThreshold: {
global: {
statements: 70,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/axios": "^0.14.0",
"@types/classnames": "^2.2.11",
"@types/d3": "^7.4.0",
"@types/date-fns": "^2.6.0",
"@types/detect-port": "^1.3.0",
"@types/electron-store": "^3.2.0",
Expand Down Expand Up @@ -216,6 +217,7 @@
"buffer": "^6.0.3",
"classnames": "^2.3.1",
"connection-string": "^4.3.2",
"d3": "^7.6.1",
"date-fns": "^2.16.1",
"detect-port": "^1.3.0",
"electron-context-menu": "^3.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react'
import { render, screen, fireEvent } from 'uiSrc/utils/test-utils'

import DonutChart, { ChartData } from './DonutChart'

const mockData: ChartData[] = [
{ value: 1, name: 'A', color: [0, 0, 0] },
{ value: 5, name: 'B', color: [10, 10, 10] },
{ value: 10, name: 'C', color: [20, 20, 20] },
{ value: 2, name: 'D', color: [30, 30, 30] },
{ value: 30, name: 'E', color: [40, 40, 40] },
{ value: 15, name: 'F', color: [50, 50, 50] },
]

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

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

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

it('should render arcs and labels', () => {
render(<DonutChart data={mockData} />)
mockData.forEach(({ value, name }) => {
expect(screen.getByTestId(`arc-${name}-${value}`)).toBeInTheDocument()
expect(screen.getByTestId(`label-${name}-${value}`)).toBeInTheDocument()
})
})

it('should do not render label value if value less than 5%', () => {
render(<DonutChart data={mockData} config={{ percentToShowLabel: 5 }} />)
expect(screen.getByTestId('label-A-1')).toHaveTextContent('')
})

it('should render label value if value more than 5%', () => {
render(<DonutChart data={mockData} config={{ percentToShowLabel: 5 }} />)
expect(screen.getByTestId('label-E-30')).toHaveTextContent('E: 30')
})

it('should call render tooltip and label methods', () => {
const renderLabel = jest.fn()
const renderTooltip = jest.fn()
render(<DonutChart data={mockData} renderLabel={renderLabel} renderTooltip={renderTooltip} />)
expect(renderLabel).toBeCalled()

fireEvent.mouseEnter(screen.getByTestId('arc-A-1'))
expect(renderTooltip).toBeCalled()
})

it('should set tooltip as visible on hover and hidden on leave', () => {
render(<DonutChart data={mockData} />)

fireEvent.mouseEnter(screen.getByTestId('arc-A-1'))
expect(screen.getByTestId('chart-value-tooltip')).toBeVisible()

fireEvent.mouseLeave(screen.getByTestId('arc-A-1'))
expect(screen.getByTestId('chart-value-tooltip')).not.toBeVisible()
})
})
171 changes: 171 additions & 0 deletions redisinsight/ui/src/components/charts/donut-chart/DonutChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import cx from 'classnames'
import * as d3 from 'd3'
import React, { useEffect, useRef } from 'react'
import { truncateNumberToRange } from 'uiSrc/utils'
import { rgb, RGBColor } from 'uiSrc/utils/colors'

import styles from './styles.module.scss'

export interface ChartData {
value: number
name: string
color: RGBColor
}

interface IProps {
name?: string
data: ChartData[]
width?: number
height?: number
title?: React.ReactElement | string
config?: {
percentToShowLabel?: number
arcWidth?: number
margin?: number
radius?: number
}
classNames?: {
chart?: string
arc?: string
arcLabel?: string
arcLabelValue?: string
tooltip?: string
}
renderLabel?: (value: number) => string
renderTooltip?: (value: number) => string
}

const ANIMATION_DURATION_MS = 100

const DonutChart = (props: IProps) => {
const {
name = '',
data,
width = 328,
height = 300,
title,
config,
classNames,
renderLabel,
renderTooltip = (v) => v,
} = props

const margin = config?.margin || 72
const radius = config?.radius || (width / 2 - margin)
const arcWidth = config?.arcWidth || 8
const percentToShowLabel = config?.percentToShowLabel || 5

const svgRef = useRef<SVGSVGElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)

const arc = d3.arc<d3.PieArcDatum<ChartData>>()
.outerRadius(radius)
.innerRadius(radius - arcWidth)

const arcHover = d3.arc<d3.PieArcDatum<ChartData>>()
.outerRadius(radius + 4)
.innerRadius(radius - arcWidth)

const onMouseEnterSlice = (e: MouseEvent, d: d3.PieArcDatum<ChartData>) => {
d3
.select<SVGPathElement, d3.PieArcDatum<ChartData>>(e.target as SVGPathElement)
.transition()
.duration(ANIMATION_DURATION_MS)
.attr('d', arcHover)

if (tooltipRef.current) {
tooltipRef.current.innerHTML = `${d.data.name}: ${renderTooltip(d.value)}`
tooltipRef.current.style.visibility = 'visible'
tooltipRef.current.style.top = `${e.pageY + 15}px`
tooltipRef.current.style.left = `${e.pageX + 15}px`
}
}

const onMouseLeaveSlice = (e: MouseEvent) => {
d3
.select<SVGPathElement, d3.PieArcDatum<ChartData>>(e.target as SVGPathElement)
.transition()
.duration(ANIMATION_DURATION_MS)
.attr('d', arc)

if (tooltipRef.current) {
tooltipRef.current.style.visibility = 'hidden'
}
}

const isShowLabel = (d: d3.PieArcDatum<ChartData>) =>
d.endAngle - d.startAngle > (Math.PI * 2) / (100 / percentToShowLabel)

const getLabelPosition = (d: d3.PieArcDatum<ChartData>) => {
const [x, y] = arc.centroid(d)
const h = Math.sqrt(x * x + y * y)
return `translate(${(x / h) * (radius + 16)}, ${((y + 4) / h) * (radius + 16)})`
}

useEffect(() => {
const pie = d3.pie<ChartData>().value((d: ChartData) => d.value).sort(null)
const dataReady = pie(data)

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

const svg = d3
.select(svgRef.current)
.attr('width', width)
.attr('height', height)
.attr('data-testid', `donut-${name}`)
.attr('class', cx(classNames?.chart))
.append('g')
.attr('transform', `translate(${width / 2},${height / 2})`)

// add arcs
svg
.selectAll()
.data(dataReady)
.enter()
.append('path')
.attr('data-testid', (d) => `arc-${d.data.name}-${d.data.value}`)
.attr('d', arc)
.attr('fill', (d) => rgb(d.data.color))
.attr('class', cx(styles.arc, classNames?.arc))
.on('mouseenter mousemove', onMouseEnterSlice)
.on('mouseleave', onMouseLeaveSlice)

// add labels
svg
.selectAll()
.data(dataReady)
.enter()
.append('text')
.attr('class', cx(styles.chartLabel, classNames?.arcLabel))
.attr('transform', getLabelPosition)
.text((d) => (isShowLabel(d) ? d.data.name : ''))
.attr('data-testid', (d) => `label-${d.data.name}-${d.data.value}`)
.style('text-anchor', (d) => ((d.endAngle + d.startAngle) / 2 > Math.PI ? 'end' : 'start'))
.on('mouseenter mousemove', onMouseEnterSlice)
.on('mouseleave', onMouseLeaveSlice)
.append('tspan')
.text((d) => (isShowLabel(d) ? `: ${renderLabel ? renderLabel(d.value) : truncateNumberToRange(d.value)}` : ''))
.attr('class', cx(styles.chartLabelValue, classNames?.arcLabelValue))
}, [data])

return (
<div className={styles.wrapper}>
<svg ref={svgRef} />
<div
className={cx(styles.tooltip, classNames?.tooltip)}
data-testid="chart-value-tooltip"
ref={tooltipRef}
/>
{title && (
<div className={styles.innerTextContainer}>
{title}
</div>
)}
</div>
)
}

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

export default DonutChart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.wrapper {
position: relative;
}

.innerTextContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

.tooltip {
position: fixed;
background: var(--separatorColor);
color: var(--htmlColor);
padding: 10px;
visibility: hidden;
border-radius: 4px;
z-index: 5;
}

.chartLabel {
fill: var(--euiTextSubduedColor);
font-size: 12px;
font-weight: bold;

.chartLabelValue {
font-weight: normal;
}
}

.arc {
stroke: var(--euiColorLightestShade);
stroke-width: 2px;
cursor: pointer;
}
5 changes: 5 additions & 0 deletions redisinsight/ui/src/components/charts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import DonutChart from './donut-chart'

export {
DonutChart
}
Loading