From f0d92878fce6f9a043e17884c3a1f31fa64fc34e Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 19 Jun 2026 18:05:30 +0530 Subject: [PATCH] ATLAS-5329: Atlas React UI: Long classification names overflow and break the layout in the Classification Distribution --- .../ClassificationDistributionCard.tsx | 23 ++-- .../ClassificationDistributionCard.test.tsx | 113 ++++++++++++++++++ .../__tests__/dashboardChartPalette.test.ts | 112 +++++++++++++++++ .../dashboardChartPalette.ts | 38 +++++- 4 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 dashboard/src/views/DashboardOverview/__tests__/ClassificationDistributionCard.test.tsx create mode 100644 dashboard/src/views/DashboardOverview/__tests__/dashboardChartPalette.test.ts diff --git a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx index 6da4704c780..41b2741be32 100644 --- a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx +++ b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx @@ -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; @@ -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 }) => { @@ -155,18 +162,14 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD ) => { 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 ( ) => { @@ -185,8 +189,9 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD : undefined } > + {isTruncated ? {value} : null} - {value} + {displayLabel} ); diff --git a/dashboard/src/views/DashboardOverview/__tests__/ClassificationDistributionCard.test.tsx b/dashboard/src/views/DashboardOverview/__tests__/ClassificationDistributionCard.test.tsx new file mode 100644 index 00000000000..8e423c16087 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/__tests__/ClassificationDistributionCard.test.tsx @@ -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 }) => ( +
{children}
+ ), + BarChart: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + CartesianGrid: () =>
, + XAxis: () =>
, + YAxis: ({ tick }: { tick?: React.ComponentType> }) => { + const Tick = tick; + if (!Tick) return null; + return ( +
+ + +
+ ); + }, + Tooltip: () =>
, + Bar: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + Cell: () =>
, + LabelList: () =>
, +})); + +describe('ClassificationDistributionCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders short Y-axis labels without truncation or title tooltip', () => { + render( + + + , + ); + + 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( + + + , + ); + + 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); + }); +}); diff --git a/dashboard/src/views/DashboardOverview/__tests__/dashboardChartPalette.test.ts b/dashboard/src/views/DashboardOverview/__tests__/dashboardChartPalette.test.ts new file mode 100644 index 00000000000..11e22f99ae6 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/__tests__/dashboardChartPalette.test.ts @@ -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]), + ); + }); + }); +}); diff --git a/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts index fb012a445b9..ec6c03ea7fb 100644 --- a/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts +++ b/dashboard/src/views/DashboardOverview/dashboardChartPalette.ts @@ -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;