Skip to content
Open
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
Expand Up @@ -38,6 +38,9 @@ import { navigateToSearch, navigateToClassificationSearch } from "@utils/dashboa
import {
CHART_BAR_ACTIVE_BLUE,
CLASSIFICATION_DISTRIBUTION_CHART_MARGIN,
getClassificationYAxisWidth,
isClassificationYAxisLabelTruncated,
truncateClassificationYAxisLabel,
} from "./dashboardChartPalette";

const BAR_COLOR = CHART_BAR_ACTIVE_BLUE;
Expand All @@ -51,6 +54,10 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
const navigate = useNavigate();
const data = getClassificationDistribution(tag, 5);
const associationTotal = useMemo(() => getTagEntityAssociationTotal(tag), [tag]);
const yAxisWidth = useMemo(
() => getClassificationYAxisWidth(data.map((item) => item.name)),
[data],
);

const handleBarClick = useCallback(
(entry: { name: string }) => {
Expand Down Expand Up @@ -155,25 +162,22 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
<YAxis
type="category"
dataKey="name"
width={52}
label={{
value: "Classification",
angle: -90,
position: "left",
offset: 2,
style: { fontSize: 10, fill: "#6c757d", textAnchor: "middle" },
}}
width={yAxisWidth}
tickMargin={4}
tick={(props: Record<string, unknown>) => {
const { x = 0, y = 0, payload } = props;
const p = payload as { value?: string; name?: string } | undefined;
const value = p?.value ?? p?.name ?? (typeof payload === "string" ? payload : "");
const displayLabel = truncateClassificationYAxisLabel(value);
const isTruncated = isClassificationYAxisLabelTruncated(value);
return (
<g
transform={`translate(${x},${y})`}
onClick={() => (value ? handleLabelClick(value) : undefined)}
style={{ cursor: value ? "pointer" : "default" }}
role={value ? "button" : undefined}
tabIndex={value ? 0 : undefined}
aria-label={value || undefined}
onKeyDown={
value
? (e: React.KeyboardEvent<SVGGElement>) => {
Expand All @@ -185,8 +189,9 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
: undefined
}
>
{isTruncated ? <title>{value}</title> : null}
<text x={0} y={0} dy={4} textAnchor="end" fill="#333" fontSize={12}>
{value}
{displayLabel}
</text>
</g>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import ClassificationDistributionCard from '../ClassificationDistributionCard';
import {
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH,
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX,
} from '../dashboardChartPalette';

jest.mock('@utils/Helper', () => ({
numberFormatWithComma: (n: number | string) => String(n),
}));

const mockNavigateToSearch = jest.fn();
const mockNavigateToClassificationSearch = jest.fn();
jest.mock('@utils/dashboardSearchUtils', () => ({
navigateToSearch: (...args: unknown[]) => mockNavigateToSearch(...args),
navigateToClassificationSearch: (...args: unknown[]) =>
mockNavigateToClassificationSearch(...args),
}));

const shortName = 'PII';
const longName = 'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH + 10);
const truncatedLongName = `${'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`;

jest.mock('@utils/metricsUtils', () => ({
getClassificationDistribution: jest.fn(() => [
{ name: shortName, count: 12 },
{ name: longName, count: 8 },
]),
getTagEntityAssociationTotal: jest.fn(() => 20),
}));

jest.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="rc">{children}</div>
),
BarChart: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="bar-chart">{children}</div>
),
CartesianGrid: () => <div data-testid="grid" />,
XAxis: () => <div data-testid="x-axis" />,
YAxis: ({ tick }: { tick?: React.ComponentType<Record<string, unknown>> }) => {
const Tick = tick;
if (!Tick) return null;
return (
<div data-testid="y-axis">
<Tick x={10} y={20} payload={{ value: shortName }} />
<Tick x={10} y={40} payload={{ value: longName }} />
</div>
);
},
Tooltip: () => <div data-testid="tooltip-mock" />,
Bar: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="bar">{children}</div>
),
Cell: () => <div data-testid="cell" />,
LabelList: () => <div data-testid="label-list" />,
}));

describe('ClassificationDistributionCard', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders short Y-axis labels without truncation or title tooltip', () => {
render(
<MemoryRouter>
<ClassificationDistributionCard tag={{}} />
</MemoryRouter>,
);

expect(screen.getByText(shortName)).toBeInTheDocument();
const shortLabelGroup = screen.getByText(shortName).closest('g');
expect(shortLabelGroup?.querySelector('title')).toBeNull();
});

it('truncates long Y-axis labels and exposes full name in SVG title tooltip', () => {
render(
<MemoryRouter>
<ClassificationDistributionCard tag={{}} />
</MemoryRouter>,
);

expect(screen.getByText(truncatedLongName)).toBeInTheDocument();

const truncatedLabelGroup = screen.getByText(truncatedLongName).closest('g');
const titleNode = truncatedLabelGroup?.querySelector('title');
expect(titleNode).not.toBeNull();
expect(titleNode?.textContent).toBe(longName);

const visibleTextNodes = truncatedLabelGroup?.querySelectorAll('text');
expect(visibleTextNodes?.length).toBe(1);
expect(visibleTextNodes?.[0]?.textContent).toBe(truncatedLongName);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH,
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX,
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
getChartYAxisWidth,
getClassificationYAxisWidth,
isChartYAxisLabelTruncated,
isClassificationYAxisLabelTruncated,
truncateChartYAxisLabel,
truncateClassificationYAxisLabel,
} from '../dashboardChartPalette';

describe('dashboardChartPalette', () => {
describe('truncateClassificationYAxisLabel', () => {
it('returns the label unchanged when within max length', () => {
expect(truncateClassificationYAxisLabel('PII')).toBe('PII');
expect(truncateClassificationYAxisLabel('a'.repeat(30))).toBe(
'a'.repeat(30),
);
});

it('truncates to 30 characters and appends ellipsis', () => {
const fullName = 'a'.repeat(45);
expect(truncateClassificationYAxisLabel(fullName)).toBe(
`${'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`,
);
});
});

describe('isClassificationYAxisLabelTruncated', () => {
it('returns false for labels at or below max length', () => {
expect(isClassificationYAxisLabelTruncated('PII')).toBe(false);
expect(isClassificationYAxisLabelTruncated('a'.repeat(30))).toBe(false);
});

it('returns true for labels longer than max length', () => {
expect(isClassificationYAxisLabelTruncated('a'.repeat(31))).toBe(true);
});
});

describe('getClassificationYAxisWidth', () => {
it('returns minimum width when labels are empty', () => {
expect(getClassificationYAxisWidth([])).toBe(
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
);
});

it('returns minimum width for short classification names', () => {
expect(getClassificationYAxisWidth(['PII', 'HIPAA'])).toBe(
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
);
});

it('scales width from truncated display length', () => {
const longLabel = 'a'.repeat(25);
expect(getClassificationYAxisWidth(['PII', longLabel])).toBe(
25 * CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
);
});

it('uses truncated length cap for very long classification names', () => {
const veryLongLabel = 'a'.repeat(80);
const truncatedLength =
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH +
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX.length;
expect(getClassificationYAxisWidth([veryLongLabel])).toBe(
truncatedLength * CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
);
});

it('does not exceed maximum width', () => {
const labels = Array.from({ length: 10 }, () => 'x'.repeat(80));
expect(getClassificationYAxisWidth(labels)).toBeLessThanOrEqual(
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
);
});
});

describe('shared chart Y-axis aliases', () => {
it('exposes the same helpers for service type and classification charts', () => {
const label = 'a'.repeat(45);
expect(truncateChartYAxisLabel(label)).toBe(
truncateClassificationYAxisLabel(label),
);
expect(isChartYAxisLabelTruncated(label)).toBe(
isClassificationYAxisLabelTruncated(label),
);
expect(getChartYAxisWidth([label])).toBe(
getClassificationYAxisWidth([label]),
);
});
});
});
38 changes: 36 additions & 2 deletions dashboard/src/views/DashboardOverview/dashboardChartPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,44 @@ export const HORIZONTAL_BAR_CHART_MARGIN = {
bottom: 48,
} as const;

/** Tighter layout: short classification names — minimize dead space left of Y ticks */
/** Classification bar chart: Y-axis width reserves label space; keep left margin minimal */
export const CLASSIFICATION_DISTRIBUTION_CHART_MARGIN = {
top: 8,
right: 72,
left: 12,
left: 8,
bottom: 48,
} as const;

export const CLASSIFICATION_Y_AXIS_MIN_WIDTH = 160;
export const CLASSIFICATION_Y_AXIS_MAX_WIDTH = 360;
export const CLASSIFICATION_Y_AXIS_CHAR_WIDTH = 8;
export const CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH = 30;
export const CLASSIFICATION_Y_AXIS_LABEL_SUFFIX = '...';

/** Truncate Y-axis classification labels for display (full name shown via SVG title on hover). */
export const truncateClassificationYAxisLabel = (label: string): string => {
if (label.length <= CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH) {
return label;
}
return `${label.slice(0, CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`;
};

export const isClassificationYAxisLabelTruncated = (label: string): boolean =>
label.length > CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH;

/** Estimate Y-axis width from truncated label length (12px font, end-anchored ticks). */
export const getClassificationYAxisWidth = (labels: string[]): number => {
if (!labels.length) return CLASSIFICATION_Y_AXIS_MIN_WIDTH;
const longest = Math.max(
...labels.map((label) => truncateClassificationYAxisLabel(label).length),
);
return Math.min(
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
Math.max(CLASSIFICATION_Y_AXIS_MIN_WIDTH, longest * CLASSIFICATION_Y_AXIS_CHAR_WIDTH),
);
};

/** Shared by classification and service type distribution chart Y-axis ticks */
export const truncateChartYAxisLabel = truncateClassificationYAxisLabel;
export const isChartYAxisLabelTruncated = isClassificationYAxisLabelTruncated;
export const getChartYAxisWidth = getClassificationYAxisWidth;
Loading