diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/tooltip/tooltip.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/tooltip/tooltip.tsx index 2cabd19ad2cd..b1051a5b0a07 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/tooltip/tooltip.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/tooltip/tooltip.tsx @@ -1,5 +1,6 @@ import { cx } from '@/utils/cx'; import type { ReactNode } from 'react'; +import { forwardRef } from 'react'; import type { ButtonProps as AriaButtonProps, TooltipProps as AriaTooltipProps, @@ -7,6 +8,7 @@ import type { } from 'react-aria-components'; import { Button as AriaButton, + Focusable as AriaFocusable, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger, @@ -126,7 +128,7 @@ export const Tooltip = ({ isExiting && 'tw:ease-in tw:animate-out tw:fade-out tw:zoom-out-95 tw:in-placement-left:slide-out-to-right-0.5 tw:in-placement-right:slide-out-to-left-0.5 tw:in-placement-top:slide-out-to-bottom-0.5 tw:in-placement-bottom:slide-out-to-top-0.5' )}> - + {title} @@ -145,21 +147,29 @@ export const Tooltip = ({ type TooltipTriggerProps = AriaButtonProps; -export const TooltipTrigger = ({ - children, - className, - ...buttonProps -}: TooltipTriggerProps) => { +// AriaTooltipTrigger passes its hover/focus-open handlers down through +// FocusableContext, not through cloned props or a plain ref - AriaButton never +// reads that context (it only reads ButtonContext), so wrapping it directly +// silently drops the tooltip's open/close wiring even though the ref itself +// gets through. AriaFocusable is the react-aria primitive that actually reads +// FocusableContext and clones the merged handlers onto its single child. +export const TooltipTrigger = forwardRef< + HTMLButtonElement, + TooltipTriggerProps +>(function TooltipTrigger({ children, className, ...buttonProps }, ref) { return ( - - cx( - 'tw:h-max tw:w-max tw:outline-hidden', - typeof className === 'function' ? className(values) : className - ) - }> - {children} - + + + cx( + 'tw:h-max tw:w-max tw:outline-hidden', + typeof className === 'function' ? className(values) : className + ) + }> + {children} + + ); -}; +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx index 8fb3169a4007..d9f4ee338447 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx @@ -17,11 +17,15 @@ import { fireEvent, render, screen, + within, } from '@testing-library/react'; import React, { act } from 'react'; +import { Link } from 'react-router-dom'; import { TestCase, TestCaseStatus } from '../../../../generated/tests/testCase'; import { MOCK_PERMISSIONS } from '../../../../mocks/Glossary.mock'; import { MOCK_TEST_CASE } from '../../../../mocks/TestSuite.mock'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import observabilityRouterClassBase from '../../../../utils/ObservabilityRouterClassBase'; import TestCaseIncidentManagerStatus from '../../../DataQuality/IncidentManager/TestCaseStatus/TestCaseIncidentManagerStatus.component'; import { DataQualityTabProps } from '../ProfilerDashboard/profilerDashboard.interface'; import DataQualityTab from './DataQualityTab'; @@ -244,9 +248,24 @@ jest.mock('@openmetadata/ui-core-components', () => { children, title, }: React.PropsWithChildren<{ title?: string }>) => ( -
{children}
+
+ {children} +
+ ), + TooltipTrigger: ({ + children, + className, + onPress, + 'data-testid': testId, + }: React.PropsWithChildren<{ + className?: string; + onPress?: () => void; + 'data-testid'?: string; + }>) => ( + ), - TooltipTrigger: ({ children }: React.PropsWithChildren) => <>{children}, Typography: ({ children, className, @@ -265,6 +284,14 @@ jest.mock('@openmetadata/ui-core-components', () => { }; }); +jest.mock('react-aria-components', () => ({ + ...jest.requireActual('react-aria-components'), + // Focusable is a transparent ref/context-wiring wrapper in real usage; for + // DOM-structure assertions in tests it's equivalent to rendering its child + // directly. + Focusable: ({ children }: React.PropsWithChildren) => <>{children}, +})); + jest.mock('../../../../rest/testAPI', () => ({ removeTestCaseFromTestSuite: jest.fn().mockResolvedValue({}), })); @@ -541,6 +568,45 @@ describe('DataQualityTab test', () => { expect(deleteButton).toBeInTheDocument(); }); + it('Should show a styled Tooltip with the full entity name for the Name cell, not a native title attribute', async () => { + const firstRowData = MOCK_TEST_CASE[0]; + await act(async () => { + render(); + }); + + const nameCellWrapper = await screen.findByTestId(firstRowData.name); + const trigger = within(nameCellWrapper).getByText( + getEntityName(firstRowData) + ); + + // The trigger is a real Link (wrapped in Focusable, not TooltipTrigger), + // so it keeps native link semantics, while still not relying on a + // native title attribute for the full name. + expect(trigger).not.toHaveAttribute('title'); + + const tooltip = within(nameCellWrapper).getByTestId('tooltip'); + + expect(tooltip).toHaveAttribute('title', getEntityName(firstRowData)); + }); + + it('Should link the Name cell trigger to the test case detail page', async () => { + const firstRowData = MOCK_TEST_CASE[0]; + await act(async () => { + render(); + }); + + const nameLinkCall = (Link as unknown as jest.Mock).mock.calls.find( + ([props]) => + props.to?.pathname === + observabilityRouterClassBase.getTestCaseDetailPagePath( + firstRowData.fullyQualifiedName ?? '' + ) + ); + + expect(nameLinkCall).toBeDefined(); + expect(nameLinkCall?.[0].state).toEqual({ breadcrumbData: undefined }); + }); + it('Should keep action dropdowns aligned when dimensions are present', async () => { const dimensionalTestCase: TestCase = { ...MOCK_TEST_CASE[0], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx index f27d1ba47329..322ab9c5d66f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx @@ -28,6 +28,7 @@ import classNames from 'classnames'; import { isUndefined, sortBy, toLower } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { Selection, SortDescriptor } from 'react-aria-components'; +import { Focusable } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { ReactComponent as DimensionIcon } from '../../../../assets/svg/data-observability/dimension.svg'; @@ -651,18 +652,21 @@ const DataQualityTab: React.FC = ({ data-testid={record.name} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> - - {getEntityName(record)} - + + + + {getEntityName(record)} + + + {showTableColumn && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.test.tsx index 177c3991a224..329ff8c13ee4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.test.tsx @@ -14,6 +14,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Link } from 'react-router-dom'; import { TestCaseResolutionStatus } from '../../generated/tests/testCaseResolutionStatus'; +import { getNameFromFQN } from '../../utils/FqnUtils'; import { NextPreviousProps } from '../common/NextPrevious/NextPrevious.interface'; import { TestCasePermission } from '../Database/Profiler/ProfilerDashboard/profilerDashboard.interface'; import IncidentManagerTable, { @@ -88,9 +89,22 @@ jest.mock('@openmetadata/ui-core-components', () => { .fn() .mockImplementation(() =>
), Table: TableMock, + Tooltip: jest.fn().mockImplementation(({ children, title }) => ( +
+ {children} +
+ )), }; }); +jest.mock('react-aria-components', () => ({ + ...jest.requireActual('react-aria-components'), + // Focusable is a transparent ref/context-wiring wrapper in real usage; for + // DOM-structure assertions in tests it's equivalent to rendering its child + // directly. + Focusable: ({ children }: React.PropsWithChildren) => <>{children}, +})); + jest.mock('../common/NextPrevious/NextPrevious', () => { return jest .fn() @@ -292,12 +306,47 @@ describe('IncidentManagerTable', () => { expect(nameLinkCall?.[0].state).toEqual({ breadcrumbData }); }); - it('should truncate the table link and expose the full name via title', () => { + it('should wrap the truncated table link in a Tooltip showing the table FQN', () => { renderTable(); + const tooltip = screen.getAllByTestId('tooltip')[0]; const tableLink = screen.getAllByTestId('table-link')[0]; - expect(tableLink).toHaveAttribute('title', 'NameFromFQN'); + // The trigger is a real anchor (wrapped in Focusable, not TooltipTrigger), + // so it keeps native link semantics - Ctrl/Cmd+click, middle-click, + // right-click "copy link address", and screen readers reading it as a + // link - while still not relying on a native title attribute. + expect(tableLink.tagName).toBe('A'); + expect(tableLink).not.toHaveAttribute('title'); expect(tableLink).toHaveClass('tw:truncate'); + + // Tooltip shows the table's own FQN (Service.Database.Schema.Table, as + // returned by getPartialNameFromTableFQN), not the test case's FQN - + // the test case reference's fullyQualifiedName has the test case name + // appended and must not leak into the tooltip. + expect(tooltip).toHaveAttribute('title', 'PartialName'); + }); + + it('should fall back to fullyQualifiedName when getNameFromFQN returns an empty string', () => { + (getNameFromFQN as jest.Mock).mockReturnValueOnce(''); + + renderTable(); + + const tableLink = screen.getAllByTestId('table-link')[0]; + + // Should not render blank - falls back to ref.fullyQualifiedName via `||` + expect(tableLink).toHaveTextContent( + mockRecords[0].testCaseReference?.fullyQualifiedName ?? '' + ); + }); + + it('should link the table trigger to the table profiler page', () => { + renderTable(); + + const tableLinkCall = (Link as unknown as jest.Mock).mock.calls.find( + ([props]) => props['data-testid'] === 'table-link' + ); + + expect(tableLinkCall?.[0].to).toEqual('entity-details-path'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.tsx index 087c0d90b22f..82e33f32f195 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManagerTable.component.tsx @@ -15,9 +15,11 @@ import { EmptyPlaceholder, Skeleton, Table, + Tooltip, } from '@openmetadata/ui-core-components'; import { ShieldTick } from '@untitledui/icons'; import { useMemo } from 'react'; +import { Focusable } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { EntityTabs, EntityType, FqnPart } from '../../enums/entity.enum'; @@ -180,19 +182,22 @@ const IncidentManagerTable = ({ {isIncidentPage && ( - e.stopPropagation()}> - {getNameFromFQN(tableFqn) ?? ref?.fullyQualifiedName} - + + + e.stopPropagation()}> + {getNameFromFQN(tableFqn) || ref?.fullyQualifiedName} + + + )}