diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts index 8fc97411c1b3..a80b5e7a2795 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts @@ -37,10 +37,10 @@ export const dragAndDropElement = async ( }; export const openDragDropDropdown = async (page: Page, name: string) => { - const dropdownIcon = page.locator( - `[data-row-key=${name}] > .whitespace-nowrap > [data-testid="expand-icon"] > svg` - ); - await dropdownIcon.click(); + await page + .locator(`[data-row-key="${name}"]`) + .getByTestId('expand-icon') + .click(); }; export const confirmationDragAndDropTeam = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx index d15d5b5212b5..057e851f82cd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx @@ -636,7 +636,13 @@ const TeamDetailsV1 = ({ ); } - return currentTeam.childrenCount === 0 && !searchTerm ? ( + const showEmptyTeamPlaceholder = + isEmpty(searchTerm) && + isEmpty(childTeamList) && + (currentTeam.childrenCount ?? 0) === 0 && + !isTeamBasicDataLoading; + + return showEmptyTeamPlaceholder ? ( } @@ -696,6 +702,7 @@ const TeamDetailsV1 = ({ entityPermissions.Create, isFetchingAllTeamAdvancedDetails, isSearchLoading, + isTeamBasicDataLoading, onTeamExpand, handleAddTeamButtonClick, handleTeamSearch, @@ -946,15 +953,10 @@ const TeamDetailsV1 = ({ const teamsCollapseHeader = useMemo( () => ( <> - - - {!isOrganization && ( - - )} -
+
+ {!isOrganization && } +
+
@@ -965,50 +967,54 @@ const TeamDetailsV1 = ({ updateTeamHandler={updateTeamHandler} /> - +
+ +
- - - {teamActionButton} - {!isOrganization ? ( - entityPermissions.EditAll && ( + + {teamActionButton} + {!isOrganization ? ( + entityPermissions.EditAll && ( + + ) + ) : ( - ) - ) : ( - - )} - - + )} + +
+
({ - Link: jest - .fn() - .mockImplementation(({ children }) => {children}), -})); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + + const MockLink = forwardRef< + HTMLAnchorElement, + { children?: ReactNode; to?: unknown } + >(({ children, to: _to, ...props }, ref) => ( + + {children} + + )); + + return { + ...actual, + Link: MockLink, + }; +}); jest.mock('../../../../utils/TeamUtils', () => ({ getMovedTeamData: jest.fn().mockReturnValue([]), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx index b00f32c531aa..c45f34549ddb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx @@ -20,19 +20,13 @@ import { compare } from 'fast-json-patch'; import { isEmpty, isUndefined } from 'lodash'; import { FC, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; import { TABLE_CONSTANTS } from '../../../../constants/Teams.constants'; import { TabSpecificField } from '../../../../enums/entity.enum'; import { Team } from '../../../../generated/entity/teams/team'; import { Include } from '../../../../generated/type/include'; import { getTeamByName, patchTeamDetail } from '../../../../rest/teamsAPI'; import { Transi18next } from '../../../../utils/CommonUtils'; -import { - getEntityName, - highlightSearchText, -} from '../../../../utils/EntityUtils'; -import { getTeamsWithFqnPath } from '../../../../utils/RouterUtils'; -import { stringToHTML } from '../../../../utils/StringsUtils'; +import { getEntityName } from '../../../../utils/EntityUtils'; import { descriptionTableObject } from '../../../../utils/TableColumn.util'; import { getTableExpandableConfig } from '../../../../utils/TableUtils'; import { isDropRestricted } from '../../../../utils/TeamUtils'; @@ -42,6 +36,7 @@ import FilterTablePlaceHolder from '../../../common/ErrorWithPlaceholder/FilterT import Table from '../../../common/Table/Table'; import { MovedTeamProps, TeamHierarchyProps } from './team.interface'; import './teams.less'; +import { TeamHierarchyNameCell } from './TeamsHeaderSection/TeamHierarchyNameCell'; const TeamHierarchy: FC = ({ currentTeam, @@ -82,17 +77,11 @@ const TeamHierarchy: FC = ({ { title: t('label.team-plural'), dataIndex: 'teams', - className: 'whitespace-nowrap', + className: 'teams-hierarchy-name-column', key: 'teams', + width: '32%', render: (_, record) => ( - - {stringToHTML( - highlightSearchText(getEntityName(record), searchTerm) - )} - + ), }, { @@ -152,7 +141,7 @@ const TeamHierarchy: FC = ({ }, ...descriptionTableObject({ width: 300 }), ]; - }, [data, isFetchingAllTeamAdvancedDetails, onTeamExpand, teamAssetCounts]); + }, [isFetchingAllTeamAdvancedDetails, searchTerm, t, teamAssetCounts]); const handleTableHover = useCallback( (value: boolean) => setIsTableHovered(value), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.test.tsx new file mode 100644 index 000000000000..8b1a898e844f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.test.tsx @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { Team } from '../../../../../generated/entity/teams/team'; +import { + getEntityName, + highlightSearchText, +} from '../../../../../utils/EntityUtils'; +import { getTeamsWithFqnPath } from '../../../../../utils/RouterUtils'; +import { stringToHTML } from '../../../../../utils/StringsUtils'; +import { TeamHierarchyNameCell } from './TeamHierarchyNameCell'; + +jest.mock('antd', () => { + const actual = jest.requireActual('antd'); + + return { + ...actual, + Tooltip: ({ + children, + title, + }: { + children: ReactNode; + title?: ReactNode; + }) => ( + + {children} + + ), + }; +}); + +jest.mock('../../../../../utils/EntityUtils', () => ({ + getEntityName: jest.fn(), + highlightSearchText: jest.fn((text: string) => text), +})); + +jest.mock('../../../../../utils/RouterUtils', () => ({ + getTeamsWithFqnPath: jest.fn(), +})); + +jest.mock('../../../../../utils/StringsUtils', () => ({ + stringToHTML: jest.fn((html: string) => html), +})); + +const mockGetEntityName = getEntityName as jest.MockedFunction< + typeof getEntityName +>; +const mockHighlightSearchText = highlightSearchText as jest.MockedFunction< + typeof highlightSearchText +>; +const mockGetTeamsWithFqnPath = getTeamsWithFqnPath as jest.MockedFunction< + typeof getTeamsWithFqnPath +>; +const mockStringToHTML = stringToHTML as jest.MockedFunction< + typeof stringToHTML +>; + +const mockTeam = { + fullyQualifiedName: 'Organization.Engineering', + id: '49d060a2-ad14-48a7-840a-836cd99aaffb', + name: 'Engineering', +} as Team; + +const renderCell = (props: { record: Team; searchTerm?: string }) => + render( + + + + ); + +describe('TeamHierarchyNameCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetEntityName.mockReturnValue('Engineering'); + mockGetTeamsWithFqnPath.mockReturnValue( + '/settings/members/teams/Engineering' + ); + jest + .spyOn(HTMLAnchorElement.prototype, 'scrollWidth', 'get') + .mockReturnValue(100); + jest + .spyOn(HTMLAnchorElement.prototype, 'clientWidth', 'get') + .mockReturnValue(100); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders team link with expected test id and route', () => { + renderCell({ record: mockTeam }); + + const link = screen.getByTestId('team-name-Engineering'); + + expect(link).toHaveClass('teams-hierarchy-team-name-link'); + expect(link).toHaveAttribute('href', '/settings/members/teams/Engineering'); + expect(mockGetTeamsWithFqnPath).toHaveBeenCalledWith( + 'Organization.Engineering' + ); + }); + + it('uses team name when fullyQualifiedName is missing', () => { + const teamWithoutFqn = { + name: 'LocalTeam', + } as Team; + + mockGetEntityName.mockReturnValue('LocalTeam'); + mockGetTeamsWithFqnPath.mockReturnValue('/teams/LocalTeam'); + + renderCell({ record: teamWithoutFqn }); + + expect(mockGetTeamsWithFqnPath).toHaveBeenCalledWith('LocalTeam'); + expect(screen.getByTestId('team-name-LocalTeam')).toBeInTheDocument(); + }); + + it('passes display name and search term through highlight and stringToHTML', () => { + mockHighlightSearchText.mockReturnValue('Engineering'); + + renderCell({ record: mockTeam, searchTerm: 'Eng' }); + + expect(mockHighlightSearchText).toHaveBeenCalledWith('Engineering', 'Eng'); + expect(mockStringToHTML).toHaveBeenCalledWith('Engineering'); + }); + + it('does not wrap with Tooltip when text is not truncated', () => { + jest + .spyOn(HTMLAnchorElement.prototype, 'scrollWidth', 'get') + .mockReturnValue(80); + jest + .spyOn(HTMLAnchorElement.prototype, 'clientWidth', 'get') + .mockReturnValue(100); + + renderCell({ record: mockTeam }); + + expect( + screen.queryByTestId('hierarchy-name-tooltip') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('team-name-Engineering')).toBeInTheDocument(); + }); + + it('wraps link with Tooltip when text is truncated', () => { + jest + .spyOn(HTMLAnchorElement.prototype, 'scrollWidth', 'get') + .mockReturnValue(400); + jest + .spyOn(HTMLAnchorElement.prototype, 'clientWidth', 'get') + .mockReturnValue(50); + + renderCell({ record: mockTeam }); + + const tooltip = screen.getByTestId('hierarchy-name-tooltip'); + + expect(tooltip).toHaveAttribute('data-tooltip-title', 'Engineering'); + expect( + tooltip.querySelector('[data-testid="team-name-Engineering"]') + ).toBeInTheDocument(); + }); + + it('re-evaluates truncation when display name changes', () => { + jest + .spyOn(HTMLAnchorElement.prototype, 'scrollWidth', 'get') + .mockReturnValue(400); + jest + .spyOn(HTMLAnchorElement.prototype, 'clientWidth', 'get') + .mockReturnValue(50); + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId('hierarchy-name-tooltip')).toBeInTheDocument(); + + mockGetEntityName.mockReturnValue('Short'); + jest + .spyOn(HTMLAnchorElement.prototype, 'scrollWidth', 'get') + .mockReturnValue(40); + jest + .spyOn(HTMLAnchorElement.prototype, 'clientWidth', 'get') + .mockReturnValue(100); + + rerender( + + + + ); + + expect( + screen.queryByTestId('hierarchy-name-tooltip') + ).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.tsx new file mode 100644 index 000000000000..523af04bec89 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamHierarchyNameCell.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { Tooltip } from 'antd'; +import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Team } from '../../../../../generated/entity/teams/team'; +import { + getEntityName, + highlightSearchText, +} from '../../../../../utils/EntityUtils'; +import { getTeamsWithFqnPath } from '../../../../../utils/RouterUtils'; +import { stringToHTML } from '../../../../../utils/StringsUtils'; + +type TeamHierarchyNameCellProps = { + record: Team; + searchTerm?: string; +}; + +export const TeamHierarchyNameCell: FC = ({ + record, + searchTerm = '', +}) => { + const displayName = getEntityName(record); + const linkRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + const updateTruncation = useCallback(() => { + const el = linkRef.current; + if (!el) { + return; + } + setIsTruncated(el.scrollWidth > el.clientWidth + 1); + }, []); + + useLayoutEffect(() => { + updateTruncation(); + }, [displayName, searchTerm, updateTruncation]); + + useLayoutEffect(() => { + const el = linkRef.current; + if (!el || typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(() => { + updateTruncation(); + }); + observer.observe(el); + + return () => observer.disconnect(); + }, [updateTruncation]); + + const link = ( + + {stringToHTML(highlightSearchText(displayName, searchTerm))} + + ); + + return ( + + {isTruncated ? ( + + + {link} + + + ) : ( + + {link} + + )} + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.component.tsx index 17800e3c5362..624794d2d7bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.component.tsx @@ -91,9 +91,11 @@ const TeamsHeadingLabel = ({ isHeadingEditing ? ( // Used onClick stop click propagation event anywhere in the component to parent // TeamDetailsV1 component collapsible panel - e.stopPropagation()}> +
e.stopPropagation()}> setHeading(e.target.value)} /> - + - +
) : ( <> <> {heading ? ( @@ -134,7 +136,7 @@ const TeamsHeadingLabel = ({ ) : ( {t('label.no-entity', { entity: t('label.display-name'), @@ -192,7 +194,11 @@ const TeamsHeadingLabel = ({ } }, [currentTeam]); - return {teamHeadingRender}; + return ( +
+ {teamHeadingRender} +
+ ); }; export default TeamsHeadingLabel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.test.tsx index ae1e38be6b4d..18748061604e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.test.tsx @@ -10,13 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TeamType } from '../../../../../generated/entity/teams/team'; import { useAuth } from '../../../../../hooks/authHooks'; @@ -68,10 +62,8 @@ const teamProps = { }; describe('TeamsHeadingLabel', () => { - it('should render Teams Heading Label', async () => { - await act(async () => { - render(); - }); + it('should render Teams Heading Label', () => { + render(); const teamHeading = screen.getByTestId('team-heading'); expect(teamHeading).toHaveTextContent('Test Team'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx index 4643315c5317..84fa4f3abfee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx @@ -175,7 +175,7 @@ const TeamsInfo = ({ () => (
- {`${t( + {`${t( 'label.email' )}`} {hasEditPermission && ( @@ -257,7 +257,7 @@ const TeamsInfo = ({ ) : ( {email ?? NO_DATA_PLACEHOLDER} @@ -279,7 +279,7 @@ const TeamsInfo = ({
- + {`${t('label.type')}`} {hasEditPermission && !showTypeSelector && !isGroupType && ( @@ -321,7 +321,7 @@ const TeamsInfo = ({ /> ) : ( {teamType} @@ -351,7 +351,7 @@ const TeamsInfo = ({
- + {t('label.persona')} ) : ( - + {t('message.no-persona-assigned')} )} @@ -433,7 +433,7 @@ const TeamsInfo = ({ {t('label.total-user-plural')} {currentTeam.userCount} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/teams.less b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/teams.less index f64902d03b7d..2d7f26d90fdb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/teams.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/teams.less @@ -42,6 +42,43 @@ } .teams-list-table { + .ant-table-content > table, + .ant-table-container .ant-table-content > table { + table-layout: fixed; + width: 100%; + } + + td.teams-hierarchy-name-column.ant-table-cell-with-append { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + } + + .teams-hierarchy-team-name-cell { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + } + + .teams-hierarchy-team-name-tooltip-trigger { + display: block; + min-width: 0; + max-width: 100%; + } + + .teams-hierarchy-team-name-link { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + th.teams-hierarchy-name-column { + overflow: hidden; + text-overflow: ellipsis; + } + .ant-table-thead { tr > th { border-right: 0px !important; @@ -85,11 +122,14 @@ } .teams-profile-container { + .ant-card { + background: @white; + } .collapse-panel-container { padding: 20px; } - .ant-card { - background: none; + .ant-card-head { + background: @white; } .ant-collapse-header, @@ -145,6 +185,19 @@ display: flex; } } + +.teams-heading-label-container { + flex: 0 1 auto; + max-width: 50%; + min-width: 0; +} + +.teams-heading-label-edit-row { + box-sizing: border-box; + max-width: 100%; + min-width: 0; +} + .team-assets-right-panel { .summary-panel-container { height: 100%;