diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainDataProductsPagination.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainDataProductsPagination.spec.ts new file mode 100644 index 000000000000..cf8f4a2e43dc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainDataProductsPagination.spec.ts @@ -0,0 +1,241 @@ +/* + * 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 { Request, Response } from '@playwright/test'; +import { DOMAIN_TAGS } from '../../constant/config'; +import { DataProduct } from '../../support/domain/DataProduct'; +import { Domain } from '../../support/domain/Domain'; +import { expect, test } from '../../support/fixtures/userPages'; +import { performAdminLogin } from '../../utils/admin'; +import { clickIgnoringToasts, uuid } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { waitForSearchIndexed } from '../../utils/polling'; + +let source: Domain; +let destination: Domain; +let products: DataProduct[] = []; +let prefix: string; + +const isProductPage = (request: Request, domain: Domain, from?: number) => { + const url = new URL(request.url()); + + return ( + request.method() === 'GET' && + url.pathname.endsWith('/search/query') && + url.searchParams.get('index') === 'dataProduct' && + url.searchParams.get('size') === '25' && + (url.searchParams.get('query_filter') ?? '').includes( + domain.responseData.fullyQualifiedName ?? domain.data.name + ) && + (from === undefined || url.searchParams.get('from') === String(from)) + ); +}; + +const productResponse = + (domain: Domain, from: number) => (response: Response) => + isProductPage(response.request(), domain, from); + +test.describe( + 'Domain data product pagination', + { tag: ['@Pages', DOMAIN_TAGS.GOVERNANCE] }, + () => { + test.beforeAll(async ({ browser }) => { + test.setTimeout(180_000); + prefix = `pagination_${uuid()}`; + source = new Domain({ + name: `${prefix}_a`, + displayName: `${prefix} A`, + description: 'Pagination source', + domainType: 'Aggregate', + }); + destination = new Domain({ + name: `${prefix}_b`, + displayName: `${prefix} B`, + description: 'Pagination destination', + domainType: 'Aggregate', + }); + products = Array.from( + { length: 51 }, + (_, index) => + new DataProduct( + [source], + `${prefix}_product_${String(index).padStart(2, '0')}` + ) + ); + products.push( + new DataProduct([destination], `${prefix}_destination_product`) + ); + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + await Promise.all([ + source.create(apiContext), + destination.create(apiContext), + ]); + await Promise.all( + products.map((product) => product.create(apiContext)) + ); + await Promise.all([ + ...[source, destination].map((domain) => + waitForSearchIndexed( + apiContext, + domain.responseData.fullyQualifiedName, + 'domain' + ) + ), + ...products.map((product) => + waitForSearchIndexed( + apiContext, + product.responseData.fullyQualifiedName, + 'dataProduct' + ) + ), + ]); + } finally { + await afterAction(); + } + }); + + test.afterAll(async ({ browser }) => { + test.setTimeout(180_000); + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + const results = await Promise.allSettled( + products.map((product) => product.delete(apiContext)) + ); + expect(results.filter(({ status }) => status === 'rejected')).toEqual( + [] + ); + } finally { + try { + const results = await Promise.allSettled( + [source, destination].map((domain) => domain.delete(apiContext)) + ); + expect(results.filter(({ status }) => status === 'rejected')).toEqual( + [] + ); + } finally { + await afterAction(); + } + } + }); + + test('switching from page three shows the next domain first page and preserves page size', async ({ + adminPage, + }) => { + await adminPage.setViewportSize({ width: 1920, height: 1080 }); + await adminPage.goto( + `/domain?q=${prefix}&domainType.keyword=Aggregate&pageSize=25&paginationTest=retained`, + { waitUntil: 'domcontentloaded' } + ); + await adminPage.getByRole('radio', { name: 'tree', exact: true }).check(); + await adminPage + .getByRole('row', { name: source.data.displayName, exact: true }) + .click(); + await expect( + adminPage.getByTestId('entity-header-display-name') + ).toHaveText(source.data.displayName); + await adminPage.getByTestId('assets').click(); + + await test.step('Page through the source domain data products', async () => { + const firstPage = adminPage.waitForResponse(productResponse(source, 0)); + await adminPage.getByTestId('data_products').click(); + expect((await firstPage).status()).toBe(200); + await expect(adminPage.getByTestId('page-indicator')).toContainText( + 'Page 1 of 3' + ); + for (const from of [25, 50]) { + const nextPage = adminPage.waitForResponse( + productResponse(source, from) + ); + await clickIgnoringToasts(adminPage.getByTestId('next')); + expect((await nextPage).status()).toBe(200); + } + await expect(adminPage.getByTestId('page-indicator')).toContainText( + 'Page 3 of 3' + ); + await expect(adminPage).toHaveURL(/currentPage=3/); + }); + + await test.step('Switch domains and verify the first page from the real search API', async () => { + const destinationRequests: Request[] = []; + const recordRequest = (request: Request) => { + if (isProductPage(request, destination)) + destinationRequests.push(request); + }; + adminPage.on('request', recordRequest); + try { + await adminPage + .getByRole('row', { + name: destination.data.displayName, + exact: true, + }) + .click(); + await expect( + adminPage.getByTestId('entity-header-display-name') + ).toHaveText(destination.data.displayName); + await adminPage.getByTestId('assets').click(); + const firstPage = adminPage.waitForResponse( + productResponse(destination, 0) + ); + await adminPage.getByTestId('data_products').click(); + expect((await firstPage).status()).toBe(200); + await waitForAllLoadersToDisappear(adminPage); + await expect( + adminPage.getByTestId(`explore-card-${prefix}_destination_product`) + ).toBeVisible(); + await expect(adminPage.getByTestId('page-indicator')).toContainText( + 'Page 1 of 1' + ); + await expect(adminPage.getByTestId('previous')).toBeDisabled(); + await expect( + adminPage.getByTestId('page-size-selection-dropdown') + ).toHaveText('25 / Page'); + expect(destinationRequests.length).toBeGreaterThan(0); + expect( + destinationRequests.every( + (request) => + new URL(request.url()).searchParams.get('from') === '0' + ) + ).toBe(true); + const url = new URL(adminPage.url()); + expect(url.searchParams.get('currentPage')).toBeNull(); + expect(url.searchParams.get('pageSize')).toBe('25'); + expect(url.searchParams.get('q')).toBe(prefix); + expect(url.searchParams.get('domainType.keyword')).toBe('Aggregate'); + expect(url.searchParams.get('paginationTest')).toBe('retained'); + } finally { + adminPage.off('request', recordRequest); + } + }); + }); + + test('a standalone domain details link retains its requested third page', async ({ + adminPage, + }) => { + const pageThree = adminPage.waitForResponse(productResponse(source, 50)); + await adminPage.goto( + `/domain/${encodeURIComponent( + source.responseData.fullyQualifiedName ?? source.data.name + )}/data_products?currentPage=3&pageSize=25`, + { waitUntil: 'domcontentloaded' } + ); + expect((await pageThree).status()).toBe(200); + await expect(adminPage.getByTestId('page-indicator')).toContainText( + 'Page 3 of 3' + ); + await expect( + adminPage.getByTestId('page-size-selection-dropdown') + ).toHaveText('25 / Page'); + await expect(adminPage).toHaveURL(/currentPage=3/); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.integration.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.integration.test.tsx new file mode 100644 index 000000000000..45469ed423f2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.integration.test.tsx @@ -0,0 +1,491 @@ +/* + * 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 type { Selection } from '@openmetadata/ui-core-components'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { AxiosRequestConfig } from 'axios'; +import { ReactNode } from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { EntityTabs } from '../../../enums/entity.enum'; +import { usePersistentStorage } from '../../../hooks/currentUserStore/useCurrentUserStore'; +import { DOMAINS_LIST } from '../../../mocks/Domains.mock'; +import { ENTITY_PERMISSIONS } from '../../../mocks/Permissions.mock'; +import APIClient from '../../../rest/axiosClient'; +import { getQueryFilterForDataProducts } from '../../../utils/DomainFilterUtils'; +import { useUrlState } from '../../common/atoms/data/useUrlState'; +import { DomainDetailsProps } from '../../Domain/DomainDetails/DomainDetails.interface'; +import DataProductsTab from '../../Domain/DomainTabs/DataProductsTab/DataProductsTab.component'; +import DomainTreeView from './DomainTreeView'; + +let mockSelectDomain: (selection: Selection) => void; +let mockSetFilters: ReturnType['setFilters']; + +jest.mock('@openmetadata/ui-core-components', () => { + const actual = jest.requireActual('@openmetadata/ui-core-components'); + const Tree = ({ + children, + onSelectionChange, + }: { + children: ReactNode; + onSelectionChange: typeof mockSelectDomain; + }) => { + mockSelectDomain = onSelectionChange; + + return
{children}
; + }; + const Item = ({ children }: { children: ReactNode }) =>
{children}
; + + return { + ...actual, + Tree: Object.assign(Tree, { + Item, + Header: Item, + ItemContent: Item, + Section: Item, + ExpandButton: () => null, + }), + }; +}); + +jest.mock('../../../rest/axiosClient', () => ({ get: jest.fn() })); +jest.mock('../../../hooks/useApplicationStore', () => ({ + useApplicationStore: ( + selector?: (state: { currentUser: { id: string; name: string } }) => unknown + ) => { + const state = { + currentUser: { id: 'domain-paging-user', name: 'domain-paging-user' }, + }; + + return selector ? selector(state) : state; + }, +})); +jest.mock('../../../hooks/useDomainStore', () => ({ + useDomainStore: () => ({ userDomains: [], isDomainRestricted: false }), +})); +jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: () => ({ + permissions: { domain: ENTITY_PERMISSIONS }, + }), +})); + +// Keep real tab remounts and URL paging, omitting unrelated domain widgets. +jest.mock('../../Domain/DomainDetails/DomainDetails.component', () => { + const { Tabs } = jest.requireActual('@openmetadata/ui-core-components'); + const Products = jest.requireActual< + typeof import('../../Domain/DomainTabs/DataProductsTab/DataProductsTab.component') + >( + '../../Domain/DomainTabs/DataProductsTab/DataProductsTab.component' + ).default; + + return ({ domain, activeTab, onActiveTabChange }: DomainDetailsProps) => ( + + onActiveTabChange?.(key as EntityTabs) + }> + + Assets + Data products + + + {domain.name} + + + + + + ); +}); +jest.mock( + '../../common/ResizablePanels/ResizableLeftPanels', + () => + ({ + firstPanel, + secondPanel, + }: { + firstPanel: { children: ReactNode }; + secondPanel: { children: ReactNode }; + }) => + ( + <> + {firstPanel.children} + {secondPanel.children} + + ) +); +jest.mock( + '../../common/ResizablePanels/ResizablePanels', + () => + ({ firstPanel }: { firstPanel: { children: ReactNode } }) => + firstPanel.children +); +jest.mock( + '../../ExploreV1/ExploreSearchCard/ExploreSearchCard', + () => + ({ source }: { source: { name: string } }) => +
{source.name}
+); + +type ProductRequest = { domain: string; from: number; size: number }; +const requests: ProductRequest[] = []; +let secondDomainTotal = 1; +const domains = DOMAINS_LIST.map((domain) => ({ + ...domain, + children: [], + childrenCount: 0, +})); +const mockGet = APIClient.get as jest.Mock; +const FILTER_KEYS = ['domainType']; +const UrlDrivenTree = () => { + const { urlState, setFilters } = useUrlState({ filterKeys: FILTER_KEYS }); + mockSetFilters = setFilters; + + return ( + + ); +}; +const openProducts = async () => + fireEvent.click(await screen.findByRole('tab', { name: 'Data products' })); +const renderTree = ( + pathname = '/domain', + search = '?currentPage=3&pageSize=25', + withUrlState = false +) => { + window.history.replaceState({}, '', `${pathname}${search}`); + + return render( + + {withUrlState ? : } + + ); +}; + +describe('Domain tree pagination with the real URL paging hooks', () => { + beforeEach(() => { + requests.length = 0; + secondDomainTotal = 1; + usePersistentStorage.getState().clearUserPreference('domain-paging-user'); + mockGet.mockImplementation( + async (url: string, config?: AxiosRequestConfig) => { + if (url === '/domains/hierarchy') { + return { data: { data: domains, paging: { total: domains.length } } }; + } + if (url.startsWith('/domains/name/')) { + return { + data: domains.find( + (domain) => + domain.fullyQualifiedName === + decodeURIComponent(url.slice('/domains/name/'.length)) + ), + }; + } + if (url.startsWith('/search/query')) { + if (config?.params.index === 'domain') { + return { + data: String(config.params.query_filter).includes( + 'Consumer-aligned' + ) + ? [domains[1]] + : domains, + }; + } + const domain = String(config?.params.query_filter).includes('Domain2') + ? 'Domain2' + : 'Domain1'; + const total = domain === 'Domain2' ? secondDomainTotal : 75; + const from = Number(config?.params.from); + requests.push({ domain, from, size: Number(config?.params.size) }); + + return { + data: { + hits: { + hits: + from < total + ? [ + { + _source: { + id: `${domain}-product`, + name: `${domain} product`, + fullyQualifiedName: `${domain}.product`, + }, + }, + ] + : [], + total: { value: total }, + }, + }, + }; + } + + throw new Error(`Unexpected API request: ${url}`); + } + ); + }); + + afterEach(() => window.history.replaceState({}, '', '/')); + + it('keeps the clicked domain selected when paging changes recreate active URL filters', async () => { + renderTree( + '/domain', + '?q=Domain&domainType=Aggregate¤tPage=3&pageSize=25', + true + ); + await screen.findByTestId('selected-domain'); + + await act(async () => mockSelectDomain(new Set(['Domain2']))); + + expect(await screen.findByTestId('selected-domain')).toHaveTextContent( + 'Domain2' + ); + + await openProducts(); + + expect(await screen.findByText('Domain2 product')).toBeInTheDocument(); + expect(requests).toEqual([{ domain: 'Domain2', from: 0, size: 25 }]); + }); + + it('reloads the domain tree when an active filter value changes', async () => { + renderTree('/domain', '?q=Domain&domainType=Aggregate', true); + await screen.findByTestId('selected-domain'); + + await act(async () => + mockSetFilters([ + { + key: 'domainType', + label: 'Domain type', + value: [{ key: 'Consumer-aligned', label: 'Consumer-aligned' }], + }, + ]) + ); + + expect(await screen.findByTestId('selected-domain')).toHaveTextContent( + 'Domain2' + ); + expect(new URLSearchParams(window.location.search).get('domainType')).toBe( + 'Consumer-aligned' + ); + }); + + it('starts on page one when a filter change selects another domain', async () => { + renderTree( + '/domain', + '?q=Domain&domainType=Aggregate¤tPage=3&pageSize=25', + true + ); + + expect(await screen.findByTestId('selected-domain')).toHaveTextContent( + 'Domain1' + ); + + await act(async () => + mockSetFilters([ + { + key: 'domainType', + label: 'Domain type', + value: [{ key: 'Consumer-aligned', label: 'Consumer-aligned' }], + }, + ]) + ); + + expect(await screen.findByTestId('selected-domain')).toHaveTextContent( + 'Domain2' + ); + + await openProducts(); + + expect(await screen.findByText('Domain2 product')).toBeInTheDocument(); + expect(requests).toEqual([{ domain: 'Domain2', from: 0, size: 25 }]); + expect(new URLSearchParams(window.location.search).has('currentPage')).toBe( + false + ); + }); + + it.each([1, 75])( + 'starts the new domain on page one when it has %i products', + async (total) => { + secondDomainTotal = total; + renderTree(); + await openProducts(); + + expect(await screen.findByText('Domain1 product')).toBeInTheDocument(); + expect(requests).toContainEqual({ + domain: 'Domain1', + from: 50, + size: 25, + }); + + act(() => mockSelectDomain(new Set(['Domain2']))); + await waitFor(() => + expect(screen.getByTestId('selected-domain')).toHaveTextContent( + 'Domain2' + ) + ); + await openProducts(); + + await waitFor(() => + expect(requests.some(({ domain }) => domain === 'Domain2')).toBe(true) + ); + const nextDomainRequests = requests.filter( + ({ domain }) => domain === 'Domain2' + ); + + expect(nextDomainRequests.length).toBeGreaterThan(0); + expect( + nextDomainRequests.every(({ from, size }) => from === 0 && size === 25) + ).toBe(true); + expect(await screen.findByText('Domain2 product')).toBeInTheDocument(); + expect( + screen.queryByText('label.no-data-products-yet') + ).not.toBeInTheDocument(); + expect( + mockGet.mock.calls + .filter(([url]) => url.startsWith('/search/query')) + .pop()?.[1].params.query_filter + ).toBe(JSON.stringify(getQueryFilterForDataProducts('Domain2'))); + } + ); + + it.each(['/domain', '/data-marketplace/domains'])( + 'preserves query settings and history on %s', + async (pathname) => { + renderTree( + pathname, + '?currentPage=3&pageSize=50&cursorType=after&cursorValue=A%2BB&keep=a%2Bb&keep=two&filters%5Bowners%5D%5B%5D=owner#members/probe' + ); + await screen.findByTestId('selected-domain'); + const historyLength = window.history.length; + + act(() => mockSelectDomain(new Set(['Domain2']))); + + await waitFor(() => + expect( + new URLSearchParams(window.location.search).has('currentPage') + ).toBe(false) + ); + const params = new URLSearchParams(window.location.search); + + expect(params.has('cursorType')).toBe(false); + expect(params.has('cursorValue')).toBe(false); + expect(params.get('pageSize')).toBe('50'); + expect(params.getAll('keep')).toEqual(['a+b', 'two']); + expect(params.getAll('filters[owners][]')).toEqual(['owner']); + expect(window.location.pathname).toBe(pathname); + expect(window.location.hash).toBe('#members/probe'); + expect(window.history.length).toBe(historyLength); + } + ); + + it.each([ + 'all', + new Set(), + new Set(['Domain1']), + new Set(['Domain1__load_more']), + ])('keeps pagination for an ignored selection %p', async (selection) => { + renderTree(); + await screen.findByTestId('selected-domain'); + const location = window.location.href; + + act(() => mockSelectDomain(selection)); + + expect(window.location.href).toBe(location); + }); + + it.each(['cursorType=after', 'cursorValue=old', 'page=3'])( + 'clears an isolated paging parameter %s', + async (param) => { + renderTree('/domain', `?${param}&pageSize=25`); + await screen.findByTestId('selected-domain'); + + act(() => mockSelectDomain(new Set(['Domain2']))); + + await waitFor(() => expect(window.location.search).toBe('?pageSize=25')); + } + ); + + it('does not rewrite the URL when the domain switch has no paging position to clear', async () => { + renderTree('/domain', '?pageSize=25&keep=two%20words#probe'); + await screen.findByTestId('selected-domain'); + const location = window.location.href; + + act(() => mockSelectDomain(new Set(['Domain2']))); + + await waitFor(() => + expect(screen.getByTestId('selected-domain')).toHaveTextContent('Domain2') + ); + + expect(window.location.href).toBe(location); + }); + + it('preserves the saved page size when the URL has no size', async () => { + usePersistentStorage + .getState() + .setUserPreference('domain-paging-user', { globalPageSize: 50 }); + renderTree('/domain', '?currentPage=3'); + await screen.findByTestId('selected-domain'); + + act(() => mockSelectDomain(new Set(['Domain2']))); + await waitFor(() => + expect(screen.getByTestId('selected-domain')).toHaveTextContent('Domain2') + ); + await openProducts(); + + expect(await screen.findByText('Domain2 product')).toBeInTheDocument(); + expect(requests).toEqual([{ domain: 'Domain2', from: 0, size: 50 }]); + expect( + usePersistentStorage.getState().getUserPreference('domain-paging-user') + .globalPageSize + ).toBe(50); + }); + + it('honors standalone detail links to a later page', async () => { + window.history.replaceState( + {}, + '', + '/domain/Domain2/data_products?currentPage=3&pageSize=25' + ); + secondDomainTotal = 75; + render( + + + + } + path="/domain/:fqn/:tab" + /> + + + ); + + expect(await screen.findByText('Domain2 product')).toBeInTheDocument(); + expect(requests).toEqual([{ domain: 'Domain2', from: 50, size: 25 }]); + expect(new URLSearchParams(window.location.search).get('currentPage')).toBe( + '3' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.tsx index 1b35a1c88008..184d1de54005 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/components/DomainTreeView.tsx @@ -30,7 +30,7 @@ import { compare, Operation as JsonPathOperation } from 'fast-json-patch'; import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { EntityTabs, TabSpecificField } from '../../../enums/entity.enum'; import { Domain } from '../../../generated/entity/domains/domain'; @@ -140,6 +140,7 @@ const DomainTreeView = ({ openAddDomainDrawer, onClearSearch, }: DomainTreeViewProps) => { + const location = useLocation(); const navigate = useNavigate(); const { t } = useTranslation(); const { currentUser } = useApplicationStore(); @@ -196,7 +197,7 @@ const DomainTreeView = ({ return undefined; } - return domainBuildESQuery(filters); + return JSON.stringify(domainBuildESQuery(filters)); }, [filters, hasActiveFilters]); useEffect(() => { @@ -276,7 +277,7 @@ const DomainTreeView = ({ const results: Domain[] = await searchDomains( encodedValue, 1, - queryFilter + queryFilter ? JSON.parse(queryFilter) : undefined ); const filteredResults = @@ -542,8 +543,8 @@ const DomainTreeView = ({ ); // Keyed on `queryFilter`, not `hasActiveFilters`: swapping one active filter // for another leaves the boolean true, so the tree would keep showing the - // previous result set. `urlState` is memoised on the search params, so the - // filter object only changes identity when the URL does. + // previous result set. A serialized filter also keeps paging-only URL changes + // from rerunning the search and selecting its first result. useEffect(() => { if (searchQuery || hasActiveFilters) { searchDomain(searchQuery); @@ -566,6 +567,39 @@ const DomainTreeView = ({ } }, [selectedFqn]); + // The tree swaps the right pane without changing the route, so the URL page + // position (`page` is the sub-domain listing's key) would carry over to the + // next domain instead of resetting like a route change does. + const clearPagingParams = useCallback(() => { + const searchParams = new URLSearchParams(location.search); + const pagingKeys = ['currentPage', 'cursorType', 'cursorValue', 'page']; + if (!pagingKeys.some((key) => searchParams.has(key))) { + return; + } + pagingKeys.forEach((key) => searchParams.delete(key)); + navigate( + { search: searchParams.toString(), hash: location.hash }, + { replace: true } + ); + }, [location.hash, location.search, navigate]); + + // Keyed on the selection itself so every path that moves it (tree click, + // search/filter reselect, delete, sub-domain navigation) resets paging. The + // first selection, e.g. after a reload, keeps its page. + const lastSelectedFqnRef = useRef(null); + useEffect(() => { + if (!selectedFqn) { + return; + } + if ( + lastSelectedFqnRef.current && + lastSelectedFqnRef.current !== selectedFqn + ) { + clearPagingParams(); + } + lastSelectedFqnRef.current = selectedFqn; + }, [selectedFqn, clearPagingParams]); + const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === 'all') {