fix(ui): add pagination in sub domains table (#22844)

* add pagination on subdomains table

* add tests

* fix domain tests

* fix flaky test

* moved config outsude
This commit is contained in:
Karan Hotchandani 2025-08-11 16:33:48 +05:30 committed by GitHub
parent b725bee3c7
commit a239cfb884
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 290 additions and 42 deletions

View File

@ -0,0 +1,161 @@
/*
* Copyright 2024 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 { expect, test } from '@playwright/test';
import { SidebarItem } from '../../constant/sidebar';
import { Domain } from '../../support/domain/Domain';
import { SubDomain } from '../../support/domain/SubDomain';
import {
createNewPage,
getApiContext,
redirectToHomePage,
} from '../../utils/common';
import { createSubDomain, selectDomain } from '../../utils/domain';
import { sidebarClick } from '../../utils/sidebar';
test.use({ storageState: 'playwright/.auth/admin.json' });
const domain = new Domain();
const subDomains: SubDomain[] = [];
const SUBDOMAIN_COUNT = 60;
test.describe('SubDomain Pagination', () => {
test.slow(true);
test.beforeAll('Setup domain and subdomains', async ({ browser }) => {
const { page, apiContext, afterAction } = await createNewPage(browser);
await domain.create(apiContext);
await redirectToHomePage(page);
const createPromises = [];
for (let i = 1; i <= SUBDOMAIN_COUNT; i++) {
const subDomain = new SubDomain(
domain,
`TestSubDomain${i.toString().padStart(2, '0')}`
);
subDomains.push(subDomain);
createPromises.push(subDomain.create(apiContext));
}
await Promise.all(createPromises);
await afterAction();
});
test.afterAll('Cleanup', async ({ browser }) => {
const { apiContext, afterAction } = await createNewPage(browser);
// Delete all subdomains in parallel
const deletePromises = subDomains.map((subDomain) =>
subDomain.delete(apiContext)
);
await Promise.all(deletePromises);
await domain.delete(apiContext);
await afterAction();
});
test.beforeEach('Navigate to domain page', async ({ page }) => {
await redirectToHomePage(page);
await sidebarClick(page, SidebarItem.DOMAIN);
await page.waitForLoadState('networkidle');
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
});
test('Verify subdomain count and pagination functionality', async ({
page,
}) => {
await selectDomain(page, domain.data);
await page.waitForLoadState('networkidle');
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
await test.step('Verify subdomain count in tab label', async () => {
const subDomainsTab = page.getByTestId('subdomains');
await expect(subDomainsTab).toBeVisible();
await expect(subDomainsTab).toContainText('60');
});
await test.step(
'Navigate to subdomains tab and verify initial data load',
async () => {
const subDomainRes = page.waitForResponse(
'/api/v1/search/query?q=*&from=0&size=50&index=domain_search_index&deleted=false&track_total_hits=true'
);
await page.getByTestId('subdomains').click();
await subDomainRes;
await page.waitForSelector('[data-testid="loader"]', {
state: 'detached',
});
await expect(page.locator('table')).toBeVisible();
await expect(page.locator('[data-testid="pagination"]')).toBeVisible();
}
);
await test.step('Test pagination navigation', async () => {
// Verify current page shows page 1
const tableRows = page.locator('table tbody tr');
await expect(tableRows).toHaveCount(50);
const nextPageResponse = page.waitForResponse('/api/v1/search/query?*');
await page.locator('[data-testid="next"]').click();
await nextPageResponse;
await expect(tableRows).toHaveCount(10);
const prevPageResponse = page.waitForResponse('/api/v1/search/query?*');
await page.locator('[data-testid="previous"]').click();
await prevPageResponse;
await expect(tableRows).toHaveCount(50);
});
await test.step(
'Create new subdomain and verify count updates',
async () => {
const subDomain = new SubDomain(domain);
await createSubDomain(page, subDomain.data);
await redirectToHomePage(page);
await sidebarClick(page, SidebarItem.DOMAIN);
await page.waitForLoadState('networkidle');
await selectDomain(page, domain.data);
const subDomainsTab = page.getByTestId('subdomains');
await expect(subDomainsTab).toContainText('61');
const { apiContext, afterAction } = await getApiContext(page);
const response = await apiContext.get(
'/api/v1/domains/name/' +
encodeURIComponent(`"${domain.data.name}"."NewTestSubDomain"`)
);
const subDomainData = await response.json();
await apiContext.delete(
`/api/v1/domains/${subDomainData.id}?hardDelete=true`
);
await afterAction();
}
);
});
});

View File

@ -48,7 +48,7 @@ export class SubDomain {
responseData: ResponseDataType = {} as ResponseDataType; responseData: ResponseDataType = {} as ResponseDataType;
constructor(domain: Domain | SubDomain, name?: string) { constructor(domain: Domain | SubDomain, name?: string) {
this.data.parent = domain.data.name; this.data.parent = domain.data.fullyQualifiedName;
this.data.name = name ?? this.data.name; this.data.name = name ?? this.data.name;
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
this.data.fullyQualifiedName = `\"${this.data.parent}\".\"${this.data.name}\"`; this.data.fullyQualifiedName = `\"${this.data.parent}\".\"${this.data.name}\"`;

View File

@ -137,14 +137,23 @@ export const selectSubDomain = async (
if (!isSelected) { if (!isSelected) {
const subDomainRes = page.waitForResponse( const subDomainRes = page.waitForResponse(
'/api/v1/search/query?q=*&from=0&size=50&index=domain_search_index&deleted=false' '/api/v1/search/query?q=*&from=0&size=0&index=domain_search_index&deleted=false&track_total_hits=true'
); );
await menuItem.click(); await menuItem.click();
await subDomainRes; await subDomainRes;
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
} }
const subDomainRes = page.waitForResponse(
'/api/v1/search/query?q=*&from=0&size=50&index=domain_search_index&deleted=false&track_total_hits=true'
);
await page.getByTestId('subdomains').getByText('Sub Domains').click(); await page.getByTestId('subdomains').getByText('Sub Domains').click();
await subDomainRes;
await page.waitForSelector('[data-testid="loader"]', {
state: 'detached',
});
await page.getByTestId(subDomain.name).click(); await page.getByTestId(subDomain.name).click();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
}; };

View File

@ -44,11 +44,7 @@ import { AssetsTabRef } from '../../../components/Glossary/GlossaryTerms/tabs/As
import { AssetsOfEntity } from '../../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import { AssetsOfEntity } from '../../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface';
import EntityNameModal from '../../../components/Modals/EntityNameModal/EntityNameModal.component'; import EntityNameModal from '../../../components/Modals/EntityNameModal/EntityNameModal.component';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { import { DE_ACTIVE_COLOR, ERROR_MESSAGE } from '../../../constants/constants';
DE_ACTIVE_COLOR,
ERROR_MESSAGE,
PAGE_SIZE_LARGE,
} from '../../../constants/constants';
import { EntityField } from '../../../constants/Feeds.constants'; import { EntityField } from '../../../constants/Feeds.constants';
import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider';
import { import {
@ -71,7 +67,6 @@ import { addDataProducts } from '../../../rest/dataProductAPI';
import { addDomains } from '../../../rest/domainAPI'; import { addDomains } from '../../../rest/domainAPI';
import { searchData } from '../../../rest/miscAPI'; import { searchData } from '../../../rest/miscAPI';
import { searchQuery } from '../../../rest/searchAPI'; import { searchQuery } from '../../../rest/searchAPI';
import { formatDomainsResponse } from '../../../utils/APIUtils';
import { getIsErrorMatch } from '../../../utils/CommonUtils'; import { getIsErrorMatch } from '../../../utils/CommonUtils';
import { import {
checkIfExpandViewSupported, checkIfExpandViewSupported,
@ -152,9 +147,7 @@ const DomainDetailsPage = ({
useState<EntityDetailsObjectInterface>(); useState<EntityDetailsObjectInterface>();
const [assetCount, setAssetCount] = useState<number>(0); const [assetCount, setAssetCount] = useState<number>(0);
const [dataProductsCount, setDataProductsCount] = useState<number>(0); const [dataProductsCount, setDataProductsCount] = useState<number>(0);
const [subDomains, setSubDomains] = useState<Domain[]>([]); const [subDomainsCount, setSubDomainsCount] = useState<number>(0);
const [isSubDomainsLoading, setIsSubDomainsLoading] =
useState<boolean>(false);
const encodedFqn = getEncodedFqn( const encodedFqn = getEncodedFqn(
escapeESReservedCharacters(domain.fullyQualifiedName) escapeESReservedCharacters(domain.fullyQualifiedName)
); );
@ -245,32 +238,31 @@ const DomainDetailsPage = ({
: []), : []),
]; ];
const fetchSubDomains = useCallback(async () => { const fetchSubDomainsCount = useCallback(async () => {
if (!isVersionsView) { if (!isVersionsView) {
try { try {
setIsSubDomainsLoading(true);
const res = await searchData( const res = await searchData(
'', '',
1, 0,
PAGE_SIZE_LARGE, 0,
`(parent.fullyQualifiedName:"${encodedFqn}")`, `(parent.fullyQualifiedName:"${encodedFqn}")`,
'', '',
'', '',
SearchIndex.DOMAIN SearchIndex.DOMAIN,
false,
true
); );
const data = formatDomainsResponse(res.data.hits.hits); const totalCount = res.data.hits.total.value ?? 0;
setSubDomains(data); setSubDomainsCount(totalCount);
} catch (error) { } catch (error) {
setSubDomains([]); setSubDomainsCount(0);
showErrorToast( showErrorToast(
error as AxiosError, error as AxiosError,
t('server.entity-fetch-error', { t('server.entity-fetch-error', {
entity: t('label.sub-domain-lowercase'), entity: t('label.sub-domain-lowercase'),
}) })
); );
} finally {
setIsSubDomainsLoading(false);
} }
} }
}, [isVersionsView, encodedFqn]); }, [isVersionsView, encodedFqn]);
@ -284,7 +276,7 @@ const DomainDetailsPage = ({
try { try {
await addDomains(data as CreateDomain); await addDomains(data as CreateDomain);
fetchSubDomains(); fetchSubDomainsCount();
} catch (error) { } catch (error) {
showErrorToast( showErrorToast(
getIsErrorMatch(error as AxiosError, ERROR_MESSAGE.alreadyExist) getIsErrorMatch(error as AxiosError, ERROR_MESSAGE.alreadyExist)
@ -302,7 +294,7 @@ const DomainDetailsPage = ({
setShowAddSubDomainModal(false); setShowAddSubDomainModal(false);
} }
}, },
[domain, fetchSubDomains] [domain, fetchSubDomainsCount]
); );
const addDataProduct = useCallback( const addDataProduct = useCallback(
@ -559,12 +551,11 @@ const DomainDetailsPage = ({
domain, domain,
isVersionsView, isVersionsView,
domainPermission, domainPermission,
subDomains, subDomainsCount,
dataProductsCount, dataProductsCount,
assetCount, assetCount,
activeTab, activeTab,
onAddDataProduct, onAddDataProduct,
isSubDomainsLoading,
queryFilter, queryFilter,
assetTabRef, assetTabRef,
dataProductsTabRef, dataProductsTabRef,
@ -593,8 +584,7 @@ const DomainDetailsPage = ({
assetCount, assetCount,
dataProductsCount, dataProductsCount,
activeTab, activeTab,
subDomains, subDomainsCount,
isSubDomainsLoading,
queryFilter, queryFilter,
customizedPage?.tabs, customizedPage?.tabs,
]); ]);
@ -606,8 +596,8 @@ const DomainDetailsPage = ({
}, [domain.fullyQualifiedName]); }, [domain.fullyQualifiedName]);
useEffect(() => { useEffect(() => {
fetchSubDomains(); fetchSubDomainsCount();
}, [domainFqn]); }, [domainFqn, fetchSubDomainsCount]);
const iconData = useMemo(() => { const iconData = useMemo(() => {
if (domain.style?.iconURL) { if (domain.style?.iconURL) {

View File

@ -11,15 +11,26 @@
* limitations under the License. * limitations under the License.
*/ */
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PAGE_SIZE_LARGE } from '../../../constants/constants';
import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum';
import { SearchIndex } from '../../../enums/search.enum';
import { Domain } from '../../../generated/entity/domains/domain'; import { Domain } from '../../../generated/entity/domains/domain';
import { usePaging } from '../../../hooks/paging/usePaging';
import { searchData } from '../../../rest/miscAPI';
import { formatDomainsResponse } from '../../../utils/APIUtils';
import { getEntityName } from '../../../utils/EntityUtils'; import { getEntityName } from '../../../utils/EntityUtils';
import { getDomainDetailsPath } from '../../../utils/RouterUtils'; import { getDomainDetailsPath } from '../../../utils/RouterUtils';
import {
escapeESReservedCharacters,
getEncodedFqn,
} from '../../../utils/StringsUtils';
import { ownerTableObject } from '../../../utils/TableColumn.util'; import { ownerTableObject } from '../../../utils/TableColumn.util';
import { showErrorToast } from '../../../utils/ToastUtils';
import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../common/Loader/Loader'; import Loader from '../../common/Loader/Loader';
import RichTextEditorPreviewerNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; import RichTextEditorPreviewerNew from '../../common/RichTextEditor/RichTextEditorPreviewNew';
@ -27,12 +38,93 @@ import Table from '../../common/Table/Table';
import { SubDomainsTableProps } from './SubDomainsTable.interface'; import { SubDomainsTableProps } from './SubDomainsTable.interface';
const SubDomainsTable = ({ const SubDomainsTable = ({
subDomains = [], domainFqn,
isLoading = false,
permissions, permissions,
onAddSubDomain, onAddSubDomain,
}: SubDomainsTableProps) => { }: SubDomainsTableProps) => {
const [subDomains, setSubDomains] = useState<Domain[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const encodedFqn = getEncodedFqn(escapeESReservedCharacters(domainFqn));
const {
currentPage,
pageSize,
paging,
handlePagingChange,
handlePageChange,
showPagination,
} = usePaging(PAGE_SIZE_LARGE);
const fetchSubDomains = useCallback(async () => {
try {
setIsLoading(true);
const res = await searchData(
'',
currentPage,
pageSize,
`(parent.fullyQualifiedName:"${encodedFqn}")`,
'',
'',
SearchIndex.DOMAIN,
false,
true
);
const data = formatDomainsResponse(res.data.hits.hits);
const totalCount = res.data.hits.total.value ?? 0;
setSubDomains(data);
handlePagingChange({
total: totalCount,
});
} catch (error) {
setSubDomains([]);
handlePagingChange({ total: 0 });
showErrorToast(
error as AxiosError,
t('server.entity-fetch-error', {
entity: t('label.sub-domain-lowercase'),
})
);
} finally {
setIsLoading(false);
}
}, [encodedFqn, t, currentPage, pageSize, handlePagingChange]);
const handlePagingClick = useCallback(
(params: { currentPage: number }) => {
handlePageChange(params.currentPage);
},
[handlePageChange]
);
const customPaginationProps = useMemo(
() => ({
currentPage,
pageSize,
paging,
pagingHandler: handlePagingClick,
showPagination,
isNumberBased: true,
isLoading,
}),
[
currentPage,
pageSize,
paging,
handlePagingClick,
showPagination,
isLoading,
]
);
useEffect(() => {
if (domainFqn) {
fetchSubDomains();
}
}, [domainFqn, fetchSubDomains]);
const columns: ColumnsType<Domain> = useMemo(() => { const columns: ColumnsType<Domain> = useMemo(() => {
const data = [ const data = [
@ -99,6 +191,7 @@ const SubDomainsTable = ({
<Table <Table
columns={columns} columns={columns}
containerClassName="m-md" containerClassName="m-md"
customPaginationProps={customPaginationProps}
dataSource={subDomains} dataSource={subDomains}
pagination={false} pagination={false}
rowKey="fullyQualifiedName" rowKey="fullyQualifiedName"

View File

@ -12,11 +12,9 @@
*/ */
import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface';
import { Domain } from '../../../generated/entity/domains/domain';
export interface SubDomainsTableProps { export interface SubDomainsTableProps {
subDomains?: Domain[]; domainFqn: string;
isLoading?: boolean;
permissions: OperationPermission; permissions: OperationPermission;
onAddSubDomain: () => void; onAddSubDomain: () => void;
} }

View File

@ -35,13 +35,12 @@ export interface DomainDetailPageTabProps {
domain: Domain; domain: Domain;
isVersionsView: boolean; isVersionsView: boolean;
domainPermission: OperationPermission; domainPermission: OperationPermission;
subDomains: Domain[]; subDomainsCount: number;
dataProductsCount: number; dataProductsCount: number;
assetCount: number; assetCount: number;
activeTab: EntityTabs; activeTab: EntityTabs;
onAddDataProduct: () => void; onAddDataProduct: () => void;
onAddSubDomain: (subDomain: CreateDomain) => Promise<void>; onAddSubDomain: (subDomain: CreateDomain) => Promise<void>;
isSubDomainsLoading: boolean;
queryFilter?: string | Record<string, unknown>; queryFilter?: string | Record<string, unknown>;
assetTabRef: React.RefObject<AssetsTabRef>; assetTabRef: React.RefObject<AssetsTabRef>;
dataProductsTabRef: React.RefObject<DataProductsTabRef>; dataProductsTabRef: React.RefObject<DataProductsTabRef>;

View File

@ -357,12 +357,11 @@ export const getDomainDetailTabs = ({
domain, domain,
isVersionsView, isVersionsView,
domainPermission, domainPermission,
subDomains, subDomainsCount,
dataProductsCount, dataProductsCount,
assetCount, assetCount,
activeTab, activeTab,
onAddDataProduct, onAddDataProduct,
isSubDomainsLoading,
queryFilter, queryFilter,
assetTabRef, assetTabRef,
dataProductsTabRef, dataProductsTabRef,
@ -389,7 +388,7 @@ export const getDomainDetailTabs = ({
{ {
label: ( label: (
<TabsLabel <TabsLabel
count={subDomains.length ?? 0} count={subDomainsCount ?? 0}
id={EntityTabs.SUBDOMAINS} id={EntityTabs.SUBDOMAINS}
isActive={activeTab === EntityTabs.SUBDOMAINS} isActive={activeTab === EntityTabs.SUBDOMAINS}
name={t('label.sub-domain-plural')} name={t('label.sub-domain-plural')}
@ -398,9 +397,8 @@ export const getDomainDetailTabs = ({
key: EntityTabs.SUBDOMAINS, key: EntityTabs.SUBDOMAINS,
children: ( children: (
<SubDomainsTable <SubDomainsTable
isLoading={isSubDomainsLoading} domainFqn={domain.fullyQualifiedName ?? ''}
permissions={domainPermission} permissions={domainPermission}
subDomains={subDomains}
onAddSubDomain={() => setShowAddSubDomainModal(true)} onAddSubDomain={() => setShowAddSubDomainModal(true)}
/> />
), ),