diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index cde323ae0e2f..dc475194b001 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -56,7 +56,6 @@ import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/Er import { OwnerLabel } from '../../../components/common/OwnerLabel/OwnerLabel.component'; import StatusBadge from '../../../components/common/StatusBadge/StatusBadge.component'; import { - API_RES_MAX_SIZE, DE_ACTIVE_COLOR, NO_DATA_PLACEHOLDER, PAGE_SIZE_LARGE, @@ -204,6 +203,12 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { const [searchTerm, setSearchTerm] = useState(''); const searchTermRef = useRef(searchTerm); searchTermRef.current = searchTerm; + // Live ref to the active glossary so an in-flight request can verify the + // glossary is still the current one before applying its response — the + // component stays mounted across glossary switches, so an uncancelled request + // for the previous glossary must not repopulate the table. + const activeGlossaryFqnRef = useRef(activeGlossary?.fullyQualifiedName); + activeGlossaryFqnRef.current = activeGlossary?.fullyQualifiedName; const [searchInput, setSearchInput] = useState(''); const [searchPaging, setSearchPaging] = useState<{ offset: number; @@ -283,6 +288,10 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { } setIsTableLoading(true); + // A fresh first-level fetch supersedes any in-flight expand-all, so clear + // its indicator; the superseded expand-all's own cleanup is sequence-gated + // and will not touch this loading state. + setIsExpandingAll(false); if (searchTerm) { setSearchPaging({ offset: 0, total: undefined, hasMore: true }); @@ -300,6 +309,7 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { const requestSeq = ++fetchRequestSeqRef.current; const fetchSearchTerm = searchTerm; const fetchStatusKey = selectedStatus.join(','); + const fetchGlossaryFqn = activeGlossary?.fullyQualifiedName; initializeLoadingStates(loadMore); try { @@ -311,34 +321,23 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { ? selectedStatus.filter((s) => s !== 'all').join(',') : undefined; - // Use search API if search term is present + // Fetch the page first and defer every shared-state mutation until the + // response is confirmed current by the stale-context guard below. + let searchOffset = 0; if (searchTerm) { - const currentOffset = loadMore ? searchPaging.offset : 0; + searchOffset = loadMore ? searchPaging.offset : 0; const response = await searchGlossaryTermsPaginated({ q: searchTerm, glossaryFqn: activeGlossary?.fullyQualifiedName, limit: PAGE_SIZE_LARGE, - offset: currentOffset, + offset: searchOffset, fields: 'children,relatedTerms,reviewers,owners,tags,usageCount,domains,extension,childrenCount', entityStatus: entityStatusParam, }); data = response.data; pagingResponse = response.paging; - - // Update search pagination state - const newOffset = currentOffset + PAGE_SIZE_LARGE; - const hasMore = - data.length === PAGE_SIZE_LARGE && - (pagingResponse?.total === undefined || - newOffset < pagingResponse?.total); - setSearchPaging({ - offset: newOffset, - total: pagingResponse?.total, - hasMore, - }); } else { - // Use regular listing API when no search term const response = await getFirstLevelGlossaryTermsPaginated( activeGlossary?.fullyQualifiedName || '', PAGE_SIZE_LARGE, @@ -347,39 +346,60 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { ); data = response.data; pagingResponse = response.paging; - - // Update regular paging state for next page - handlePagingChange((prev) => ({ - ...prev, - after: pagingResponse?.after, - total: pagingResponse?.total || prev.total, - })); } - // Apply the response only when it still matches the active search context. - // A response computed for a different (now-outdated) search term — e.g. a - // listing request that was in flight when the user typed a query, or a - // stale search-mode fetch after the query changed — is discarded so it - // cannot repopulate or clear the table against the user's current intent. - if ( - !data || - !Array.isArray(data) || + // A response is applied only while it still matches the context it was + // issued for (same request sequence, search term, status filter, and + // glossary). Re-checked at every await boundary — including the follow-up + // count request below — so a request that goes stale mid-flight touches + // no shared state (pagination cursor, term list, or count). + const isSuperseded = () => + requestSeq !== fetchRequestSeqRef.current || fetchSearchTerm !== searchTermRef.current || - fetchStatusKey !== selectedStatusRef.current.join(',') - ) { + fetchStatusKey !== selectedStatusRef.current.join(',') || + fetchGlossaryFqn !== activeGlossaryFqnRef.current; + + if (!data || !Array.isArray(data) || isSuperseded()) { return; } + // A status-filtered empty page needs a follow-up total for the empty + // state. Fetch it before applying anything and re-validate afterwards so + // the previous context's empty result cannot overwrite the active one. + let totalCount = data.length; if (data.length === 0 && isStatusFilterActive) { const countResponse = await getFirstLevelGlossaryTermsPaginated( - activeGlossary?.fullyQualifiedName || '', + fetchGlossaryFqn || '', 0 ); - setTotalTermsCount(countResponse.paging?.total ?? 0); + if (isSuperseded()) { + return; + } + totalCount = countResponse.paging?.total ?? 0; + } + + // Advance pagination state now that the response is confirmed current. + if (searchTerm) { + const newOffset = searchOffset + PAGE_SIZE_LARGE; + const hasMore = + data.length === PAGE_SIZE_LARGE && + (pagingResponse?.total === undefined || + newOffset < pagingResponse?.total); + setSearchPaging({ + offset: newOffset, + total: pagingResponse?.total, + hasMore, + }); } else { - setTotalTermsCount(data.length); + handlePagingChange((prev) => ({ + ...prev, + after: pagingResponse?.after, + total: pagingResponse?.total || prev.total, + })); } + setTotalTermsCount(totalCount); + const newTerms = data as ModifiedGlossary[]; if (loadMore) { @@ -411,53 +431,111 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { }; const fetchExpadedTree = async () => { + // Share the same monotonic sequence as fetchAllTerms so whichever term + // load starts last owns the shared table + loading state; a superseded + // expand-all then applies no rows and clears no loading flags. + const requestSeq = ++fetchRequestSeqRef.current; setIsTableLoading(true); setIsExpandingAll(true); const key = isGlossary ? 'glossary' : 'parent'; - const { data } = await getGlossaryTerms({ - [key]: activeGlossary?.id || '', - limit: API_RES_MAX_SIZE, - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.PARENT, - TabSpecificField.CHILDREN, - ], - }); - setGlossaryChildTerms(buildTree(data) as ModifiedGlossary[]); - const keys = data.reduce((prev, curr) => { - if (curr.children?.length) { - prev.push(curr.fullyQualifiedName ?? ''); + const requestedGlossaryFqn = activeGlossary?.fullyQualifiedName; + + try { + const allTerms: GlossaryTerm[] = []; + let after: string | undefined; + + do { + const { data, paging } = await getGlossaryTerms({ + [key]: activeGlossary?.id || '', + limit: PAGE_SIZE_LARGE, + after, + fields: [ + TabSpecificField.OWNERS, + TabSpecificField.PARENT, + TabSpecificField.CHILDREN, + ], + }); + allTerms.push(...data); + // Stop on no forward progress — an empty page or a cursor that did not + // advance — so termination never depends solely on the server nulling + // the token (a stuck cursor would otherwise loop forever). + const nextAfter = paging?.after; + const hasProgress = data.length > 0 && nextAfter !== after; + after = hasProgress ? nextAfter : undefined; + } while (after); + + // Discard the result if the glossary changed under us, or if a newer term + // load superseded this one while it was paging. + if ( + requestedGlossaryFqn !== activeGlossaryFqnRef.current || + requestSeq !== fetchRequestSeqRef.current + ) { + return; } - return prev; - }, [] as string[]); + setGlossaryChildTerms(buildTree(allTerms) as ModifiedGlossary[]); + const keys = allTerms.reduce((prev, curr) => { + if (curr.children?.length) { + prev.push(curr.fullyQualifiedName ?? ''); + } - setExpandedRowKeys(keys); - setIsTableLoading(false); - setIsExpandingAll(false); + return prev; + }, [] as string[]); + + setExpandedRowKeys(keys); + } catch (error) { + if (requestSeq === fetchRequestSeqRef.current) { + showErrorToast(error as AxiosError); + } + } finally { + // Only the latest term load owns the shared loading flags. + if (requestSeq === fetchRequestSeqRef.current) { + setIsTableLoading(false); + setIsExpandingAll(false); + } + } }; const fetchAllTasks = useCallback(async () => { - if (!activeGlossary?.fullyQualifiedName) { + const glossaryFqn = activeGlossary?.fullyQualifiedName; + if (!glossaryFqn) { return; } try { - const { data } = await listTasks({ - status: TaskEntityStatus.Open, - category: TaskCategory.Approval, - type: TaskEntityType.RequestApproval, - limit: API_RES_MAX_SIZE, - fields: 'about,assignees', - }); + const tasks: Task[] = []; + let after: string | undefined; + + do { + const { data, paging } = await listTasks({ + status: TaskEntityStatus.Open, + category: TaskCategory.Approval, + type: TaskEntityType.RequestApproval, + aboutEntity: glossaryFqn, + limit: PAGE_SIZE_LARGE, + after, + fields: 'about,assignees', + }); + tasks.push(...data); + // Stop on no forward progress (empty page or non-advancing cursor) so + // termination never depends solely on the server nulling the token. + const nextAfter = paging?.after; + const hasProgress = data.length > 0 && nextAfter !== after; + after = hasProgress ? nextAfter : undefined; + } while (after); + + // Discard the result if the user switched glossaries while paging. + if (glossaryFqn !== activeGlossaryFqnRef.current) { + return; + } // Glossary approvals are now workflow-managed RequestApproval tasks created // for each glossary term, not legacy glossary-root tasks. - const tasksByTerm = data.reduce( + const tasksByTerm = tasks.reduce( (acc: Record, task: Task) => { const termFQN = task.about?.fullyQualifiedName; const isGlossaryTermTask = task.about?.type === EntityType.GLOSSARY_TERM && - termFQN?.startsWith(`${activeGlossary.fullyQualifiedName}.`); + termFQN?.startsWith(`${glossaryFqn}.`); if (isGlossaryTermTask && termFQN) { const entityLink = `<#E::${EntityType.GLOSSARY_TERM}::${termFQN}>`; @@ -485,26 +563,18 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { useEffect(() => { const currentFQN = activeGlossary?.fullyQualifiedName; - if ( - currentFQN && - !isLoadingMore && - currentFQN !== previousGlossaryFQN && - !toggleExpandBtn && - !searchTerm // Don't fetch if there's an active search - ) { - // Clear existing terms when switching glossaries + if (currentFQN && !isLoadingMore && currentFQN !== previousGlossaryFQN) { + // Reload whenever the glossary changes — including while a search is + // active, in which case fetchAllTerms re-runs the search scoped to the + // new glossary. Reset to the collapsed, paginated view so the new + // glossary starts scrollable and no stuck expand-all flag carries over. + setToggleExpandBtn(false); setGlossaryChildTerms([]); handlePagingChange((prev) => ({ ...prev, after: undefined })); setPreviousGlossaryFQN(currentFQN); fetchAllTerms(); } - }, [ - activeGlossary?.fullyQualifiedName, - isLoadingMore, - previousGlossaryFQN, - toggleExpandBtn, - searchTerm, - ]); + }, [activeGlossary?.fullyQualifiedName, isLoadingMore, previousGlossaryFQN]); // Clear terms when component unmounts useEffect(() => { @@ -1130,8 +1200,14 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { }; const toggleExpandAll = useCallback(async () => { - setToggleExpandBtn((prev) => !prev); - if (expandedRowKeys.length === expandableKeys.length) { + // `toggleExpandBtn` marks whether the fully-expanded tree is currently + // shown; the scroll handlers gate the collapsed-view infinite scroll on + // `!toggleExpandBtn`. Reflect the resulting mode rather than blindly + // flipping — a blind toggle leaves the flag stuck `true` after an odd + // number of clicks, which permanently disables scroll-to-load-more. + const isCollapsing = expandedRowKeys.length === expandableKeys.length; + setToggleExpandBtn(!isCollapsing); + if (isCollapsing) { // Collapse all - immediate UI update setExpandedRowKeys([]); fetchAllTerms(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx index c352296323fb..49096e7d4f8b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx @@ -761,7 +761,8 @@ describe('Test GlossaryTermTab component', () => { status: 'Open', category: 'Approval', type: 'RequestApproval', - limit: 100000, + aboutEntity: mockedGlossaryTerms[0].fullyQualifiedName, + limit: 50, fields: 'about,assignees', }) ); @@ -779,7 +780,8 @@ describe('Test GlossaryTermTab component', () => { status: 'Open', category: 'Approval', type: 'RequestApproval', - limit: 100000, + aboutEntity: mockedGlossaryTerms[0].fullyQualifiedName, + limit: 50, fields: 'about,assignees', }) );