diff --git a/packages/manager/.changeset/pr-11145-tech-stories-1729655521664.md b/packages/manager/.changeset/pr-11145-tech-stories-1729655521664.md new file mode 100644 index 00000000000..62836d869a4 --- /dev/null +++ b/packages/manager/.changeset/pr-11145-tech-stories-1729655521664.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add more customization to legends and charts ([#11145](https://github.com/linode/manager/pull/11145)) diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index 7556630c0dd..ea6dd7e08b5 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -75,15 +75,25 @@ interface AreaChartProps { fillOpacity?: number; /** - * maximum height of the chart container + * The height of chart container. */ - height: number; + height?: number; + + /** + * Sets the height of the legend. Overflow scroll if the content exceeds the height. + */ + legendHeight?: string; /** * list of legends rows to be displayed */ legendRows?: Omit; + /** + * The sizes of whitespace around the container. + */ + margin?: { bottom: number; left: number; right: number; top: number }; + /** * true to display legends rows else false to hide * @default false @@ -106,6 +116,11 @@ interface AreaChartProps { */ variant?: 'area' | 'line'; + /** + * The width of chart container. + */ + width?: number; + /** * x-axis properties */ @@ -118,12 +133,15 @@ export const AreaChart = (props: AreaChartProps) => { ariaLabel, data, fillOpacity, - height, + height = '100%', + legendHeight, legendRows, + margin = { bottom: 0, left: -20, right: 0, top: 0 }, showLegend, timezone, unit, variant, + width = '100%', xAxis, } = props; @@ -174,7 +192,7 @@ export const AreaChart = (props: AreaChartProps) => { return null; }; - const CustomLegend = () => { + const CustomLegend = ({ legendHeight }: { legendHeight?: string }) => { if (legendRows) { const legendRowsWithClickHandler = legendRows.map((legendRow) => ({ ...legendRow, @@ -185,6 +203,7 @@ export const AreaChart = (props: AreaChartProps) => { @@ -195,10 +214,16 @@ export const AreaChart = (props: AreaChartProps) => { const accessibleDataKeys = areas.map((area) => area.dataKey); + const legendStyles = { + bottom: 0, + left: 0, + width: '100%', + }; + return ( <> - - <_AreaChart aria-label={ariaLabel} data={data}> + + <_AreaChart aria-label={ariaLabel} data={data} margin={margin}> { handleLegendClick(dataKey as string); } }} - wrapperStyle={{ - left: 25, - }} iconType="square" + wrapperStyle={legendStyles} /> )} {showLegend && legendRows && ( } + content={} + wrapperStyle={legendStyles} /> )} {areas.map(({ color, dataKey }) => ( diff --git a/packages/manager/src/components/AreaChart/utils.test.ts b/packages/manager/src/components/AreaChart/utils.test.ts index 2c513baef42..1b98dd93e9d 100644 --- a/packages/manager/src/components/AreaChart/utils.test.ts +++ b/packages/manager/src/components/AreaChart/utils.test.ts @@ -39,7 +39,8 @@ describe('humanizeLargeData', () => { it('should return the value as an abbreviated string if the value is >= 1000', () => { expect(humanizeLargeData(999)).toBe('999'); expect(humanizeLargeData(1125)).toBe('1.1K'); - expect(humanizeLargeData(231434)).toBe('231.4K'); + expect(humanizeLargeData(55555)).toBe('55.6K'); + expect(humanizeLargeData(231434)).toBe('231K'); expect(humanizeLargeData(1010000)).toBe('1M'); expect(humanizeLargeData(12345678900)).toBe('12.3B'); expect(humanizeLargeData(1543212345678)).toBe('1.5T'); diff --git a/packages/manager/src/components/AreaChart/utils.ts b/packages/manager/src/components/AreaChart/utils.ts index b83a9c0c879..6026169e58a 100644 --- a/packages/manager/src/components/AreaChart/utils.ts +++ b/packages/manager/src/components/AreaChart/utils.ts @@ -27,6 +27,9 @@ export const humanizeLargeData = (value: number) => { if (value >= 1000000) { return +(value / 1000000).toFixed(1) + 'M'; } + if (value >= 100000) { + return +(value / 1000).toFixed(0) + 'K'; + } if (value >= 1000) { return +(value / 1000).toFixed(1) + 'K'; } diff --git a/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts b/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts index ed4f14d9978..9870bd9bc2e 100644 --- a/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts +++ b/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts @@ -25,9 +25,11 @@ export const StyledTableCell = styled(TableCell, { alignItems: 'center', display: 'flex', justifyContent: 'flex-start', + textAlign: 'left', [theme.breakpoints.down('sm')]: { padding: 0, }, + whiteSpace: 'nowrap', }, })); @@ -40,6 +42,7 @@ export const StyledButton = styled(Button, { backgroundColor: hidden ? theme.color.disabledText : theme.graphs[legendColor], + flexShrink: 0, }, }), })); diff --git a/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx b/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx index 531f1755d8d..dd466110b16 100644 --- a/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx @@ -1,9 +1,7 @@ +import { screen } from '@testing-library/react'; import * as React from 'react'; -import { - MetricsDisplay, - metricsBySection, -} from 'src/components/LineGraph/MetricsDisplay'; +import { MetricsDisplay } from 'src/components/LineGraph/MetricsDisplay'; import { formatPercentage } from 'src/utilities/statMetrics'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -119,12 +117,50 @@ describe('CPUMetrics', () => { }); describe('metrics by section', () => { - it('returns expected metric data', () => { - const metrics = { average: 5, last: 8, length: 10, max: 10, total: 80 }; - expect(metricsBySection(metrics)).toHaveLength(3); - expect(metricsBySection(metrics)).toBeInstanceOf(Array); - expect(metricsBySection(metrics)[0]).toEqual(metrics.max); - expect(metricsBySection(metrics)[1]).toEqual(metrics.average); - expect(metricsBySection(metrics)[2]).toEqual(metrics.last); + const sampleMetrics = { average: 5, last: 8, length: 10, max: 10, total: 80 }; + const defaultProps = { + rows: [ + { + data: sampleMetrics, + format: (n: number) => n.toString(), + legendColor: 'blue' as const, + legendTitle: 'Test Metric', + }, + ], + }; + + it('renders metric data in correct order and format', () => { + renderWithTheme(); + + // Check if headers are rendered in correct order + const headers = screen.getAllByRole('columnheader'); + expect(headers[1]).toHaveTextContent('Max'); + expect(headers[2]).toHaveTextContent('Avg'); + expect(headers[3]).toHaveTextContent('Last'); + + // Check if metric values are rendered in correct order + const cells = screen.getAllByRole('cell'); + expect(cells[1]).toHaveTextContent('10'); // max + expect(cells[2]).toHaveTextContent('5'); // average + expect(cells[3]).toHaveTextContent('8'); // last + }); + + it('formats metric values using provided format function', () => { + const formatFn = (n: number) => `${n}%`; + renderWithTheme( + + ); + + const cells = screen.getAllByRole('cell'); + expect(cells[1]).toHaveTextContent('10%'); + expect(cells[2]).toHaveTextContent('5%'); + expect(cells[3]).toHaveTextContent('8%'); }); }); diff --git a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx index 2053aa4aa46..c22a3c375e8 100644 --- a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx @@ -5,7 +5,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { Metrics } from 'src/utilities/statMetrics'; import { StyledButton, @@ -13,8 +12,34 @@ import { StyledTableCell, } from './MetricDisplay.styles'; +import type { Metrics } from 'src/utilities/statMetrics'; + +const ROW_HEADERS = ['Max', 'Avg', 'Last'] as const; + +type MetricKey = 'average' | 'last' | 'max'; +const METRIC_KEYS: MetricKey[] = ['max', 'average', 'last']; + +export type LegendColor = + | 'blue' + | 'darkGreen' + | 'green' + | 'lightGreen' + | 'purple' + | 'red' + | 'yellow'; + interface Props { + /** + * Array of rows to hide. Each row should contain the legend title. + */ hiddenRows?: string[]; + /** + * Sets the height of the legend. Overflow scroll if the content exceeds the height. + */ + legendHeight?: string; + /** + * Array of rows to display. Each row should contain the data to display, the format function to use, the legend color, and the legend title. + */ rows: MetricsDisplayRow[]; } @@ -22,84 +47,100 @@ export interface MetricsDisplayRow { data: Metrics; format: (n: number) => string; handleLegendClick?: () => void; - legendColor: - | 'blue' - | 'darkGreen' - | 'green' - | 'lightGreen' - | 'purple' - | 'red' - | 'yellow'; + legendColor: LegendColor; legendTitle: string; } -export const MetricsDisplay = ({ hiddenRows, rows }: Props) => { - const rowHeaders = ['Max', 'Avg', 'Last']; - const sxProps = { - borderTop: 'none !important', - }; - +const HeaderRow = () => { + const sxProps = { borderTop: 'none !important' }; return ( - - - - {''} - {rowHeaders.map((section, idx) => ( - - {section} - - ))} - - - - {rows.map((row) => { - const { - data, - format, - handleLegendClick, - legendColor, - legendTitle, - } = row; - const hidden = hiddenRows?.includes(legendTitle); + + + + {ROW_HEADERS.map((header) => ( + + {header} + + ))} + + + ); +}; - return ( - - - - - {metricsBySection(data).map((section, idx) => { - return ( - - {format(section)} - - ); - })} - - ); - })} - - +const MetricRow = ({ + hidden, + row, +}: { + hidden?: boolean; + row: MetricsDisplayRow; +}) => { + const { data, format, handleLegendClick, legendColor, legendTitle } = row; + + return ( + + + + + {METRIC_KEYS.map((key, idx) => ( + + {format(data[key])} + + ))} + ); }; -// Grabs the sections we want (max, average, last) and puts them in an array -// so we can map through them and create JSX -export const metricsBySection = (data: Metrics): number[] => [ - data.max, - data.average, - data.last, -]; +export const MetricsDisplay = ({ + hiddenRows = [], + legendHeight = '100%', + rows, +}: Props) => ( + ({ + '.MuiTable-root': { + border: 0, + }, + overflowY: 'auto', + [theme.breakpoints.up(1100)]: { + height: legendHeight, + }, + })} + aria-label="Stats and metrics" + stickyHeader + > + + + {rows.map((row) => ( + + +); export default MetricsDisplay; diff --git a/packages/manager/src/components/Table/Table.tsx b/packages/manager/src/components/Table/Table.tsx index ae2f0c272c0..6f1f4af4336 100644 --- a/packages/manager/src/components/Table/Table.tsx +++ b/packages/manager/src/components/Table/Table.tsx @@ -1,11 +1,10 @@ -import { - default as _Table, - TableProps as _TableProps, -} from '@mui/material/Table'; +import { default as _Table } from '@mui/material/Table'; import * as React from 'react'; import { StyledTableWrapper } from './Table.styles'; +import type { TableProps as _TableProps } from '@mui/material/Table'; + export interface TableProps extends _TableProps { /** Optional additional css class to pass to the component */ className?: string; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index bdc0602ab39..55a3748120b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -118,7 +118,7 @@ const LinodeSummary: React.FC = (props) => { }, []); return ( - + = (props) => { } return ( - + = (props) => { }} ariaLabel="Disk I/O Graph" data={timeData} - height={342} + height={rechartsHeight} showLegend timezone={timezone} unit={' blocks/s'} @@ -332,7 +332,6 @@ const StyledGrid = styled(Grid, { border: `solid 1px ${theme.borderColors.divider}`, marginBottom: theme.spacing(2), padding: theme.spacing(3), - paddingBottom: theme.spacing(2), [theme.breakpoints.up(1100)]: { '&:first-of-type': { marginRight: theme.spacing(2), diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx index ba349ebc9c7..73e9f56c944 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx @@ -190,7 +190,7 @@ const Graph = (props: GraphProps) => { } return ( - +
{summaryCopy}
- +
{summaryCopy}
- +
{summaryCopy}
- + { ); return ( - + { legendTitle: 'Connections', }, ]} + margin={{ + bottom: 0, + left: -15, + right: 0, + top: 0, + }} xAxis={{ tickFormat: 'hh a', tickGap: 60, @@ -176,7 +182,7 @@ export const TablesPanel = () => { } return ( - + { legendTitle: 'Traffic Out', }, ]} + margin={{ + bottom: 0, + left: -15, + right: 0, + top: 0, + }} xAxis={{ tickFormat: 'hh a', tickGap: 60, @@ -255,11 +267,9 @@ const StyledTitle = styled(Typography, { export const StyledBottomLegend = styled('div', { label: 'StyledBottomLegend', -})(({ theme }) => ({ - backgroundColor: theme.bg.offWhite, +})(() => ({ color: '#777', fontSize: 14, - margin: `${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(1)}`, })); const StyledPanel = styled(Paper, {