diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index a0d3514c480..d31596518f8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -532,3 +532,44 @@ export const executeWithRetry = async ( } } }; + +export const readElementInListWithScroll = async ( + page: Page, + locator: Locator, + hierarchyElementLocator: Locator +) => { + const element = locator; + + // Reset scroll position to top before starting pagination + await hierarchyElementLocator.hover(); + await page.mouse.wheel(0, -99999); + + await page.waitForTimeout(1000); + + // Retry mechanism for pagination + let elementCount = await element.count(); + let retryCount = 0; + const maxRetries = 10; + + while (elementCount === 0 && retryCount < maxRetries) { + await hierarchyElementLocator.hover(); + await page.mouse.wheel(0, 1000); + await page.waitForTimeout(500); + + // Create fresh locator and check if the article is now visible after this retry + const freshArticle = locator; + const count = await freshArticle.count(); + + // Check if the article is now visible after this retry + elementCount = count; + + // If we found the element, validate it and break out of the loop + if (count > 0) { + await expect(freshArticle).toBeVisible(); + + return; // Exit the function early since we found and validated the article + } + + retryCount++; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index c8d1f19f587..f57f8b7065f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -26,6 +26,7 @@ import { TagClass } from '../support/tag/TagClass'; import { clickOutside, descriptionBox, + readElementInListWithScroll, redirectToHomePage, toastNotification, uuid, @@ -486,8 +487,24 @@ export const assignCertification = async ( certification: TagClass, endpoint: string ) => { + const certificationResponse = page.waitForResponse( + '/api/v1/tags?parent=Certification&limit=50' + ); await page.getByTestId('edit-certification').click(); + await certificationResponse; + await page.waitForSelector('.certification-card-popover', { + state: 'visible', + }); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + await readElementInListWithScroll( + page, + page.getByTestId( + `radio-btn-${certification.responseData.fullyQualifiedName}` + ), + page.locator('[data-testid="certification-cards"] .ant-radio-group') + ); + await page .getByTestId(`radio-btn-${certification.responseData.fullyQualifiedName}`) .click(); @@ -503,6 +520,9 @@ export const assignCertification = async ( export const removeCertification = async (page: Page, endpoint: string) => { await page.getByTestId('edit-certification').click(); + await page.waitForSelector('.certification-card-popover', { + state: 'visible', + }); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`); await page.getByTestId('clear-certification').click(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx index 75fcb8e93cc..4694f2d3884 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx @@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as CertificationIcon } from '../../assets/svg/ic-certification.svg'; import { Tag } from '../../generated/entity/classification/tag'; +import { Paging } from '../../generated/type/paging'; import { getTags } from '../../rest/tagAPI'; import { getEntityName } from '../../utils/EntityUtils'; import { stringToHTML } from '../../utils/StringsUtils'; @@ -35,59 +36,84 @@ const Certification = ({ onClose, }: CertificationProps) => { const { t } = useTranslation(); - const popoverRef = useRef(null); + const popoverRef = useRef<{ close: () => void } | null>(null); const [isLoadingCertificationData, setIsLoadingCertificationData] = useState(false); + const [hasContentLoading, setHasContentLoading] = useState(false); const [certifications, setCertifications] = useState>([]); const [selectedCertification, setSelectedCertification] = useState( currentCertificate ?? '' ); - const certificationCardData = useMemo(() => { - return ( - - {certifications.map((certificate) => { - const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? ''); - const title = getEntityName(certificate); - const { id, fullyQualifiedName, description } = certificate; + const [paging, setPaging] = useState({} as Paging); + const [currentPage, setCurrentPage] = useState(1); - return ( -
{ - setSelectedCertification(fullyQualifiedName ?? ''); - }}> - -
- {tagSrc ? ( - {title} - ) : ( -
- -
- )} -
- - {title} - - - {stringToHTML(description)} - -
-
-
- ); - })} -
- ); - }, [certifications, selectedCertification]); + const getCertificationData = async (page = 1, append = false) => { + if (page === 1) { + setIsLoadingCertificationData(true); + } else { + setHasContentLoading(true); + } + + try { + const response = await getTags({ + parent: 'Certification', + limit: 50, + after: page > 1 ? paging.after : undefined, + }); + + const { data, paging: newPaging } = response; + + // Sort certifications with Gold, Silver, Bronze first (only for initial load) + const sortedData = + page === 1 + ? [...data].sort((a, b) => { + const order: Record = { + Gold: 0, + Silver: 1, + Bronze: 2, + }; + + const aName = getEntityName(a); + const bName = getEntityName(b); + + const aOrder = order[aName] ?? 3; + const bOrder = order[bName] ?? 3; + + return aOrder - bOrder; + }) + : data; + + if (append) { + setCertifications((prev) => [...prev, ...sortedData]); + } else { + setCertifications(sortedData); + } + + setPaging(newPaging); + setCurrentPage(page); + } catch (err) { + showErrorToast( + err as AxiosError, + t('server.entity-fetch-error', { + entity: t('label.certification-plural-lowercase'), + }) + ); + } finally { + setIsLoadingCertificationData(false); + setHasContentLoading(false); + } + }; + + const handleScroll = async (e: React.UIEvent) => { + const { currentTarget } = e; + const isAtBottom = + currentTarget.scrollTop + currentTarget.offsetHeight >= + currentTarget.scrollHeight - 1; // -1 for precision tolerance + + if (isAtBottom && paging.after && !hasContentLoading) { + await getCertificationData(currentPage + 1, true); + } + }; const updateCertificationData = async (value?: string) => { setIsLoadingCertificationData(true); @@ -98,43 +124,60 @@ const Certification = ({ setIsLoadingCertificationData(false); popoverRef.current?.close(); }; - const getCertificationData = async () => { - setIsLoadingCertificationData(true); - try { - const { data } = await getTags({ - parent: 'Certification', - limit: 50, - }); - // Sort certifications with Gold, Silver, Bronze first - const sortedData = [...data].sort((a, b) => { - const order: Record = { - Gold: 0, - Silver: 1, - Bronze: 2, - }; + const certificationCardData = useMemo(() => { + return ( +
+ + {certifications.map((certificate) => { + const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? ''); + const title = getEntityName(certificate); + const { id, fullyQualifiedName, description } = certificate; - const aName = getEntityName(a); - const bName = getEntityName(b); - - const aOrder = order[aName] ?? 3; - const bOrder = order[bName] ?? 3; - - return aOrder - bOrder; - }); - - setCertifications(sortedData); - } catch (err) { - showErrorToast( - err as AxiosError, - t('server.entity-fetch-error', { - entity: t('label.certification-plural-lowercase'), - }) - ); - } finally { - setIsLoadingCertificationData(false); - } - }; + return ( +
{ + setSelectedCertification(fullyQualifiedName ?? ''); + }}> + +
+ {tagSrc ? ( + {title} + ) : ( +
+ +
+ )} +
+ + {title} + + + {stringToHTML(description)} + +
+
+
+ ); + })} +
+ {hasContentLoading && ( +
+ +
+ )} +
+ ); + }, [certifications, selectedCertification, hasContentLoading, handleScroll]); const handleCloseCertification = async () => { popoverRef.current?.close(); @@ -143,16 +186,19 @@ const Certification = ({ const onOpenChange = (visible: boolean) => { if (visible) { - getCertificationData(); + getCertificationData(1); setSelectedCertification(currentCertificate); + setCurrentPage(1); + setPaging({} as Paging); } else { setSelectedCertification(''); + setCertifications([]); } }; useEffect(() => { if (popoverProps?.open && certifications.length === 0) { - getCertificationData(); + getCertificationData(1); } }, [popoverProps?.open]);