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 e98b8667920..be69b32e324 100644 --- a/dashboard/src/components/__tests__/muiComponents.test.tsx +++ b/dashboard/src/components/__tests__/muiComponents.test.tsx @@ -21,11 +21,12 @@ */ 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, LightTooltip, + OverflowTooltip, LinkTab, Accordion, AccordionSummary, @@ -69,6 +70,100 @@ describe('muiComponents', () => { expect(screen.getByText('Tooltip Child')).toBeTruthy() }) + 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( + + Overflow Child + + ) + expect(screen.getByText('Overflow Child')).toBeTruthy() + }) + + it('disables tooltip when not overflowed', async () => { + render( + + Short + + ) + 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', async () => { + render( + + Will be long + + ) + 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() + }) + + 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 () => { const preventDefault = jest.fn() render( diff --git a/dashboard/src/components/muiComponents.tsx b/dashboard/src/components/muiComponents.tsx index 12ee53af455..ac6294cd1e5 100644 --- a/dashboard/src/components/muiComponents.tsx +++ b/dashboard/src/components/muiComponents.tsx @@ -22,9 +22,10 @@ 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"; +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"; @@ -51,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) => ( ( } })); -interface ButtonProps { - children?: any; - variant?: string; - color: string; - onClick: any; - sx?: any; - size?: string; - endIcon?: any; - startIcon?: any; - className?: string; - disabled?: boolean; + +interface OverflowTooltipProps extends Omit { + children: React.ReactElement; + wrapperSx?: SxProps; + wrapperClassName?: string; } +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( + el.scrollWidth > el.clientWidth || + el.scrollWidth > el.getBoundingClientRect().width + ); + } + }, []); + + React.useEffect(() => { + checkOverflow(); + const element = textElementRef.current; + if (element) { + const resizeObserver = new ResizeObserver(() => checkOverflow()); + resizeObserver.observe(element); + return () => resizeObserver.disconnect(); + } + }, [title, checkOverflow]); + + const child = ( + + {children} + + ); + + return ( + + {child} + + ); +}; + +const ButtonWrapper = styled(Box)({ + display: "inline-flex" +}); + +const StyledButton = styled(Button)(({ variant }) => ({ + fontWeight: "600", + letterSpacing: "0", + fontSize: "0.875rem", + cursor: "pointer", + minWidth: "unset", + ...(variant === "outlined" && { border: "1px solid #dddddd" }) +})); + 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 }; - +}: ButtonProps) => { return ( - - - + + ); }; @@ -202,5 +241,6 @@ export { CustomButton, Accordion, AccordionSummary, - AccordionDetails + AccordionDetails, + OverflowTooltip }; 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 new file mode 100644 index 00000000000..71c4f7e40a9 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.scss @@ -0,0 +1,105 @@ +/* + * 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: rgba(0, 0, 0, 0.87); +} + +.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 { + font-size: 0.875rem; +} + +.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: rgba(0, 0, 0, 0.6); +} + +.latest-entities-timestamp { + font-size: 0.8125rem; + color: rgba(0, 0, 0, 0.6); + flex-shrink: 0; + 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 2d80a5a1fa0..0301e407155 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; @@ -136,6 +138,8 @@ const formatRelativeTime = (raw: unknown): string => { return formatCreatedRelativeFromMs(ms); }; + + const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesListProps) => { const navigate = useNavigate(); @@ -146,54 +150,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 +194,42 @@ const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesL - + {detailHref ? ( - - {displayName} - + + + {displayName} + + ) : ( + + + {displayName} + + + )} + - {displayName} + ({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 4ed368f0306..0a65755e215 100644 --- a/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx +++ b/dashboard/src/views/DashboardOverview/__tests__/LatestEntitiesList.test.tsx @@ -185,11 +185,9 @@ describe('LatestEntitiesList', () => { /> , ) - const row = screen.getByText('X').closest('li') + const row = screen.getByText('X').closest('li') as HTMLElement expect(row).toBeTruthy() - expect( - within(row as HTMLElement).getByText(/^Created /), - ).toBeInTheDocument() + expect(within(row).getByText(/ago/)).toBeInTheDocument() }) it('shows Created today for unusable timestamp', () => { @@ -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() }) @@ -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,68 @@ 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('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) + + 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', () => { + render( + + + , + ) + expect(screen.getByText('(Entity)')).toBeInTheDocument() + }) }) 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({});