diff --git a/redisinsight/ui/src/components/charts/bar-chart/BarChart.spec.tsx b/redisinsight/ui/src/components/charts/bar-chart/BarChart.spec.tsx new file mode 100644 index 0000000000..2c11ecff60 --- /dev/null +++ b/redisinsight/ui/src/components/charts/bar-chart/BarChart.spec.tsx @@ -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()).toBeTruthy() + }) + + it('should render with data', () => { + expect(render()).toBeTruthy() + }) + + it('should not render area with empty data', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('should render svg', () => { + render() + expect(screen.getByTestId('bar-test')).toBeInTheDocument() + }) + + it('should render bars', () => { + render() + mockData.forEach(({ x, y }) => { + expect(screen.getByTestId(`bar-${x}-${y}`)).toBeInTheDocument() + }) + }) + + it('should render tooltip and content inside', async () => { + render() + + 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() + + 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() + + expect(queryByTestId('ytick-51200-8')).not.toBeInTheDocument() + expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toBeInTheDocument() + expect(queryByTestId(`ytick-${lastDataValue?.y}-8`)).toHaveTextContent(`${lastDataValue?.y}`) + }) +}) diff --git a/redisinsight/ui/src/components/charts/bar-chart/BarChart.tsx b/redisinsight/ui/src/components/charts/bar-chart/BarChart.tsx new file mode 100644 index 0000000000..088b949499 --- /dev/null +++ b/redisinsight/ui/src/components/charts/bar-chart/BarChart.tsx @@ -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(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 ( +
+ +
+ ) +} + +export default BarChart diff --git a/redisinsight/ui/src/components/charts/bar-chart/index.ts b/redisinsight/ui/src/components/charts/bar-chart/index.ts new file mode 100644 index 0000000000..8294174392 --- /dev/null +++ b/redisinsight/ui/src/components/charts/bar-chart/index.ts @@ -0,0 +1,5 @@ +import BarChart from './BarChart' + +export * from './BarChart' + +export default BarChart diff --git a/redisinsight/ui/src/components/charts/bar-chart/styles.module.scss b/redisinsight/ui/src/components/charts/bar-chart/styles.module.scss new file mode 100644 index 0000000000..ff0da24e12 --- /dev/null +++ b/redisinsight/ui/src/components/charts/bar-chart/styles.module.scss @@ -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); + } +} diff --git a/redisinsight/ui/src/components/charts/index.ts b/redisinsight/ui/src/components/charts/index.ts index f27948d512..c3418d9e3e 100644 --- a/redisinsight/ui/src/components/charts/index.ts +++ b/redisinsight/ui/src/components/charts/index.ts @@ -1,7 +1,9 @@ import DonutChart from './donut-chart' import AreaChart from './area-chart' +import BarChart from './bar-chart' export { DonutChart, AreaChart, + BarChart, } diff --git a/redisinsight/ui/src/components/inline-item-editor/styles.module.scss b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss index 0b5c389f59..2657157e40 100644 --- a/redisinsight/ui/src/components/inline-item-editor/styles.module.scss +++ b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss @@ -20,7 +20,7 @@ width: 80px; height: 33px; - z-index: 1; + z-index: 3; .tooltip, .declineBtn, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.spec.tsx index 7e81e2082b..81f3d33db6 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.spec.tsx @@ -5,11 +5,11 @@ import ExpirationGroupsView from './ExpirationGroupsView' describe('ExpirationGroupsView', () => { it('should be rendered', async () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should render spinner if loading=true and data=null', async () => { - const { queryByTestId } = render() + const { queryByTestId } = render() expect(queryByTestId('summary-per-ttl-loading')).toBeInTheDocument() expect(queryByTestId('analysis-ttl')).not.toBeInTheDocument() diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.tsx index 453af2ab68..d19d01d872 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/ExpirationGroupsView.tsx @@ -6,8 +6,8 @@ import cx from 'classnames' import { DEFAULT_EXTRAPOLATION } from 'uiSrc/pages/databaseAnalysis' import { extrapolate, formatBytes, formatExtrapolation, Nullable } from 'uiSrc/utils' -import { AreaChart } from 'uiSrc/components/charts' -import { AreaChartData, AreaChartDataType, DEFAULT_MULTIPLIER_GRID } from 'uiSrc/components/charts/area-chart/AreaChart' +import { BarChart } from 'uiSrc/components/charts' +import { BarChartData, BarChartDataType, DEFAULT_BAR_WIDTH, DEFAULT_MULTIPLIER_GRID, DEFAULT_Y_TICKS } from 'uiSrc/components/charts/bar-chart' import { DBAnalysisReportsSelector, setShowNoExpiryGroup } from 'uiSrc/slices/analytics/dbAnalysis' import { DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' import styles from './styles.module.scss' @@ -24,7 +24,7 @@ const ExpirationGroupsView = (props: Props) => { const { totalMemory, totalKeys } = data || {} const { showNoExpiryGroup } = useSelector(DBAnalysisReportsSelector) - const [expirationGroups, setExpirationGroups] = useState([]) + const [expirationGroups, setExpirationGroups] = useState([]) const [isExtrapolated, setIsExtrapolated] = useState(true) const dispatch = useDispatch() @@ -74,6 +74,9 @@ const ExpirationGroupsView = (props: Props) => { return null } + const multiplierGrid = DEFAULT_MULTIPLIER_GRID + const yCountTicks = DEFAULT_Y_TICKS + return (
@@ -110,17 +113,19 @@ const ExpirationGroupsView = (props: Props) => {
{({ width, height }) => ( - 1000 ? 70 : (width < 800 ? 30 : DEFAULT_BAR_WIDTH)} tooltipValidation={(val) => `${formatExtrapolation(formatBytes(val, 3) as string, isExtrapolated)}`} - leftAxiosValidation={(val) => formatBytes(val, 1)} - bottomAxiosValidation={(_val, i) => (i % DEFAULT_MULTIPLIER_GRID ? '' : expirationGroups[i / DEFAULT_MULTIPLIER_GRID]?.xlabel)} + leftAxiosValidation={(val, i) => (i % 2 ? '' : formatBytes(val, 1))} + bottomAxiosValidation={(_val, i) => expirationGroups[i / multiplierGrid]?.xlabel} /> )} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/styles.module.scss index a2ac5ca48a..e04ca43e16 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-ttl-view/styles.module.scss @@ -1,5 +1,6 @@ .container { position: relative; + padding-right: 0; @media screen and (max-width: 920px) { :global(.section-title-wrapper) { @@ -42,6 +43,7 @@ .switch { float: right; + padding-right: 20px; :global(.euiSwitch__label) { font-size: 13px !important; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 48a77ce13a..bc4767f06b 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -16,6 +16,7 @@ --tableRowHoverColor: #{$tableRowHoverColor}; --tableRowSelectedColor: #{$tableRowSelectedColor}; --euiColorPrimary: #{$euiColorPrimary}; + --euiColorPrimaryRGB: #{$euiColorPrimaryRGB}; --euiColorSecondary: #{$euiColorSecondary}; --euiColorSuccess: #{$euiColorSuccess}; --euiColorAccent: #{$euiColorAccent}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 9e0a43444a..31d57da5a3 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -1,4 +1,5 @@ $euiColorPrimary: #8ba2ff; +$euiColorPrimaryRGB: 139, 162, 255; $euiColorPrimaryText: #ffffff; $euiColorSecondary: #465282; $euiColorSecondaryText: #ffffff; @@ -109,7 +110,7 @@ $groupScriptingColor: #5d141c; $groupTransactionsColor: #14708d; $groupServerColor: #000000; $groupHyperLolLogColor: #3f4b5f; -$defaultTypeColor: #AA4E4E; +$defaultTypeColor: #aa4e4e; // JSON colors $jsonKeyNameColor: #c678dd; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index 7999eaa9bf..1bbeba18bb 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -17,6 +17,7 @@ --tableRowHoverColor: #{$tableRowHoverColor}; --tableRowSelectedColor: #{$tableRowSelectedColor}; --euiColorPrimary: #{$euiColorPrimary}; + --euiColorPrimaryRGB: #{$euiColorPrimaryRGB}; --euiColorSecondary: #{$euiColorSecondary}; --euiColorSuccess: #{$euiColorSuccess}; --euiColorSecondaryText: #{$euiColorSecondaryText}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index 73e6a601aa..eff4e433a6 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -1,4 +1,5 @@ $euiColorPrimary: #3953c3; +$euiColorPrimaryRGB: 57,83,195; $euiColorPrimaryText: #ffffff; $euiColorSecondary: #243dac; $euiColorSecondaryText: #ffffff;