From 61813683bc1cdc1651a908b12795cadff03f041f Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 12 Aug 2026 11:49:56 +0530 Subject: [PATCH 1/4] ATLAS-5373: Atlas React UI: Extremely long entity names break layout in Latest Entities Created widget --- .../__tests__/muiComponents.test.tsx | 34 ++++++ dashboard/src/components/muiComponents.tsx | 67 +++++++++- .../DashboardOverview/LatestEntitiesList.scss | 100 +++++++++++++++ .../DashboardOverview/LatestEntitiesList.tsx | 114 +++++++----------- .../__tests__/LatestEntitiesList.test.tsx | 102 +++++++++++++--- 5 files changed, 330 insertions(+), 87 deletions(-) create mode 100644 dashboard/src/views/DashboardOverview/LatestEntitiesList.scss diff --git a/dashboard/src/components/__tests__/muiComponents.test.tsx b/dashboard/src/components/__tests__/muiComponents.test.tsx index e98b8667920..b6bdb2c0f3c 100644 --- a/dashboard/src/components/__tests__/muiComponents.test.tsx +++ b/dashboard/src/components/__tests__/muiComponents.test.tsx @@ -26,6 +26,7 @@ import userEvent from '@testing-library/user-event' import { CustomButton, LightTooltip, + OverflowTooltip, LinkTab, Accordion, AccordionSummary, @@ -69,6 +70,39 @@ describe('muiComponents', () => { expect(screen.getByText('Tooltip Child')).toBeTruthy() }) + describe('OverflowTooltip', () => { + it('renders OverflowTooltip children', () => { + render( + + Overflow Child + + ) + expect(screen.getByText('Overflow Child')).toBeTruthy() + }) + + it('disables tooltip when not overflowed', () => { + render( + + Short + + ) + // Element scrollWidth and clientWidth are 0 in JSDOM, so isOverflowed is false + // We can verify it renders successfully and handles the basic case + expect(screen.getByTestId('short-text')).toBeInTheDocument() + }) + + it('enables tooltip on resize if overflow occurs', () => { + render( + + Will be long + + ) + // Trigger resize + window.dispatchEvent(new Event('resize')) + expect(screen.getByTestId('resize-text')).toBeInTheDocument() + }) + }) + it('prevents default navigation in LinkTab', async () => { const preventDefault = jest.fn() render( diff --git a/dashboard/src/components/muiComponents.tsx b/dashboard/src/components/muiComponents.tsx index 12ee53af455..60a94db15f9 100644 --- a/dashboard/src/components/muiComponents.tsx +++ b/dashboard/src/components/muiComponents.tsx @@ -22,6 +22,7 @@ import Switch from "@mui/material/Switch"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; +import React from "react"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Button from "@mui/material/Button"; @@ -68,6 +69,69 @@ const LightTooltip = styled(({ className, ...props }: any) => ( } })); +import { TooltipProps } from '@mui/material/Tooltip'; +import { SxProps, Theme } from '@mui/material/styles'; + +interface OverflowTooltipProps extends Omit { + children: React.ReactElement; + wrapperComponent?: React.ElementType; + wrapperSx?: SxProps; +} + +const OverflowTooltip = ({ title, children, wrapperComponent, wrapperSx, ...props }: OverflowTooltipProps) => { + const textElementRef = React.useRef(null); + const [isOverflowed, setIsOverflowed] = React.useState(false); + + const checkOverflow = () => { + if (textElementRef.current) { + setIsOverflowed( + textElementRef.current.scrollWidth > textElementRef.current.clientWidth + ); + } + }; + + React.useEffect(() => { + checkOverflow(); + window.addEventListener("resize", checkOverflow); + return () => { + window.removeEventListener("resize", checkOverflow); + }; + }, [children, title]); + + const child = wrapperComponent || wrapperSx ? ( + + {children} + + ) : ( + React.cloneElement(children, { ref: textElementRef }) + ); + + return ( + + {child} + + ); +}; + interface ButtonProps { children?: any; variant?: string; @@ -202,5 +266,6 @@ export { CustomButton, Accordion, AccordionSummary, - AccordionDetails + AccordionDetails, + OverflowTooltip }; diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss new file mode 100644 index 00000000000..091a354be58 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss @@ -0,0 +1,100 @@ +/* + * 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. + */ + +.latest-entities-paper { + padding: 16px; + border-radius: 8px; + min-height: 340px; + min-width: 0; + width: 100%; + flex: 1; + box-sizing: border-box; + transition: box-shadow 0.3s ease; + + &:hover { + box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); + } +} + +.latest-entities-header { + padding-bottom: 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + +.latest-entities-title { + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; +} + +.latest-entities-view-all { + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; +} + +.latest-entities-empty { + padding-top: 16px; +} + +.latest-entities-list { + padding-top: 16px; +} + +.latest-entities-list-item { + padding-top: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + + &:last-child { + border-bottom: none; + } +} + +.latest-entities-entity-name { + display: block; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; +} + +.latest-entities-entity-name-link { + cursor: pointer; +} + +.latest-entities-entity-name-fallback { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); +} + +.latest-entities-type-name { + font-size: 0.875rem; + color: #6c757d; + flex-shrink: 0; + margin-left: 4px; +} + +.latest-entities-timestamp { + font-size: 0.8125rem; + color: #6c757d; + flex-shrink: 0; + margin-left: 8px; + white-space: nowrap; +} diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx index 2d80a5a1fa0..4cc405acd5f 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx @@ -17,6 +17,7 @@ import { memo, useCallback } from "react"; import { Paper, Stack, Typography, Link, List, ListItem, Box } from "@mui/material"; +import { OverflowTooltip } from "@components/muiComponents"; import { Link as RouterLink } from "react-router-dom"; import moment from "moment"; import { useNavigate } from "react-router-dom"; @@ -27,6 +28,7 @@ import { resolveLatestEntityGuid, resolveLatestEntityTypeName } from "./latestEntitiesList.utils"; +import "./LatestEntitiesList.scss"; interface EntityItem extends LatestEntityRowModel { createTime?: number | Date | string; @@ -104,30 +106,30 @@ const formatCreatedRelativeFromMs = (ms: number): string => { const deltaMs = now - ms; if (!Number.isFinite(deltaMs)) return INVALID_TS_LABEL; if (deltaMs < 0) { - return `Created ${moment(ms).fromNow()}`; + return moment(ms).fromNow(); } const totalSec = Math.floor(deltaMs / 1000); if (totalSec < 1) { - return "Created just now"; + return "Just now"; } if (totalSec < 60) { return totalSec === 1 - ? "Created 1 second ago" - : `Created ${totalSec} seconds ago`; + ? "1 second ago" + : `${totalSec} seconds ago`; } const totalMin = Math.floor(totalSec / 60); if (totalMin < 60) { return totalMin === 1 - ? "Created 1 minute ago" - : `Created ${totalMin} minutes ago`; + ? "1 minute ago" + : `${totalMin} minutes ago`; } const totalHr = Math.floor(totalMin / 60); if (totalHr < 24) { return totalHr === 1 - ? "Created 1 hour ago" - : `Created ${totalHr} hours ago`; + ? "1 hour ago" + : `${totalHr} hours ago`; } - return `Created ${moment(ms).fromNow()}`; + return moment(ms).fromNow(); }; const formatRelativeTime = (raw: unknown): string => { @@ -146,54 +148,37 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL if (isLoading) return null; return ( - - + + - + Latest Entities Created View All {error ? ( - + {error} ) : !entities || entities.length === 0 ? ( - + No recent entities ) : ( - + {entities.slice(0, 7).map((entity) => { const displayName = resolveLatestEntityDisplayName(entity); const entityGuid = resolveLatestEntityGuid(entity); @@ -207,55 +192,40 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL - + {detailHref ? ( - - {displayName} - + + + {displayName} + + ) : ( - - {displayName} - + + + {displayName} + + )} ({typeName}) - + {formatRelativeTime(timestamp)} diff --git a/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx b/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx index 4ed368f0306..597ca6b972b 100644 --- a/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx +++ b/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx @@ -187,9 +187,7 @@ describe('LatestEntitiesList', () => { ) const row = screen.getByText('X').closest('li') expect(row).toBeTruthy() - expect( - within(row as HTMLElement).getByText(/^Created /), - ).toBeInTheDocument() + expect(row).toBeTruthy() }) it('shows Created today for unusable timestamp', () => { @@ -227,7 +225,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created just now')).toBeInTheDocument() + expect(screen.getByText('Just now')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -244,7 +242,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 30 seconds ago')).toBeInTheDocument() + expect(screen.getByText('30 seconds ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -261,7 +259,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 1 minute ago')).toBeInTheDocument() + expect(screen.getByText('1 minute ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -278,7 +276,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 2 minutes ago')).toBeInTheDocument() + expect(screen.getByText('2 minutes ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -295,7 +293,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 1 hour ago')).toBeInTheDocument() + expect(screen.getByText('1 hour ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -312,7 +310,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 2 hours ago')).toBeInTheDocument() + expect(screen.getByText('2 hours ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -330,7 +328,7 @@ describe('LatestEntitiesList', () => { , ) const li = screen.getByText('A').closest('li') as HTMLElement - expect(within(li).getByText(/Created in /)).toBeInTheDocument() + expect(within(li).getByText(/in /)).toBeInTheDocument() }) it('normalizeEntityTimestampMs: $numberLong and longValue wrappers', () => { @@ -536,7 +534,7 @@ describe('LatestEntitiesList', () => { ) expect( within(screen.getByText('Old').closest('li') as HTMLElement).getByText( - /^Created /, + /ago/, ), ).toBeInTheDocument() }) @@ -558,7 +556,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('Created 1 second ago')).toBeInTheDocument() + expect(screen.getByText('1 second ago')).toBeInTheDocument() }) it('uses Created today when Date.now is non-finite for relative time', () => { @@ -578,7 +576,7 @@ describe('LatestEntitiesList', () => { , ) expect( - within(screen.getByRole('listitem')).getByText(/^Created /), + within(screen.getByRole('listitem')).getByText('Created today'), ).toHaveTextContent('Created today') spy.mockRestore() }) @@ -602,7 +600,7 @@ describe('LatestEntitiesList', () => { ) const row = screen.getByText('Historic').closest('li') as HTMLElement expect( - within(row).getByText(/^Created /).textContent, + within(row).getByText(/ago/).textContent, ).not.toMatch(/^Created today$/) }) @@ -624,4 +622,80 @@ describe('LatestEntitiesList', () => { ) expect(screen.getByText('Created today')).toBeInTheDocument() }) + + it('renders fallback Typography when detailHref is absent (no guid)', () => { + render( + + + , + ) + const fallbackText = screen.getByText('EntityWithoutGuid') + expect(fallbackText).toBeInTheDocument() + expect(fallbackText.tagName).toBe('SPAN') + expect(fallbackText).toHaveClass('latest-entities-entity-name-fallback') + }) + + it('slices to exactly 7 items even if more are provided', () => { + const tenEntities = Array.from({ length: 10 }).map((_, i) => ({ + guid: `g${i}`, + name: `Entity ${i}`, + typeName: 'T', + attributes: { __timestamp: Date.now() }, + } as any)) + + render( + + + , + ) + + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(7) + }) + + it('renders extremely long entity name without crashing', () => { + const longName = 'A'.repeat(500) + render( + + + , + ) + const link = screen.getByRole('link', { name: longName }) + expect(link).toBeInTheDocument() + expect(link.textContent).toBe(longName) + }) + + it('renders gracefully when typeName is missing', () => { + render( + + + , + ) + expect(screen.getByText('(Entity)')).toBeInTheDocument() + }) }) From ba701dfae1ee49e43c491e0d9fb1a096cb0de80c Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Thu, 20 Aug 2026 16:08:13 +0530 Subject: [PATCH 2/4] ATLAS-5373: Atlas React UI: Extremely long entity names break layout in Latest Entities Created widget --- dashboard/src/components/muiComponents.tsx | 13 ++++------ .../DashboardOverview/LatestEntitiesList.scss | 14 +++------- .../DashboardOverview/LatestEntitiesList.tsx | 20 +++++++------- .../__tests__/LatestEntitiesList.test.tsx | 26 +++++-------------- 4 files changed, 26 insertions(+), 47 deletions(-) diff --git a/dashboard/src/components/muiComponents.tsx b/dashboard/src/components/muiComponents.tsx index 60a94db15f9..ac6b3f8c066 100644 --- a/dashboard/src/components/muiComponents.tsx +++ b/dashboard/src/components/muiComponents.tsx @@ -52,6 +52,8 @@ import MuiAccordionSummary, { AccordionSummaryProps } from "@mui/material/AccordionSummary"; import MuiAccordionDetails from "@mui/material/AccordionDetails"; +import { TooltipProps } from '@mui/material/Tooltip'; +import { SxProps, Theme } from '@mui/material/styles'; const LightTooltip = styled(({ className, ...props }: any) => ( ( } })); -import { TooltipProps } from '@mui/material/Tooltip'; -import { SxProps, Theme } from '@mui/material/styles'; interface OverflowTooltipProps extends Omit { children: React.ReactElement; @@ -90,15 +90,15 @@ const OverflowTooltip = ({ title, children, wrapperComponent, wrapperSx, ...prop } }; - React.useEffect(() => { + React.useLayoutEffect(() => { checkOverflow(); window.addEventListener("resize", checkOverflow); return () => { window.removeEventListener("resize", checkOverflow); }; - }, [children, title]); + }, [title]); - const child = wrapperComponent || wrapperSx ? ( + const child = ( {children} - ) : ( - React.cloneElement(children, { ref: textElementRef }) ); return ( diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss index 091a354be58..74c5950388b 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss @@ -38,7 +38,7 @@ .latest-entities-title { font-size: 1rem; font-weight: 600; - color: #1a1a1a; + color: rgba(0, 0, 0, 0.87); } .latest-entities-view-all { @@ -66,13 +66,7 @@ } .latest-entities-entity-name { - display: block; font-size: 0.875rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 0 1 auto; - min-width: 0; } .latest-entities-entity-name-link { @@ -86,14 +80,12 @@ .latest-entities-type-name { font-size: 0.875rem; - color: #6c757d; - flex-shrink: 0; - margin-left: 4px; + color: rgba(0, 0, 0, 0.6); } .latest-entities-timestamp { font-size: 0.8125rem; - color: #6c757d; + color: rgba(0, 0, 0, 0.6); flex-shrink: 0; margin-left: 8px; white-space: nowrap; diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx index 4cc405acd5f..9596840f67e 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx @@ -159,7 +159,7 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL onClick={handleViewAll} className="latest-entities-view-all" aria-label="View all entities" - color="primary.main" + color="primary" > View All @@ -197,7 +197,7 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL {detailHref ? ( - + ) : ( - + )} - - ({typeName}) - + + + ({typeName}) + + {formatRelativeTime(timestamp)} diff --git a/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx b/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx index 597ca6b972b..88afa7e0681 100644 --- a/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx +++ b/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx @@ -185,9 +185,9 @@ describe('LatestEntitiesList', () => { /> , ) - const row = screen.getByText('X').closest('li') - expect(row).toBeTruthy() + const row = screen.getByText('X').closest('li') as HTMLElement expect(row).toBeTruthy() + expect(within(row).getByText(/ago/)).toBeInTheDocument() }) it('shows Created today for unusable timestamp', () => { @@ -643,23 +643,6 @@ describe('LatestEntitiesList', () => { expect(fallbackText).toHaveClass('latest-entities-entity-name-fallback') }) - it('slices to exactly 7 items even if more are provided', () => { - const tenEntities = Array.from({ length: 10 }).map((_, i) => ({ - guid: `g${i}`, - name: `Entity ${i}`, - typeName: 'T', - attributes: { __timestamp: Date.now() }, - } as any)) - - render( - - - , - ) - - const listItems = screen.getAllByRole('listitem') - expect(listItems).toHaveLength(7) - }) it('renders extremely long entity name without crashing', () => { const longName = 'A'.repeat(500) @@ -680,6 +663,11 @@ describe('LatestEntitiesList', () => { const link = screen.getByRole('link', { name: longName }) expect(link).toBeInTheDocument() expect(link.textContent).toBe(longName) + + const span = link.parentElement + expect(span).toHaveStyle('overflow: hidden') + expect(span).toHaveStyle('text-overflow: ellipsis') + expect(span).toHaveStyle('white-space: nowrap') }) it('renders gracefully when typeName is missing', () => { From f1e77ebe15f364dd9d1dc89c387453e90f9e153b Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 21 Aug 2026 11:30:42 +0530 Subject: [PATCH 3/4] ATLAS-5373: Atlas React UI: Extremely long entity names break layout in Latest Entities Created widget --- .../__tests__/muiComponents.test.tsx | 59 +++++++++++--- dashboard/src/components/muiComponents.tsx | 80 ++++++++----------- .../DashboardOverview/LatestEntitiesList.tsx | 9 ++- .../__tests__/LatestEntitiesList.test.tsx | 6 +- 4 files changed, 92 insertions(+), 62 deletions(-) diff --git a/dashboard/src/components/__tests__/muiComponents.test.tsx b/dashboard/src/components/__tests__/muiComponents.test.tsx index b6bdb2c0f3c..a82d2639c6b 100644 --- a/dashboard/src/components/__tests__/muiComponents.test.tsx +++ b/dashboard/src/components/__tests__/muiComponents.test.tsx @@ -21,7 +21,7 @@ */ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { CustomButton, @@ -71,6 +71,24 @@ describe('muiComponents', () => { }) describe('OverflowTooltip', () => { + let triggerResize: any + const originalResizeObserver = global.ResizeObserver + + beforeAll(() => { + global.ResizeObserver = class { + constructor(callback: any) { + triggerResize = callback + } + observe = jest.fn() + unobserve = jest.fn() + disconnect = jest.fn() + } as any + }) + + afterAll(() => { + global.ResizeObserver = originalResizeObserver + }) + it('renders OverflowTooltip children', () => { render( @@ -80,26 +98,49 @@ describe('muiComponents', () => { expect(screen.getByText('Overflow Child')).toBeTruthy() }) - it('disables tooltip when not overflowed', () => { + it('disables tooltip when not overflowed', async () => { render( Short ) - // Element scrollWidth and clientWidth are 0 in JSDOM, so isOverflowed is false - // We can verify it renders successfully and handles the basic case - expect(screen.getByTestId('short-text')).toBeInTheDocument() + const span = screen.getByTestId('short-text').parentElement! + + // Mock no overflow + Object.defineProperty(span, 'scrollWidth', { configurable: true, value: 100 }) + Object.defineProperty(span, 'clientWidth', { configurable: true, value: 100 }) + + act(() => { + if (triggerResize) triggerResize() + }) + + fireEvent.mouseOver(span) + + // Tooltip should not be in the document + expect(screen.queryByText('overflow tip')).not.toBeInTheDocument() }) - it('enables tooltip on resize if overflow occurs', () => { + it('enables tooltip on resize if overflow occurs', async () => { render( Will be long ) - // Trigger resize - window.dispatchEvent(new Event('resize')) - expect(screen.getByTestId('resize-text')).toBeInTheDocument() + const span = screen.getByTestId('resize-text').parentElement! + + // Mock overflow condition + Object.defineProperty(span, 'scrollWidth', { configurable: true, value: 200 }) + Object.defineProperty(span, 'clientWidth', { configurable: true, value: 100 }) + + // Trigger resize observer callback + act(() => { + if (triggerResize) triggerResize() + }) + + fireEvent.mouseOver(span) + + // Tooltip should appear + expect(await screen.findByText('overflow tip')).toBeInTheDocument() }) }) diff --git a/dashboard/src/components/muiComponents.tsx b/dashboard/src/components/muiComponents.tsx index ac6b3f8c066..ae786224149 100644 --- a/dashboard/src/components/muiComponents.tsx +++ b/dashboard/src/components/muiComponents.tsx @@ -52,8 +52,8 @@ import MuiAccordionSummary, { AccordionSummaryProps } from "@mui/material/AccordionSummary"; import MuiAccordionDetails from "@mui/material/AccordionDetails"; -import { TooltipProps } from '@mui/material/Tooltip'; -import { SxProps, Theme } from '@mui/material/styles'; +import { TooltipProps } from "@mui/material/Tooltip"; +import { SxProps, Theme } from "@mui/material/styles"; const LightTooltip = styled(({ className, ...props }: any) => ( ( })); -interface OverflowTooltipProps extends Omit { +interface OverflowTooltipProps extends Omit { children: React.ReactElement; - wrapperComponent?: React.ElementType; wrapperSx?: SxProps; } -const OverflowTooltip = ({ title, children, wrapperComponent, wrapperSx, ...props }: OverflowTooltipProps) => { +const OverflowTooltip = ({ title, children, wrapperSx, ...props }: OverflowTooltipProps) => { const textElementRef = React.useRef(null); const [isOverflowed, setIsOverflowed] = React.useState(false); - const checkOverflow = () => { + const checkOverflow = React.useCallback(() => { if (textElementRef.current) { setIsOverflowed( textElementRef.current.scrollWidth > textElementRef.current.clientWidth ); } - }; + }, []); - React.useLayoutEffect(() => { + React.useEffect(() => { checkOverflow(); - window.addEventListener("resize", checkOverflow); - return () => { - window.removeEventListener("resize", checkOverflow); - }; - }, [title]); + const element = textElementRef.current; + if (element) { + const resizeObserver = new ResizeObserver(() => checkOverflow()); + resizeObserver.observe(element); + return () => resizeObserver.disconnect(); + } + }, [title, children, checkOverflow]); const child = ( ({ + fontWeight: "600 !important", + letterSpacing: "0 !important", + fontSize: "0.875rem !important", + cursor: "pointer !important", + minWidth: "unset !important", + ...(variant === "outlined" && { border: "1px solid #dddddd !important" }) +})); + const CustomButton = ({ children, - variant, - color, - sx: customStyles = {}, - onClick, - size, - endIcon, - startIcon, - disabled, + sx, ...rest }: ButtonProps | any) => { - let defaultStyles = { - fontWeight: "600 !important", - letterSpacing: "0 !important", - fontSize: "0.875rem !important", - cursor: "pointer !important", - minWidth: "unset !important", - ...(variant == "outlined" && { border: "1px solid #dddddd !important" }) - }; - - let mergedStyle = { ...defaultStyles, ...customStyles }; - return ( - - - + + ); }; diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx index 9596840f67e..b56690460ec 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx @@ -138,6 +138,9 @@ const formatRelativeTime = (raw: unknown): string => { return formatCreatedRelativeFromMs(ms); }; +const nameWrapperSx = { display: "block", flex: "0 1 auto", width: "auto" }; +const typeWrapperSx = { display: "block", flex: "0 0 auto", width: "auto" }; + const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesListProps) => { const navigate = useNavigate(); @@ -197,7 +200,7 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL {detailHref ? ( - + ) : ( - + )} - + { name: 'EntityWithoutGuid', typeName: 'T', attributes: { __timestamp: Date.now() }, - } as any, + }, ]} /> , @@ -655,7 +655,7 @@ describe('LatestEntitiesList', () => { name: longName, typeName: 'T', attributes: { __timestamp: Date.now() }, - } as any, + }, ]} /> , @@ -679,7 +679,7 @@ describe('LatestEntitiesList', () => { guid: 'g1', name: 'NamelessType', attributes: { __timestamp: Date.now() }, - } as any, + }, ]} /> , From 0b2f6580c604674230545a48587c7eddb4482397 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Mon, 24 Aug 2026 15:23:09 +0530 Subject: [PATCH 4/4] ATLAS-5373: Atlas React UI: Extremely long entity names break layout in Latest Entities Created widget --- .../GlobalSearch/AdvancedSearch.tsx | 3 +- dashboard/src/components/Modal.tsx | 5 +-- .../components/ShowMore/ShowMoreDrawer.tsx | 6 +-- .../__tests__/muiComponents.test.tsx | 20 ++++++++++ dashboard/src/components/muiComponents.tsx | 40 ++++++++----------- .../BusinessMetadata/BusinessMetadataForm.tsx | 2 +- .../BusinessMetadata/EnumCreateUpdate.tsx | 2 +- .../DashboardOverview/LatestEntitiesList.scss | 15 ++++++- .../DashboardOverview/LatestEntitiesList.tsx | 27 ++++++------- .../__tests__/LatestEntitiesList.test.tsx | 14 +++---- .../BusinessMetadataDetailsLayout.tsx | 2 +- 11 files changed, 78 insertions(+), 58 deletions(-) diff --git a/dashboard/src/components/GlobalSearch/AdvancedSearch.tsx b/dashboard/src/components/GlobalSearch/AdvancedSearch.tsx index 0e9e854df06..3d98c0ec883 100644 --- a/dashboard/src/components/GlobalSearch/AdvancedSearch.tsx +++ b/dashboard/src/components/GlobalSearch/AdvancedSearch.tsx @@ -394,9 +394,8 @@ const AdvancedSearch: React.FC = ({ variant="outlined" color="success" aria-label="reset" - primary={true} size="small" - onClick={(e: Event) => { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleClearValue(); }} diff --git a/dashboard/src/components/Modal.tsx b/dashboard/src/components/Modal.tsx index e91b16d85b9..5c551bd93b4 100644 --- a/dashboard/src/components/Modal.tsx +++ b/dashboard/src/components/Modal.tsx @@ -175,7 +175,7 @@ export const CustomModal: React.FC = ({ variant="outlined" color="primary" disabled={isLoading} - onClick={(e: Event) => { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); if (isLoading) { return; @@ -196,7 +196,6 @@ export const CustomModal: React.FC = ({ ? "Action in progress, please wait" : "Confirm dialog action" } - primary={true} disabled={primaryDisabled} sx={{ minWidth: isLoading ? 88 : undefined, @@ -232,7 +231,7 @@ export const CustomModal: React.FC = ({ /> ) : undefined } - onClick={(e: Event) => { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); if (isLoading) { return; diff --git a/dashboard/src/components/ShowMore/ShowMoreDrawer.tsx b/dashboard/src/components/ShowMore/ShowMoreDrawer.tsx index ae7fc53c1b3..0b84bc85dbe 100644 --- a/dashboard/src/components/ShowMore/ShowMoreDrawer.tsx +++ b/dashboard/src/components/ShowMore/ShowMoreDrawer.tsx @@ -119,8 +119,7 @@ const ShowMoreDrawer = ({ variant="text" color="primary" aria-label="save" - primary={true} - onClick={(e: Event) => { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); dispatch(toggleDrawer()); }} @@ -132,8 +131,7 @@ const ShowMoreDrawer = ({ variant="text" color="primary" aria-label="close" - primary={true} - onClick={(e: Event) => { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); dispatch(toggleDrawer()); }} diff --git a/dashboard/src/components/__tests__/muiComponents.test.tsx b/dashboard/src/components/__tests__/muiComponents.test.tsx index a82d2639c6b..be69b32e324 100644 --- a/dashboard/src/components/__tests__/muiComponents.test.tsx +++ b/dashboard/src/components/__tests__/muiComponents.test.tsx @@ -142,6 +142,26 @@ describe('muiComponents', () => { // Tooltip should appear expect(await screen.findByText('overflow tip')).toBeInTheDocument() }) + + it('enables tooltip for subpixel overflow where clientWidth matches scrollWidth', async () => { + render( + + Subpixel + + ) + const span = screen.getByTestId('subpixel-text').parentElement! + + // Mock subpixel overflow condition (scrollWidth matches clientWidth, but rect is smaller) + Object.defineProperty(span, 'scrollWidth', { configurable: true, value: 100 }) + Object.defineProperty(span, 'clientWidth', { configurable: true, value: 100 }) + span.getBoundingClientRect = jest.fn(() => ({ width: 99.5 } as DOMRect)) + + // Trigger hover to fire the onMouseEnter checkOverflow logic + fireEvent.mouseEnter(span) + fireEvent.mouseOver(span) + + expect(await screen.findByText('subpixel tip')).toBeInTheDocument() + }) }) it('prevents default navigation in LinkTab', async () => { diff --git a/dashboard/src/components/muiComponents.tsx b/dashboard/src/components/muiComponents.tsx index ae786224149..ac6294cd1e5 100644 --- a/dashboard/src/components/muiComponents.tsx +++ b/dashboard/src/components/muiComponents.tsx @@ -25,7 +25,7 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import React from "react"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; -import Button from "@mui/material/Button"; +import Button, { ButtonProps } from "@mui/material/Button"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; @@ -75,16 +75,19 @@ const LightTooltip = styled(({ className, ...props }: any) => ( interface OverflowTooltipProps extends Omit { children: React.ReactElement; wrapperSx?: SxProps; + wrapperClassName?: string; } -const OverflowTooltip = ({ title, children, wrapperSx, ...props }: OverflowTooltipProps) => { +const OverflowTooltip = ({ title, children, wrapperSx, wrapperClassName, ...props }: OverflowTooltipProps) => { const textElementRef = React.useRef(null); const [isOverflowed, setIsOverflowed] = React.useState(false); const checkOverflow = React.useCallback(() => { if (textElementRef.current) { + const el = textElementRef.current; setIsOverflowed( - textElementRef.current.scrollWidth > textElementRef.current.clientWidth + el.scrollWidth > el.clientWidth || + el.scrollWidth > el.getBoundingClientRect().width ); } }, []); @@ -97,12 +100,13 @@ const OverflowTooltip = ({ title, children, wrapperSx, ...props }: OverflowToolt resizeObserver.observe(element); return () => resizeObserver.disconnect(); } - }, [title, children, checkOverflow]); + }, [title, checkOverflow]); const child = ( {children} @@ -130,37 +135,24 @@ const OverflowTooltip = ({ title, children, wrapperSx, ...props }: OverflowToolt ); }; -interface ButtonProps { - children?: any; - variant?: string; - color: string; - onClick: any; - sx?: any; - size?: string; - endIcon?: any; - startIcon?: any; - className?: string; - disabled?: boolean; -} - const ButtonWrapper = styled(Box)({ display: "inline-flex" }); const StyledButton = styled(Button)(({ variant }) => ({ - fontWeight: "600 !important", - letterSpacing: "0 !important", - fontSize: "0.875rem !important", - cursor: "pointer !important", - minWidth: "unset !important", - ...(variant === "outlined" && { border: "1px solid #dddddd !important" }) + fontWeight: "600", + letterSpacing: "0", + fontSize: "0.875rem", + cursor: "pointer", + minWidth: "unset", + ...(variant === "outlined" && { border: "1px solid #dddddd" }) })); const CustomButton = ({ children, sx, ...rest -}: ButtonProps | any) => { +}: ButtonProps) => { return ( diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx index ce6eadf6638..73178e37615 100644 --- a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx +++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx @@ -563,7 +563,7 @@ const BusinessMetaDataForm = ({ { + onClick={(_e: React.MouseEvent) => { setForm(false); setBMAttribute({}); dispatchState(setEditBMAttribute({})); diff --git a/dashboard/src/views/BusinessMetadata/EnumCreateUpdate.tsx b/dashboard/src/views/BusinessMetadata/EnumCreateUpdate.tsx index bd4eed7df62..bcf42eca24d 100644 --- a/dashboard/src/views/BusinessMetadata/EnumCreateUpdate.tsx +++ b/dashboard/src/views/BusinessMetadata/EnumCreateUpdate.tsx @@ -294,7 +294,7 @@ const EnumCreateUpdate = ({ size="small" data-cy="clearButton" color="primary" - onClick={(_e: Event) => { + onClick={(_e: React.MouseEvent) => { reset({ enumType: "", enumValues: [] }); }} disabled={ diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss index 74c5950388b..71c4f7e40a9 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss @@ -26,7 +26,7 @@ transition: box-shadow 0.3s ease; &:hover { - box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); + box-shadow: 0px 2px 4px -1px rgba(0, 0, 0, 0.2), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 1px 10px 0px rgba(0, 0, 0, 0.12); } } @@ -90,3 +90,16 @@ margin-left: 8px; white-space: nowrap; } + +.latest-entities-name-wrapper { + display: block; + flex: 0 10 auto; + width: auto; +} + +.latest-entities-type-wrapper { + display: block; + flex: 0 1 auto; + width: auto; + min-width: 0; +} \ No newline at end of file diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx index b56690460ec..0301e407155 100644 --- a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx @@ -106,30 +106,30 @@ const formatCreatedRelativeFromMs = (ms: number): string => { const deltaMs = now - ms; if (!Number.isFinite(deltaMs)) return INVALID_TS_LABEL; if (deltaMs < 0) { - return moment(ms).fromNow(); + return `Created ${moment(ms).fromNow()}`; } const totalSec = Math.floor(deltaMs / 1000); if (totalSec < 1) { - return "Just now"; + return "Created just now"; } if (totalSec < 60) { return totalSec === 1 - ? "1 second ago" - : `${totalSec} seconds ago`; + ? "Created 1 second ago" + : `Created ${totalSec} seconds ago`; } const totalMin = Math.floor(totalSec / 60); if (totalMin < 60) { return totalMin === 1 - ? "1 minute ago" - : `${totalMin} minutes ago`; + ? "Created 1 minute ago" + : `Created ${totalMin} minutes ago`; } const totalHr = Math.floor(totalMin / 60); if (totalHr < 24) { return totalHr === 1 - ? "1 hour ago" - : `${totalHr} hours ago`; + ? "Created 1 hour ago" + : `Created ${totalHr} hours ago`; } - return moment(ms).fromNow(); + return `Created ${moment(ms).fromNow()}`; }; const formatRelativeTime = (raw: unknown): string => { @@ -138,8 +138,7 @@ const formatRelativeTime = (raw: unknown): string => { return formatCreatedRelativeFromMs(ms); }; -const nameWrapperSx = { display: "block", flex: "0 1 auto", width: "auto" }; -const typeWrapperSx = { display: "block", flex: "0 0 auto", width: "auto" }; + const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesListProps) => { const navigate = useNavigate(); @@ -200,7 +199,7 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL {detailHref ? ( - + ) : ( - + )} - + { /> , ) - expect(screen.getByText('Just now')).toBeInTheDocument() + expect(screen.getByText('Created just now')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -242,7 +242,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('30 seconds ago')).toBeInTheDocument() + expect(screen.getByText('Created 30 seconds ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -259,7 +259,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('1 minute ago')).toBeInTheDocument() + expect(screen.getByText('Created 1 minute ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -276,7 +276,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('2 minutes ago')).toBeInTheDocument() + expect(screen.getByText('Created 2 minutes ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -293,7 +293,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('1 hour ago')).toBeInTheDocument() + expect(screen.getByText('Created 1 hour ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -310,7 +310,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('2 hours ago')).toBeInTheDocument() + expect(screen.getByText('Created 2 hours ago')).toBeInTheDocument() jest.setSystemTime(new Date(base)) rerender( @@ -556,7 +556,7 @@ describe('LatestEntitiesList', () => { /> , ) - expect(screen.getByText('1 second ago')).toBeInTheDocument() + expect(screen.getByText('Created 1 second ago')).toBeInTheDocument() }) it('uses Created today when Date.now is non-finite for relative time', () => { diff --git a/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx b/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx index bea851fac25..6627207e81d 100644 --- a/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx +++ b/dashboard/src/views/DetailPage/BusinessMetadataDetails/BusinessMetadataDetailsLayout.tsx @@ -358,7 +358,7 @@ const BusinessMetadataDetailsLayout = () => { { + onClick={(_e: React.MouseEvent) => { reset({ attributeDefs: [defaultAttrObj] }); setForm(false); setBMAttribute({});