Skip to content

Commit ab1c3f2

Browse files
committed
(chore) ui: Add hierarchical asset count aggregation for teams (#25877)
* ui: Add hierarchical asset count aggregation for teams with Playwright test * fix minor issue * address gitar comments * address gitar * address gitar * address gitar & fix sonar * address gitar * address comment * Revert "address comment" This reverts commit 064bc14. * address gitar * nit * revert last change * address gitar (cherry picked from commit 73b6af0)
1 parent 9f98479 commit ab1c3f2

13 files changed

Lines changed: 652 additions & 98 deletions

File tree

openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/TeamsHierarchy.spec.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,21 @@
1313
import { expect, test } from '@playwright/test';
1414
import { DELETE_TERM } from '../../constant/common';
1515
import { GlobalSettingOptions } from '../../constant/settings';
16+
import { TableClass } from '../../support/entity/TableClass';
17+
import { TeamClass } from '../../support/team/TeamClass';
1618
import {
19+
createNewPage,
1720
redirectToHomePage,
1821
toastNotification,
1922
uuid,
2023
} from '../../utils/common';
2124
import { settingClick } from '../../utils/sidebar';
22-
import { addTeamHierarchy, getNewTeamDetails } from '../../utils/team';
25+
import {
26+
addTeamHierarchy,
27+
addTeamOwnerToEntity,
28+
getNewTeamDetails,
29+
verifyAssetsInTeamsPage,
30+
} from '../../utils/team';
2331

2432
// use the admin user to login
2533
test.use({ storageState: 'playwright/.auth/admin.json' });
@@ -144,3 +152,115 @@ test.describe('Add Nested Teams and Test TeamsSelectable', () => {
144152
);
145153
});
146154
});
155+
156+
// ── Self-contained asset count aggregation test ──
157+
// Creates hierarchy (API): aggBU (BusinessUnit) → aggGroup (Group)
158+
// Creates 1 table (API), assigns Group as owner via UI,
159+
// then verifies asset count + assets tab on the parent team via UI.
160+
161+
const aggId = uuid();
162+
const aggBUName = `agg-bu-${aggId}`;
163+
const aggGroupName = `agg-grp-${aggId}`;
164+
165+
const aggBU = new TeamClass({
166+
name: aggBUName,
167+
displayName: aggBUName,
168+
description: 'Aggregation test BU',
169+
teamType: 'BusinessUnit',
170+
});
171+
const aggGroup = new TeamClass({
172+
name: aggGroupName,
173+
displayName: aggGroupName,
174+
description: 'Aggregation test Group',
175+
teamType: 'Group',
176+
});
177+
178+
const aggTable = new TableClass();
179+
180+
test.describe('Verify Asset Count Aggregation', () => {
181+
test.use({ storageState: 'playwright/.auth/admin.json' });
182+
183+
test.beforeAll('Setup hierarchy and table', async ({ browser }) => {
184+
const { apiContext, afterAction } = await createNewPage(browser);
185+
186+
// Create BU → Group hierarchy via API
187+
await aggBU.create(apiContext);
188+
189+
const grpRes = await apiContext.post('/api/v1/teams', {
190+
data: { ...aggGroup.data, parents: [aggBU.responseData.id] },
191+
});
192+
expect(grpRes.ok()).toBeTruthy();
193+
aggGroup.responseData = await grpRes.json();
194+
195+
// Create a table via API
196+
await aggTable.create(apiContext);
197+
198+
await afterAction();
199+
});
200+
201+
test.afterAll('Cleanup', async ({ browser }) => {
202+
const { apiContext, afterAction } = await createNewPage(browser);
203+
await aggTable.delete(apiContext);
204+
await aggBU.delete(apiContext);
205+
await afterAction();
206+
});
207+
208+
test('Assign asset to sub-team and verify aggregated count on parent', async ({
209+
page,
210+
}) => {
211+
test.slow();
212+
await redirectToHomePage(page);
213+
// Step 1: Visit the table page and assign the Group team as owner (via UI)
214+
await addTeamOwnerToEntity(page, aggTable, aggGroup);
215+
216+
// Step 2: Verify the asset appears in the Group team's Assets tab
217+
await verifyAssetsInTeamsPage(page, aggTable, aggGroup, 1);
218+
219+
// Step 3: Navigate to the parent BU team page
220+
await redirectToHomePage(page);
221+
const getOrganizationResponse = page.waitForResponse(
222+
'/api/v1/teams/name/*'
223+
);
224+
await settingClick(page, GlobalSettingOptions.TEAMS);
225+
await getOrganizationResponse;
226+
227+
// Step 3: Verify asset count for the BU in the Organization-level table
228+
// Wait for skeleton in the BU row to resolve and verify aggregated count
229+
const buRow = page.locator(`[data-row-key="${aggBUName}"]`);
230+
await buRow.locator('.ant-skeleton-active').waitFor({ state: 'hidden' });
231+
await expect(buRow.getByTestId('asset-count')).toHaveText('1');
232+
233+
// Click on the BU team to see its children
234+
const permissionResponse = page.waitForResponse(
235+
'/api/v1/permissions/team/name/*'
236+
);
237+
await page.getByRole('link', { name: aggBUName }).click();
238+
await permissionResponse;
239+
240+
// Step 4: Verify asset count shown in the hierarchy table for the Group child
241+
// Wait for the skeleton loader to disappear, then assert the count
242+
const groupRow = page.locator(
243+
`[data-row-key="${aggGroupName}"]`
244+
);
245+
246+
await groupRow.locator('.ant-skeleton-active').waitFor({ state: 'hidden' });
247+
248+
await expect(groupRow.getByTestId('asset-count')).toHaveText('1');
249+
250+
// Step 5: Open the Assets tab on the BU team page and verify aggregated count
251+
const assetsRes = page.waitForResponse('/api/v1/search/query?*size=15*');
252+
await page.getByTestId('assets').click();
253+
await assetsRes;
254+
255+
// Verify the BU's Assets tab shows count = 1 (aggregated from Group child)
256+
await expect(
257+
page.getByTestId('assets').getByTestId('filter-count')
258+
).toContainText('1');
259+
260+
// Verify the table card is visible in the BU's Assets tab
261+
const tableFqn = aggTable.entityResponseData?.['fullyQualifiedName'];
262+
await expect(
263+
page.locator(`[data-testid="table-data-card_${tableFqn}"]`)
264+
).toBeVisible();
265+
});
266+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
import { Skeleton, Typography } from 'antd';
14+
15+
interface TeamAssetCountProps {
16+
count: number | null;
17+
isLoading: boolean;
18+
}
19+
20+
export const TeamAssetCount = ({ count, isLoading }: TeamAssetCountProps) => {
21+
if (isLoading) {
22+
return <Skeleton.Input active size="small" style={{ width: 30, height: 20 }} />;
23+
}
24+
25+
return <Typography.Text data-testid="asset-count">{count ?? 0}</Typography.Text>;
26+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
import { render, screen } from '@testing-library/react';
14+
15+
jest.mock('antd', () => ({
16+
Skeleton: {
17+
Input: jest.fn().mockImplementation(() => <div data-testid="skeleton" />),
18+
},
19+
Typography: {
20+
Text: jest
21+
.fn()
22+
.mockImplementation(({ children, ...props }) => <span {...props}>{children}</span>),
23+
},
24+
}));
25+
26+
import { TeamAssetCount } from './TeamAssetCount.component';
27+
28+
describe('TeamAssetCount', () => {
29+
it('renders the count when not loading', () => {
30+
render(<TeamAssetCount count={42} isLoading={false} />);
31+
32+
expect(screen.getByTestId('asset-count')).toHaveTextContent('42');
33+
});
34+
35+
it('renders 0 when count is null', () => {
36+
render(<TeamAssetCount count={null} isLoading={false} />);
37+
38+
expect(screen.getByTestId('asset-count')).toHaveTextContent('0');
39+
});
40+
41+
it('renders skeleton while loading', () => {
42+
render(<TeamAssetCount isLoading count={null} />);
43+
44+
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
45+
expect(screen.queryByTestId('asset-count')).not.toBeInTheDocument();
46+
});
47+
});

openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Team } from '../../../../generated/entity/teams/team';
1919
import { EntityReference } from '../../../../generated/entity/type';
2020

2121
export interface TeamDetailsProp {
22+
allTeamIds: string[];
2223
assetsCount: number;
2324
currentTeam: Team;
2425
teams?: Team[];

0 commit comments

Comments
 (0)