Skip to content

Commit 8597e43

Browse files
committed
ATLAS-5329: Atlas React UI: Long classification names overflow and break the layout in the Classification Distribution
1 parent 48ff22c commit 8597e43

4 files changed

Lines changed: 275 additions & 11 deletions

File tree

dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import { navigateToSearch, navigateToClassificationSearch } from "@utils/dashboa
3838
import {
3939
CHART_BAR_ACTIVE_BLUE,
4040
CLASSIFICATION_DISTRIBUTION_CHART_MARGIN,
41+
getClassificationYAxisWidth,
42+
isClassificationYAxisLabelTruncated,
43+
truncateClassificationYAxisLabel,
4144
} from "./dashboardChartPalette";
4245

4346
const BAR_COLOR = CHART_BAR_ACTIVE_BLUE;
@@ -51,6 +54,10 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
5154
const navigate = useNavigate();
5255
const data = getClassificationDistribution(tag, 5);
5356
const associationTotal = useMemo(() => getTagEntityAssociationTotal(tag), [tag]);
57+
const yAxisWidth = useMemo(
58+
() => getClassificationYAxisWidth(data.map((item) => item.name)),
59+
[data],
60+
);
5461

5562
const handleBarClick = useCallback(
5663
(entry: { name: string }) => {
@@ -155,25 +162,22 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
155162
<YAxis
156163
type="category"
157164
dataKey="name"
158-
width={52}
159-
label={{
160-
value: "Classification",
161-
angle: -90,
162-
position: "left",
163-
offset: 2,
164-
style: { fontSize: 10, fill: "#6c757d", textAnchor: "middle" },
165-
}}
165+
width={yAxisWidth}
166+
tickMargin={4}
166167
tick={(props: Record<string, unknown>) => {
167168
const { x = 0, y = 0, payload } = props;
168169
const p = payload as { value?: string; name?: string } | undefined;
169170
const value = p?.value ?? p?.name ?? (typeof payload === "string" ? payload : "");
171+
const displayLabel = truncateClassificationYAxisLabel(value);
172+
const isTruncated = isClassificationYAxisLabelTruncated(value);
170173
return (
171174
<g
172175
transform={`translate(${x},${y})`}
173176
onClick={() => (value ? handleLabelClick(value) : undefined)}
174177
style={{ cursor: value ? "pointer" : "default" }}
175178
role={value ? "button" : undefined}
176179
tabIndex={value ? 0 : undefined}
180+
aria-label={value || undefined}
177181
onKeyDown={
178182
value
179183
? (e: React.KeyboardEvent<SVGGElement>) => {
@@ -185,8 +189,9 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD
185189
: undefined
186190
}
187191
>
192+
{isTruncated ? <title>{value}</title> : null}
188193
<text x={0} y={0} dy={4} textAnchor="end" fill="#333" fontSize={12}>
189-
{value}
194+
{displayLabel}
190195
</text>
191196
</g>
192197
);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import React from 'react';
19+
import { render, screen } from '@testing-library/react';
20+
import { MemoryRouter } from 'react-router-dom';
21+
import ClassificationDistributionCard from '../ClassificationDistributionCard';
22+
import {
23+
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH,
24+
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX,
25+
} from '../dashboardChartPalette';
26+
27+
jest.mock('@utils/Helper', () => ({
28+
numberFormatWithComma: (n: number | string) => String(n),
29+
}));
30+
31+
const mockNavigateToSearch = jest.fn();
32+
const mockNavigateToClassificationSearch = jest.fn();
33+
jest.mock('@utils/dashboardSearchUtils', () => ({
34+
navigateToSearch: (...args: unknown[]) => mockNavigateToSearch(...args),
35+
navigateToClassificationSearch: (...args: unknown[]) =>
36+
mockNavigateToClassificationSearch(...args),
37+
}));
38+
39+
const shortName = 'PII';
40+
const longName = 'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH + 10);
41+
const truncatedLongName = `${'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`;
42+
43+
jest.mock('@utils/metricsUtils', () => ({
44+
getClassificationDistribution: jest.fn(() => [
45+
{ name: shortName, count: 12 },
46+
{ name: longName, count: 8 },
47+
]),
48+
getTagEntityAssociationTotal: jest.fn(() => 20),
49+
}));
50+
51+
jest.mock('recharts', () => ({
52+
ResponsiveContainer: ({ children }: { children?: React.ReactNode }) => (
53+
<div data-testid="rc">{children}</div>
54+
),
55+
BarChart: ({ children }: { children?: React.ReactNode }) => (
56+
<div data-testid="bar-chart">{children}</div>
57+
),
58+
CartesianGrid: () => <div data-testid="grid" />,
59+
XAxis: () => <div data-testid="x-axis" />,
60+
YAxis: ({ tick }: { tick?: React.ComponentType<Record<string, unknown>> }) => {
61+
const Tick = tick;
62+
if (!Tick) return null;
63+
return (
64+
<div data-testid="y-axis">
65+
<Tick x={10} y={20} payload={{ value: shortName }} />
66+
<Tick x={10} y={40} payload={{ value: longName }} />
67+
</div>
68+
);
69+
},
70+
Tooltip: () => <div data-testid="tooltip-mock" />,
71+
Bar: ({ children }: { children?: React.ReactNode }) => (
72+
<div data-testid="bar">{children}</div>
73+
),
74+
Cell: () => <div data-testid="cell" />,
75+
LabelList: () => <div data-testid="label-list" />,
76+
}));
77+
78+
describe('ClassificationDistributionCard', () => {
79+
beforeEach(() => {
80+
jest.clearAllMocks();
81+
});
82+
83+
it('renders short Y-axis labels without truncation or title tooltip', () => {
84+
render(
85+
<MemoryRouter>
86+
<ClassificationDistributionCard tag={{}} />
87+
</MemoryRouter>,
88+
);
89+
90+
expect(screen.getByText(shortName)).toBeInTheDocument();
91+
const shortLabelGroup = screen.getByText(shortName).closest('g');
92+
expect(shortLabelGroup?.querySelector('title')).toBeNull();
93+
});
94+
95+
it('truncates long Y-axis labels and exposes full name in SVG title tooltip', () => {
96+
render(
97+
<MemoryRouter>
98+
<ClassificationDistributionCard tag={{}} />
99+
</MemoryRouter>,
100+
);
101+
102+
expect(screen.getByText(truncatedLongName)).toBeInTheDocument();
103+
104+
const truncatedLabelGroup = screen.getByText(truncatedLongName).closest('g');
105+
const titleNode = truncatedLabelGroup?.querySelector('title');
106+
expect(titleNode).not.toBeNull();
107+
expect(titleNode?.textContent).toBe(longName);
108+
109+
const visibleTextNodes = truncatedLabelGroup?.querySelectorAll('text');
110+
expect(visibleTextNodes?.length).toBe(1);
111+
expect(visibleTextNodes?.[0]?.textContent).toBe(truncatedLongName);
112+
});
113+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import {
19+
CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
20+
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH,
21+
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX,
22+
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
23+
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
24+
getChartYAxisWidth,
25+
getClassificationYAxisWidth,
26+
isChartYAxisLabelTruncated,
27+
isClassificationYAxisLabelTruncated,
28+
truncateChartYAxisLabel,
29+
truncateClassificationYAxisLabel,
30+
} from '../dashboardChartPalette';
31+
32+
describe('dashboardChartPalette', () => {
33+
describe('truncateClassificationYAxisLabel', () => {
34+
it('returns the label unchanged when within max length', () => {
35+
expect(truncateClassificationYAxisLabel('PII')).toBe('PII');
36+
expect(truncateClassificationYAxisLabel('a'.repeat(30))).toBe(
37+
'a'.repeat(30),
38+
);
39+
});
40+
41+
it('truncates to 30 characters and appends ellipsis', () => {
42+
const fullName = 'a'.repeat(45);
43+
expect(truncateClassificationYAxisLabel(fullName)).toBe(
44+
`${'a'.repeat(CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`,
45+
);
46+
});
47+
});
48+
49+
describe('isClassificationYAxisLabelTruncated', () => {
50+
it('returns false for labels at or below max length', () => {
51+
expect(isClassificationYAxisLabelTruncated('PII')).toBe(false);
52+
expect(isClassificationYAxisLabelTruncated('a'.repeat(30))).toBe(false);
53+
});
54+
55+
it('returns true for labels longer than max length', () => {
56+
expect(isClassificationYAxisLabelTruncated('a'.repeat(31))).toBe(true);
57+
});
58+
});
59+
60+
describe('getClassificationYAxisWidth', () => {
61+
it('returns minimum width when labels are empty', () => {
62+
expect(getClassificationYAxisWidth([])).toBe(
63+
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
64+
);
65+
});
66+
67+
it('returns minimum width for short classification names', () => {
68+
expect(getClassificationYAxisWidth(['PII', 'HIPAA'])).toBe(
69+
CLASSIFICATION_Y_AXIS_MIN_WIDTH,
70+
);
71+
});
72+
73+
it('scales width from truncated display length', () => {
74+
const longLabel = 'a'.repeat(25);
75+
expect(getClassificationYAxisWidth(['PII', longLabel])).toBe(
76+
25 * CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
77+
);
78+
});
79+
80+
it('uses truncated length cap for very long classification names', () => {
81+
const veryLongLabel = 'a'.repeat(80);
82+
const truncatedLength =
83+
CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH +
84+
CLASSIFICATION_Y_AXIS_LABEL_SUFFIX.length;
85+
expect(getClassificationYAxisWidth([veryLongLabel])).toBe(
86+
truncatedLength * CLASSIFICATION_Y_AXIS_CHAR_WIDTH,
87+
);
88+
});
89+
90+
it('does not exceed maximum width', () => {
91+
const labels = Array.from({ length: 10 }, () => 'x'.repeat(80));
92+
expect(getClassificationYAxisWidth(labels)).toBeLessThanOrEqual(
93+
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
94+
);
95+
});
96+
});
97+
98+
describe('shared chart Y-axis aliases', () => {
99+
it('exposes the same helpers for service type and classification charts', () => {
100+
const label = 'a'.repeat(45);
101+
expect(truncateChartYAxisLabel(label)).toBe(
102+
truncateClassificationYAxisLabel(label),
103+
);
104+
expect(isChartYAxisLabelTruncated(label)).toBe(
105+
isClassificationYAxisLabelTruncated(label),
106+
);
107+
expect(getChartYAxisWidth([label])).toBe(
108+
getClassificationYAxisWidth([label]),
109+
);
110+
});
111+
});
112+
});

dashboard/src/views/DashboardOverview/dashboardChartPalette.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,44 @@ export const HORIZONTAL_BAR_CHART_MARGIN = {
3333
bottom: 48,
3434
} as const;
3535

36-
/** Tighter layout: short classification names — minimize dead space left of Y ticks */
36+
/** Classification bar chart: Y-axis width reserves label space; keep left margin minimal */
3737
export const CLASSIFICATION_DISTRIBUTION_CHART_MARGIN = {
3838
top: 8,
3939
right: 72,
40-
left: 12,
40+
left: 8,
4141
bottom: 48,
4242
} as const;
43+
44+
export const CLASSIFICATION_Y_AXIS_MIN_WIDTH = 160;
45+
export const CLASSIFICATION_Y_AXIS_MAX_WIDTH = 360;
46+
export const CLASSIFICATION_Y_AXIS_CHAR_WIDTH = 8;
47+
export const CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH = 30;
48+
export const CLASSIFICATION_Y_AXIS_LABEL_SUFFIX = '...';
49+
50+
/** Truncate Y-axis classification labels for display (full name shown via SVG title on hover). */
51+
export const truncateClassificationYAxisLabel = (label: string): string => {
52+
if (label.length <= CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH) {
53+
return label;
54+
}
55+
return `${label.slice(0, CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH)}${CLASSIFICATION_Y_AXIS_LABEL_SUFFIX}`;
56+
};
57+
58+
export const isClassificationYAxisLabelTruncated = (label: string): boolean =>
59+
label.length > CLASSIFICATION_Y_AXIS_LABEL_MAX_LENGTH;
60+
61+
/** Estimate Y-axis width from truncated label length (12px font, end-anchored ticks). */
62+
export const getClassificationYAxisWidth = (labels: string[]): number => {
63+
if (!labels.length) return CLASSIFICATION_Y_AXIS_MIN_WIDTH;
64+
const longest = Math.max(
65+
...labels.map((label) => truncateClassificationYAxisLabel(label).length),
66+
);
67+
return Math.min(
68+
CLASSIFICATION_Y_AXIS_MAX_WIDTH,
69+
Math.max(CLASSIFICATION_Y_AXIS_MIN_WIDTH, longest * CLASSIFICATION_Y_AXIS_CHAR_WIDTH),
70+
);
71+
};
72+
73+
/** Shared by classification and service type distribution chart Y-axis ticks */
74+
export const truncateChartYAxisLabel = truncateClassificationYAxisLabel;
75+
export const isChartYAxisLabelTruncated = isClassificationYAxisLabelTruncated;
76+
export const getChartYAxisWidth = getClassificationYAxisWidth;

0 commit comments

Comments
 (0)